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())
|
createdAt DateTime @default(now())
|
||||||
actor User @relation(fields: [actorId], references: [id])
|
actor User @relation(fields: [actorId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model BlogPost {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
title String
|
||||||
|
content String @db.Text
|
||||||
|
author String
|
||||||
|
publishDate String
|
||||||
|
thumbnailUrl String?
|
||||||
|
readTime String?
|
||||||
|
tags String[]
|
||||||
|
status String @default("draft") // draft, published
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
@ -174,6 +174,36 @@ async function seed() {
|
|||||||
console.log(`Partner (Approved) already exists: ${activeEmail}`);
|
console.log(`Partner (Approved) already exists: ${activeEmail}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7. Seed Blog Posts
|
||||||
|
const blogCount = await prisma.blogPost.count();
|
||||||
|
if (blogCount === 0) {
|
||||||
|
await prisma.blogPost.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
title: "Unlocking Ultra-Low Latency: Synthesis of RISC-V in Edge Devices",
|
||||||
|
content: "As edge intelligence grows, local compute units require custom processor topologies. In this article, we details the exact synthesis settings and pipelining optimizations that enabled our quad-core RISC-V IP block to achieve 35% better performance per watt compared to baseline architectures. We review cache organization, instruction fetch queue sizing, and how we tackled branch prediction overheads within tightly constrained FPGA silicon boundaries.",
|
||||||
|
author: "Dr. Marcus Vance",
|
||||||
|
publishDate: "2026-06-18",
|
||||||
|
thumbnailUrl: "https://images.unsplash.com/photo-1601524909162-be87252be298?w=500&auto=format&fit=crop&q=60",
|
||||||
|
readTime: "6 min read",
|
||||||
|
tags: ["RISC-V", "Hardware-Design", "Edge-AI"],
|
||||||
|
status: "published"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Introduction to CodeNuk: Scalable Microservice Architecture",
|
||||||
|
content: "Building distributed systems often involves navigating high configuration overhead. CodeNuk solves this by providing a unified, type-safe scaffolding that integrates telemetry, connection pools, and circuit-breakers out of the box. This deep-dive explains how CodeNuk leverages TypeScript decorators to declare service endpoints and automatically generate OpenAPI contracts and React Client hooks during the build phase, saving engineering weeks.",
|
||||||
|
author: "Yasha Khandelwal",
|
||||||
|
publishDate: "2026-06-25",
|
||||||
|
thumbnailUrl: "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=500&auto=format&fit=crop&q=60",
|
||||||
|
readTime: "8 min read",
|
||||||
|
tags: ["CodeNuk", "TypeScript", "Microservices"],
|
||||||
|
status: "published"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
console.log('Seeded Blog Posts.');
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Seeding completed successfully.');
|
console.log('Seeding completed successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import authRoutes from './routes/auth.routes';
|
|||||||
import assetRoutes from './routes/asset.routes';
|
import assetRoutes from './routes/asset.routes';
|
||||||
import orgRoutes from './routes/organization.routes';
|
import orgRoutes from './routes/organization.routes';
|
||||||
import legalRoutes from './routes/legal.routes';
|
import legalRoutes from './routes/legal.routes';
|
||||||
|
import blogRoutes from './routes/blog.routes';
|
||||||
|
|
||||||
import { ensureBucketExists } from './utils/s3';
|
import { ensureBucketExists } from './utils/s3';
|
||||||
|
|
||||||
@ -82,6 +83,7 @@ app.use('/api/v1/auth', authRoutes);
|
|||||||
app.use('/api/v1/assets', assetRoutes);
|
app.use('/api/v1/assets', assetRoutes);
|
||||||
app.use('/api/v1/organizations', orgRoutes);
|
app.use('/api/v1/organizations', orgRoutes);
|
||||||
app.use('/api/v1/legal', legalRoutes);
|
app.use('/api/v1/legal', legalRoutes);
|
||||||
|
app.use('/api/v1/blog', blogRoutes);
|
||||||
|
|
||||||
app.get('/api/v1/health', (req: Request, res: Response) => {
|
app.get('/api/v1/health', (req: Request, res: Response) => {
|
||||||
res.status(200).json({ status: 'success', message: 'API is fully functional and real.' });
|
res.status(200).json({ status: 'success', message: 'API is fully functional and real.' });
|
||||||
|
|||||||
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 React, { useState, useEffect, useRef } from "react";
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from "react-dom";
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
X, ZoomIn, ZoomOut, RotateCcw, Download, Printer,
|
X,
|
||||||
ShieldCheck, AlertTriangle, CheckCircle, ChevronLeft,
|
ZoomIn,
|
||||||
ChevronRight, FileText, Maximize, Minimize
|
ZoomOut,
|
||||||
} from 'lucide-react';
|
RotateCcw,
|
||||||
import { Button } from './Button';
|
Download,
|
||||||
|
Printer,
|
||||||
|
ShieldCheck,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
|
Maximize,
|
||||||
|
Minimize,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
|
||||||
interface Acceptance {
|
interface Acceptance {
|
||||||
id: string;
|
id: string;
|
||||||
@ -31,7 +42,7 @@ interface DocumentPreviewModalProps {
|
|||||||
partnerCreatedAt: string;
|
partnerCreatedAt: string;
|
||||||
acceptances: Acceptance[];
|
acceptances: Acceptance[];
|
||||||
verifiedDocs: { nda: boolean; msa: boolean };
|
verifiedDocs: { nda: boolean; msa: boolean };
|
||||||
onVerify: (docType: 'NDA' | 'MSA') => void;
|
onVerify: (docType: "NDA" | "MSA") => void;
|
||||||
onApprovePartner: () => void;
|
onApprovePartner: () => void;
|
||||||
isApproving: boolean;
|
isApproving: boolean;
|
||||||
}
|
}
|
||||||
@ -48,7 +59,7 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
onApprovePartner,
|
onApprovePartner,
|
||||||
isApproving,
|
isApproving,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentTab, setCurrentTab] = useState<'NDA' | 'MSA'>('NDA');
|
const [currentTab, setCurrentTab] = useState<"NDA" | "MSA">("NDA");
|
||||||
const [zoom, setZoom] = useState(100);
|
const [zoom, setZoom] = useState(100);
|
||||||
const [fitWidth, setFitWidth] = useState(false);
|
const [fitWidth, setFitWidth] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@ -57,20 +68,20 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const ndaAcceptance = acceptances.find(a => a.document.type === 'NDA');
|
const ndaAcceptance = acceptances.find((a) => a.document.type === "NDA");
|
||||||
const msaAcceptance = acceptances.find(a => a.document.type === 'MSA');
|
const msaAcceptance = acceptances.find((a) => a.document.type === "MSA");
|
||||||
const activeAcceptance = currentTab === 'NDA' ? ndaAcceptance : msaAcceptance;
|
const activeAcceptance = currentTab === "NDA" ? ndaAcceptance : msaAcceptance;
|
||||||
const totalPages = activeAcceptance?.documentUrl ? 1 : 2;
|
const totalPages = activeAcceptance?.documentUrl ? 1 : 2;
|
||||||
|
|
||||||
// Prevent background page scrolling when modal is open
|
// Prevent background page scrolling when modal is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = "hidden";
|
||||||
} else {
|
} else {
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = "";
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = "";
|
||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
@ -108,19 +119,20 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
const handleFullscreenChange = () => {
|
const handleFullscreenChange = () => {
|
||||||
setIsFullScreen(!!document.fullscreenElement);
|
setIsFullScreen(!!document.fullscreenElement);
|
||||||
};
|
};
|
||||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
return () =>
|
||||||
|
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handleZoomIn = () => {
|
const handleZoomIn = () => {
|
||||||
setFitWidth(false);
|
setFitWidth(false);
|
||||||
setZoom(prev => Math.min(200, prev + 25));
|
setZoom((prev) => Math.min(200, prev + 25));
|
||||||
};
|
};
|
||||||
const handleZoomOut = () => {
|
const handleZoomOut = () => {
|
||||||
setFitWidth(false);
|
setFitWidth(false);
|
||||||
setZoom(prev => Math.max(50, prev - 25));
|
setZoom((prev) => Math.max(50, prev - 25));
|
||||||
};
|
};
|
||||||
const handleZoomReset = () => {
|
const handleZoomReset = () => {
|
||||||
setFitWidth(false);
|
setFitWidth(false);
|
||||||
@ -131,12 +143,16 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePrint = () => {
|
const handlePrint = () => {
|
||||||
const printContent = document.getElementById('printable-doc-content');
|
const printContent = document.getElementById("printable-doc-content");
|
||||||
if (!printContent) return;
|
if (!printContent) return;
|
||||||
|
|
||||||
const windowUrl = 'about:blank';
|
const windowUrl = "about:blank";
|
||||||
const uniqueName = new Date().getTime();
|
const uniqueName = new Date().getTime();
|
||||||
const printWindow = window.open(windowUrl, uniqueName.toString(), 'left=50000,top=50000,width=0,height=0');
|
const printWindow = window.open(
|
||||||
|
windowUrl,
|
||||||
|
uniqueName.toString(),
|
||||||
|
"left=50000,top=50000,width=0,height=0",
|
||||||
|
);
|
||||||
if (!printWindow) return;
|
if (!printWindow) return;
|
||||||
|
|
||||||
printWindow.document.write(`
|
printWindow.document.write(`
|
||||||
@ -153,14 +169,14 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>${currentTab === 'NDA' ? 'Non-Disclosure Agreement (NDA)' : 'Master Services Agreement (MSA)'}</h1>
|
<h1>${currentTab === "NDA" ? "Non-Disclosure Agreement (NDA)" : "Master Services Agreement (MSA)"}</h1>
|
||||||
<p>${activeAcceptance?.document.content || ''}</p>
|
<p>${activeAcceptance?.document.content || ""}</p>
|
||||||
<div class="sig-box">
|
<div class="sig-box">
|
||||||
<div class="sig-title">Digitally Signed & Verified</div>
|
<div class="sig-title">Digitally Signed & Verified</div>
|
||||||
<div>Signed By: ${partnerEmail}</div>
|
<div>Signed By: ${partnerEmail}</div>
|
||||||
<div class="hash">Verification Hash: ${activeAcceptance?.signatureHash || 'N/A'}</div>
|
<div class="hash">Verification Hash: ${activeAcceptance?.signatureHash || "N/A"}</div>
|
||||||
<div>IP Address: ${activeAcceptance?.ipAddress || 'N/A'}</div>
|
<div>IP Address: ${activeAcceptance?.ipAddress || "N/A"}</div>
|
||||||
<div>Date Signed: ${activeAcceptance ? new Date(activeAcceptance.acceptedAt).toLocaleString() : ''}</div>
|
<div>Date Signed: ${activeAcceptance ? new Date(activeAcceptance.acceptedAt).toLocaleString() : ""}</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -174,30 +190,36 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
const handleDownloadText = () => {
|
const handleDownloadText = () => {
|
||||||
if (!activeAcceptance) return;
|
if (!activeAcceptance) return;
|
||||||
const element = document.createElement("a");
|
const element = document.createElement("a");
|
||||||
const file = new Blob([
|
const file = new Blob(
|
||||||
`${currentTab === 'NDA' ? 'Non-Disclosure Agreement (NDA)' : 'Master Services Agreement (MSA)'}\n\n`,
|
[
|
||||||
|
`${currentTab === "NDA" ? "Non-Disclosure Agreement (NDA)" : "Master Services Agreement (MSA)"}\n\n`,
|
||||||
activeAcceptance.document.content,
|
activeAcceptance.document.content,
|
||||||
`\n\n=== DIGITAL SIGNATURE ===\n`,
|
`\n\n=== DIGITAL SIGNATURE ===\n`,
|
||||||
`Signed By: ${partnerEmail}\n`,
|
`Signed By: ${partnerEmail}\n`,
|
||||||
`Verification Hash: ${activeAcceptance.signatureHash || 'N/A'}\n`,
|
`Verification Hash: ${activeAcceptance.signatureHash || "N/A"}\n`,
|
||||||
`IP Address: ${activeAcceptance.ipAddress}\n`,
|
`IP Address: ${activeAcceptance.ipAddress}\n`,
|
||||||
`Signed On: ${new Date(activeAcceptance.acceptedAt).toLocaleString()}\n`
|
`Signed On: ${new Date(activeAcceptance.acceptedAt).toLocaleString()}\n`,
|
||||||
], {type: 'text/plain'});
|
],
|
||||||
|
{ type: "text/plain" },
|
||||||
|
);
|
||||||
element.href = URL.createObjectURL(file);
|
element.href = URL.createObjectURL(file);
|
||||||
element.download = `${currentTab}_Agreement_${partnerEmail.split('@')[0]}.txt`;
|
element.download = `${currentTab}_Agreement_${partnerEmail.split("@")[0]}.txt`;
|
||||||
document.body.appendChild(element);
|
document.body.appendChild(element);
|
||||||
element.click();
|
element.click();
|
||||||
document.body.removeChild(element);
|
document.body.removeChild(element);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', '');
|
const fileHost = (
|
||||||
|
import.meta.env.VITE_API_URL || "http://localhost:5000/api/v1"
|
||||||
|
).replace("/api/v1", "");
|
||||||
const isBothVerified = verifiedDocs.nda && verifiedDocs.msa;
|
const isBothVerified = verifiedDocs.nda && verifiedDocs.msa;
|
||||||
const isCurrentVerified = currentTab === 'NDA' ? verifiedDocs.nda : verifiedDocs.msa;
|
const isCurrentVerified =
|
||||||
|
currentTab === "NDA" ? verifiedDocs.nda : verifiedDocs.msa;
|
||||||
|
|
||||||
// Responsive classes based on screen size
|
// Responsive classes based on screen size
|
||||||
const modalContainerClasses = isFullScreen
|
const modalContainerClasses = isFullScreen
|
||||||
? 'fixed inset-0 w-screen h-screen bg-ink-0 z-[9999] flex flex-col overflow-hidden'
|
? "fixed inset-0 w-screen h-screen bg-ink-0 z-[9999] flex flex-col overflow-hidden"
|
||||||
: 'relative w-full h-full sm:w-[90vw] sm:h-[88vh] lg:w-[78vw] lg:h-[86vh] bg-ink-0 border border-ink-200 sm:rounded-2xl shadow-2xl flex flex-col overflow-hidden z-[9999]';
|
: "relative w-full h-full sm:w-[90vw] sm:h-[88vh] lg:w-[78vw] lg:h-[86vh] bg-ink-0 border border-ink-200 sm:rounded-2xl shadow-2xl flex flex-col overflow-hidden z-[9999]";
|
||||||
|
|
||||||
return ReactDOM.createPortal(
|
return ReactDOM.createPortal(
|
||||||
<div className="fixed inset-0 z-[9999] flex justify-center items-center p-0 sm:p-4 md:p-6 overflow-hidden">
|
<div className="fixed inset-0 z-[9999] flex justify-center items-center p-0 sm:p-4 md:p-6 overflow-hidden">
|
||||||
@ -216,7 +238,7 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
initial={{ opacity: 0, scale: 0.97, y: 15 }}
|
initial={{ opacity: 0, scale: 0.97, y: 15 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.97, y: 15 }}
|
exit={{ opacity: 0, scale: 0.97, y: 15 }}
|
||||||
transition={{ type: 'spring', damping: 26, stiffness: 340 }}
|
transition={{ type: "spring", damping: 26, stiffness: 340 }}
|
||||||
className={modalContainerClasses}
|
className={modalContainerClasses}
|
||||||
>
|
>
|
||||||
{/* Sticky Header */}
|
{/* Sticky Header */}
|
||||||
@ -227,7 +249,9 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="text-sm font-extrabold text-ink-900 tracking-tight truncate">
|
<h3 className="text-sm font-extrabold text-ink-900 tracking-tight truncate">
|
||||||
{currentTab === 'NDA' ? 'Mutual Non-Disclosure Agreement (NDA)' : 'Master Services Agreement (MSA)'}
|
{currentTab === "NDA"
|
||||||
|
? "Mutual Non-Disclosure Agreement (NDA)"
|
||||||
|
: "Master Services Agreement (MSA)"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-ink-500 font-semibold truncate">
|
<p className="text-xs text-ink-500 font-semibold truncate">
|
||||||
Partner: <span className="text-ink-900">{partnerEmail}</span>
|
Partner: <span className="text-ink-900">{partnerEmail}</span>
|
||||||
@ -239,29 +263,29 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
<div className="flex items-center gap-2 bg-ink-50 p-1 rounded-xl border border-ink-200 self-start md:self-auto">
|
<div className="flex items-center gap-2 bg-ink-50 p-1 rounded-xl border border-ink-200 self-start md:self-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentTab('NDA');
|
setCurrentTab("NDA");
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
|
||||||
currentTab === 'NDA'
|
currentTab === "NDA"
|
||||||
? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200'
|
? "bg-ink-0 text-ink-900 shadow-sm border border-ink-200"
|
||||||
: 'text-ink-500 hover:text-ink-900'
|
: "text-ink-500 hover:text-ink-900"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
NDA Agreement {verifiedDocs.nda && '✓'}
|
NDA Agreement {verifiedDocs.nda && "✓"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentTab('MSA');
|
setCurrentTab("MSA");
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
|
||||||
currentTab === 'MSA'
|
currentTab === "MSA"
|
||||||
? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200'
|
? "bg-ink-0 text-ink-900 shadow-sm border border-ink-200"
|
||||||
: 'text-ink-500 hover:text-ink-900'
|
: "text-ink-500 hover:text-ink-900"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
MSA Agreement {verifiedDocs.msa && '✓'}
|
MSA Agreement {verifiedDocs.msa && "✓"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -271,7 +295,11 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
className="p-2 rounded-lg text-ink-500 hover:text-ink-900 hover:bg-ink-50 transition-all cursor-pointer"
|
className="p-2 rounded-lg text-ink-500 hover:text-ink-900 hover:bg-ink-50 transition-all cursor-pointer"
|
||||||
title={isFullScreen ? "Exit Fullscreen" : "Fullscreen"}
|
title={isFullScreen ? "Exit Fullscreen" : "Fullscreen"}
|
||||||
>
|
>
|
||||||
{isFullScreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
|
{isFullScreen ? (
|
||||||
|
<Minimize className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Maximize className="w-4 h-4" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@ -288,7 +316,8 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
<div className="bg-amber-50 border-b border-amber-200 px-5 py-2.5 flex items-center gap-3 shrink-0">
|
<div className="bg-amber-50 border-b border-amber-200 px-5 py-2.5 flex items-center gap-3 shrink-0">
|
||||||
<AlertTriangle className="w-4.5 h-4.5 text-amber-600 shrink-0" />
|
<AlertTriangle className="w-4.5 h-4.5 text-amber-600 shrink-0" />
|
||||||
<p className="text-xs font-bold text-amber-800">
|
<p className="text-xs font-bold text-amber-800">
|
||||||
Attention: This document has not been submitted or signed by the partner.
|
Attention: This document has not been submitted or signed by the
|
||||||
|
partner.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -299,18 +328,21 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
disabled={currentPage <= 1 || !activeAcceptance}
|
disabled={currentPage <= 1 || !activeAcceptance}
|
||||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
||||||
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
|
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
|
||||||
title="Previous Page"
|
title="Previous Page"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs font-bold text-ink-600 min-w-[70px] text-center">
|
<span className="text-xs font-bold text-ink-600 min-w-[70px] text-center">
|
||||||
Page {activeAcceptance ? currentPage : 0} of {activeAcceptance ? totalPages : 0}
|
Page {activeAcceptance ? currentPage : 0} of{" "}
|
||||||
|
{activeAcceptance ? totalPages : 0}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
disabled={currentPage >= totalPages || !activeAcceptance}
|
disabled={currentPage >= totalPages || !activeAcceptance}
|
||||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
|
||||||
|
}
|
||||||
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
|
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
|
||||||
title="Next Page"
|
title="Next Page"
|
||||||
>
|
>
|
||||||
@ -322,7 +354,9 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={handleZoomOut}
|
onClick={handleZoomOut}
|
||||||
disabled={!activeAcceptance || activeAcceptance.documentUrl !== null}
|
disabled={
|
||||||
|
!activeAcceptance || activeAcceptance.documentUrl !== null
|
||||||
|
}
|
||||||
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
|
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
|
||||||
title="Zoom Out"
|
title="Zoom Out"
|
||||||
>
|
>
|
||||||
@ -333,7 +367,9 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={handleZoomIn}
|
onClick={handleZoomIn}
|
||||||
disabled={!activeAcceptance || activeAcceptance.documentUrl !== null}
|
disabled={
|
||||||
|
!activeAcceptance || activeAcceptance.documentUrl !== null
|
||||||
|
}
|
||||||
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
|
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
|
||||||
title="Zoom In"
|
title="Zoom In"
|
||||||
>
|
>
|
||||||
@ -341,7 +377,9 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleZoomReset}
|
onClick={handleZoomReset}
|
||||||
disabled={!activeAcceptance || activeAcceptance.documentUrl !== null}
|
disabled={
|
||||||
|
!activeAcceptance || activeAcceptance.documentUrl !== null
|
||||||
|
}
|
||||||
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
|
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
|
||||||
title="Reset Zoom"
|
title="Reset Zoom"
|
||||||
>
|
>
|
||||||
@ -349,11 +387,13 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={toggleFitWidth}
|
onClick={toggleFitWidth}
|
||||||
disabled={!activeAcceptance || activeAcceptance.documentUrl !== null}
|
disabled={
|
||||||
|
!activeAcceptance || activeAcceptance.documentUrl !== null
|
||||||
|
}
|
||||||
className={`px-2.5 py-1.5 rounded-lg border text-xs font-bold cursor-pointer shadow-sm transition-all ${
|
className={`px-2.5 py-1.5 rounded-lg border text-xs font-bold cursor-pointer shadow-sm transition-all ${
|
||||||
fitWidth
|
fitWidth
|
||||||
? 'bg-ink-900 text-ink-0 border-transparent'
|
? "bg-ink-900 text-ink-0 border-transparent"
|
||||||
: 'bg-ink-0 border-ink-200 text-ink-700 hover:bg-ink-50'
|
: "bg-ink-0 border-ink-200 text-ink-700 hover:bg-ink-50"
|
||||||
}`}
|
}`}
|
||||||
title="Fit Width"
|
title="Fit Width"
|
||||||
>
|
>
|
||||||
@ -373,7 +413,9 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
<span className="hidden sm:inline">Print</span>
|
<span className="hidden sm:inline">Print</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={activeAcceptance?.documentUrl ? undefined : handleDownloadText}
|
onClick={
|
||||||
|
activeAcceptance?.documentUrl ? undefined : handleDownloadText
|
||||||
|
}
|
||||||
disabled={!activeAcceptance}
|
disabled={!activeAcceptance}
|
||||||
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer flex items-center gap-1.5 text-xs font-bold shadow-sm"
|
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer flex items-center gap-1.5 text-xs font-bold shadow-sm"
|
||||||
title="Download File"
|
title="Download File"
|
||||||
@ -400,7 +442,6 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
|
|
||||||
{/* Modal Main Body (Split Pane) */}
|
{/* Modal Main Body (Split Pane) */}
|
||||||
<div className="flex-1 flex flex-col md:flex-row overflow-hidden min-h-0">
|
<div className="flex-1 flex flex-col md:flex-row overflow-hidden min-h-0">
|
||||||
|
|
||||||
{/* Left Pane: Centered Document Viewer */}
|
{/* Left Pane: Centered Document Viewer */}
|
||||||
<div className="flex-1 flex flex-col bg-ink-100 overflow-hidden relative p-4 sm:p-6 justify-center items-center">
|
<div className="flex-1 flex flex-col bg-ink-100 overflow-hidden relative p-4 sm:p-6 justify-center items-center">
|
||||||
<div
|
<div
|
||||||
@ -411,7 +452,9 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
activeAcceptance.documentUrl ? (
|
activeAcceptance.documentUrl ? (
|
||||||
// PDF / Uploaded Document Viewer centered
|
// PDF / Uploaded Document Viewer centered
|
||||||
<div className="w-full h-full max-w-4xl bg-ink-0 shadow-lg rounded-xl overflow-hidden border border-ink-200 flex justify-center items-center">
|
<div className="w-full h-full max-w-4xl bg-ink-0 shadow-lg rounded-xl overflow-hidden border border-ink-200 flex justify-center items-center">
|
||||||
{activeAcceptance.documentUrl.toLowerCase().endsWith('.pdf') ? (
|
{activeAcceptance.documentUrl
|
||||||
|
.toLowerCase()
|
||||||
|
.endsWith(".pdf") ? (
|
||||||
<iframe
|
<iframe
|
||||||
src={`${fileHost}${activeAcceptance.documentUrl}#toolbar=0`}
|
src={`${fileHost}${activeAcceptance.documentUrl}#toolbar=0`}
|
||||||
title="Document PDF"
|
title="Document PDF"
|
||||||
@ -434,8 +477,12 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
className="w-full bg-ink-0 p-8 sm:p-12 my-2 rounded-xl shadow-lg border border-ink-200 font-serif leading-relaxed text-ink-800 transition-all duration-150 select-text flex flex-col"
|
className="w-full bg-ink-0 p-8 sm:p-12 my-2 rounded-xl shadow-lg border border-ink-200 font-serif leading-relaxed text-ink-800 transition-all duration-150 select-text flex flex-col"
|
||||||
style={
|
style={
|
||||||
fitWidth
|
fitWidth
|
||||||
? { width: '100%', maxWidth: '100%', fontSize: '15px' }
|
? { width: "100%", maxWidth: "100%", fontSize: "15px" }
|
||||||
: { width: '100%', maxWidth: '720px', fontSize: `${14 * (zoom / 100)}px` }
|
: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "720px",
|
||||||
|
fontSize: `${14 * (zoom / 100)}px`,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{currentPage === 1 ? (
|
{currentPage === 1 ? (
|
||||||
@ -443,10 +490,14 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-center mb-8 not-italic font-sans">
|
<div className="text-center mb-8 not-italic font-sans">
|
||||||
<h4 className="text-base sm:text-lg font-extrabold text-ink-900 tracking-tight">
|
<h4 className="text-base sm:text-lg font-extrabold text-ink-900 tracking-tight">
|
||||||
{currentTab === 'NDA' ? 'MUTUAL NON-DISCLOSURE AGREEMENT' : 'MASTER SERVICES AGREEMENT'}
|
{currentTab === "NDA"
|
||||||
|
? "MUTUAL NON-DISCLOSURE AGREEMENT"
|
||||||
|
: "MASTER SERVICES AGREEMENT"}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="w-16 h-1 bg-ink-900 mx-auto my-3" />
|
<div className="w-16 h-1 bg-ink-900 mx-auto my-3" />
|
||||||
<p className="text-[10px] text-ink-400 font-bold uppercase tracking-wider">Version {activeAcceptance.document.version}</p>
|
<p className="text-[10px] text-ink-400 font-bold uppercase tracking-wider">
|
||||||
|
Version {activeAcceptance.document.version}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="whitespace-pre-line prose max-w-none text-xs sm:text-sm">
|
<div className="whitespace-pre-line prose max-w-none text-xs sm:text-sm">
|
||||||
{activeAcceptance.document.content}
|
{activeAcceptance.document.content}
|
||||||
@ -463,11 +514,17 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
<h4 className="text-base sm:text-lg font-extrabold text-ink-900 tracking-tight">
|
<h4 className="text-base sm:text-lg font-extrabold text-ink-900 tracking-tight">
|
||||||
SIGNATURE & ACCORD SIGNING PAGE
|
SIGNATURE & ACCORD SIGNING PAGE
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-[10px] text-ink-400 font-bold uppercase tracking-wider mt-1">Agreement Version {activeAcceptance.document.version}</p>
|
<p className="text-[10px] text-ink-400 font-bold uppercase tracking-wider mt-1">
|
||||||
|
Agreement Version{" "}
|
||||||
|
{activeAcceptance.document.version}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs sm:text-sm mb-6 text-ink-600 font-sans italic">
|
<p className="text-xs sm:text-sm mb-6 text-ink-600 font-sans italic">
|
||||||
IN WITNESS WHEREOF, the parties hereto have caused this Agreement to be executed by their digital signatures as of the Acceptance Date specified below.
|
IN WITNESS WHEREOF, the parties hereto have caused
|
||||||
|
this Agreement to be executed by their digital
|
||||||
|
signatures as of the Acceptance Date specified
|
||||||
|
below.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -476,10 +533,14 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
{/* Signature image box */}
|
{/* Signature image box */}
|
||||||
<div className="border-2 border-ink-200 rounded-xl overflow-hidden bg-white shadow-sm">
|
<div className="border-2 border-ink-200 rounded-xl overflow-hidden bg-white shadow-sm">
|
||||||
<div className="px-4 pt-3 pb-1 border-b border-ink-100 flex items-center justify-between">
|
<div className="px-4 pt-3 pb-1 border-b border-ink-100 flex items-center justify-between">
|
||||||
<span className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">Client Signature</span>
|
<span className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">
|
||||||
|
Client Signature
|
||||||
|
</span>
|
||||||
<div className="flex items-center gap-1 text-emerald-600">
|
<div className="flex items-center gap-1 text-emerald-600">
|
||||||
<ShieldCheck className="w-3.5 h-3.5" />
|
<ShieldCheck className="w-3.5 h-3.5" />
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider">Verified</span>
|
<span className="text-[10px] font-bold uppercase tracking-wider">
|
||||||
|
Verified
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 min-h-[100px] flex items-center justify-center bg-white">
|
<div className="p-4 min-h-[100px] flex items-center justify-center bg-white">
|
||||||
@ -489,45 +550,71 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
src={activeAcceptance.signatureBase64}
|
src={activeAcceptance.signatureBase64}
|
||||||
alt="Client drawn signature"
|
alt="Client drawn signature"
|
||||||
className="max-w-full max-h-[160px] object-contain"
|
className="max-w-full max-h-[160px] object-contain"
|
||||||
style={{ imageRendering: 'crisp-edges' }}
|
style={{ imageRendering: "crisp-edges" }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// Upload-based signing — no drawn image stored
|
// Upload-based signing — no drawn image stored
|
||||||
<div className="text-center py-4">
|
<div className="text-center py-4">
|
||||||
<ShieldCheck className="w-8 h-8 text-emerald-500 mx-auto mb-2" />
|
<ShieldCheck className="w-8 h-8 text-emerald-500 mx-auto mb-2" />
|
||||||
<p className="text-xs text-ink-500 font-semibold">Signed via document upload</p>
|
<p className="text-xs text-ink-500 font-semibold">
|
||||||
<p className="text-[10px] text-ink-400 mt-0.5">See uploaded file in viewer</p>
|
Signed via document upload
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-ink-400 mt-0.5">
|
||||||
|
See uploaded file in viewer
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Signature baseline line */}
|
{/* Signature baseline line */}
|
||||||
<div className="px-6 pb-3">
|
<div className="px-6 pb-3">
|
||||||
<div className="border-b-2 border-ink-300 w-full" />
|
<div className="border-b-2 border-ink-300 w-full" />
|
||||||
<p className="text-[9px] text-ink-400 font-bold uppercase tracking-widest mt-1 text-center">Authorized Digital Signature</p>
|
<p className="text-[9px] text-ink-400 font-bold uppercase tracking-widest mt-1 text-center">
|
||||||
|
Authorized Digital Signature
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Verification metadata strip */}
|
{/* Verification metadata strip */}
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-[10px] sm:text-xs font-sans bg-ink-50 border border-ink-200 rounded-xl p-4">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-[10px] sm:text-xs font-sans bg-ink-50 border border-ink-200 rounded-xl p-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">Signed By</p>
|
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">
|
||||||
<p className="font-bold text-ink-900 mt-0.5 truncate">{partnerEmail}</p>
|
Signed By
|
||||||
|
</p>
|
||||||
|
<p className="font-bold text-ink-900 mt-0.5 truncate">
|
||||||
|
{partnerEmail}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">Accepted On</p>
|
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">
|
||||||
<p className="font-semibold text-ink-900 mt-0.5">{new Date(activeAcceptance.acceptedAt).toLocaleString()}</p>
|
Accepted On
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-ink-900 mt-0.5">
|
||||||
|
{new Date(
|
||||||
|
activeAcceptance.acceptedAt,
|
||||||
|
).toLocaleString()}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">IP Address</p>
|
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">
|
||||||
<p className="font-semibold font-mono text-ink-900 mt-0.5">{activeAcceptance.ipAddress}</p>
|
IP Address
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold font-mono text-ink-900 mt-0.5">
|
||||||
|
{activeAcceptance.ipAddress}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">Document Version</p>
|
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">
|
||||||
<p className="font-semibold text-ink-900 mt-0.5">v{activeAcceptance.document.version}</p>
|
Document Version
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-ink-900 mt-0.5">
|
||||||
|
v{activeAcceptance.document.version}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{activeAcceptance.signatureHash && (
|
{activeAcceptance.signatureHash && (
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">Verification Hash (SHA-256)</p>
|
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">
|
||||||
|
Verification Hash (SHA-256)
|
||||||
|
</p>
|
||||||
<p className="font-mono text-[9px] text-ink-500 bg-ink-100 p-1.5 rounded border border-ink-200 break-all mt-0.5">
|
<p className="font-mono text-[9px] text-ink-500 bg-ink-100 p-1.5 rounded border border-ink-200 break-all mt-0.5">
|
||||||
{activeAcceptance.signatureHash}
|
{activeAcceptance.signatureHash}
|
||||||
</p>
|
</p>
|
||||||
@ -546,9 +633,12 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<div className="w-full max-w-md my-auto flex flex-col items-center justify-center text-center p-8 bg-ink-0 rounded-2xl border border-ink-200 shadow-lg">
|
<div className="w-full max-w-md my-auto flex flex-col items-center justify-center text-center p-8 bg-ink-0 rounded-2xl border border-ink-200 shadow-lg">
|
||||||
<AlertTriangle className="w-12 h-12 text-ink-400 mb-3" />
|
<AlertTriangle className="w-12 h-12 text-ink-400 mb-3" />
|
||||||
<h4 className="text-base font-extrabold text-ink-900">Document Unavailable</h4>
|
<h4 className="text-base font-extrabold text-ink-900">
|
||||||
|
Document Unavailable
|
||||||
|
</h4>
|
||||||
<p className="text-xs text-ink-500 mt-1.5 max-w-xs leading-relaxed font-semibold">
|
<p className="text-xs text-ink-500 mt-1.5 max-w-xs leading-relaxed font-semibold">
|
||||||
The partner has not yet submitted or signed the {currentTab} document.
|
The partner has not yet submitted or signed the {currentTab}{" "}
|
||||||
|
document.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -563,23 +653,39 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between items-center text-xs">
|
<div className="flex justify-between items-center text-xs">
|
||||||
<span className="font-semibold text-ink-500">Document Category</span>
|
<span className="font-semibold text-ink-500">
|
||||||
<span className="font-bold text-ink-900 bg-ink-100 px-2 py-0.5 rounded border border-ink-200">{currentTab}</span>
|
Document Category
|
||||||
|
</span>
|
||||||
|
<span className="font-bold text-ink-900 bg-ink-100 px-2 py-0.5 rounded border border-ink-200">
|
||||||
|
{currentTab}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center text-xs">
|
<div className="flex justify-between items-center text-xs">
|
||||||
<span className="font-semibold text-ink-500">Submission State</span>
|
<span className="font-semibold text-ink-500">
|
||||||
|
Submission State
|
||||||
|
</span>
|
||||||
{activeAcceptance ? (
|
{activeAcceptance ? (
|
||||||
<span className="font-bold text-emerald-700 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">Signed</span>
|
<span className="font-bold text-emerald-700 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">
|
||||||
|
Signed
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-bold text-amber-700 bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">Pending</span>
|
<span className="font-bold text-amber-700 bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">
|
||||||
|
Pending
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center text-xs">
|
<div className="flex justify-between items-center text-xs">
|
||||||
<span className="font-semibold text-ink-500">Digital Signature</span>
|
<span className="font-semibold text-ink-500">
|
||||||
|
Digital Signature
|
||||||
|
</span>
|
||||||
{activeAcceptance ? (
|
{activeAcceptance ? (
|
||||||
<span className="font-bold text-emerald-700 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">Verified</span>
|
<span className="font-bold text-emerald-700 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">
|
||||||
|
Verified
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-bold text-red-650 bg-red-500/10 px-2 py-0.5 rounded border border-red-500/20">Not Verified</span>
|
<span className="font-bold text-red-650 bg-red-500/10 px-2 py-0.5 rounded border border-red-500/20">
|
||||||
|
Not Verified
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -592,24 +698,42 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
{activeAcceptance ? (
|
{activeAcceptance ? (
|
||||||
<div className="space-y-3 text-xs">
|
<div className="space-y-3 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-ink-400 text-[10px] uppercase">Signed By</p>
|
<p className="font-semibold text-ink-400 text-[10px] uppercase">
|
||||||
<p className="font-bold text-ink-900 truncate mt-0.5">{partnerEmail}</p>
|
Signed By
|
||||||
|
</p>
|
||||||
|
<p className="font-bold text-ink-900 truncate mt-0.5">
|
||||||
|
{partnerEmail}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-ink-400 text-[10px] uppercase">Signed On</p>
|
<p className="font-semibold text-ink-400 text-[10px] uppercase">
|
||||||
<p className="font-bold text-ink-900 mt-0.5">{new Date(activeAcceptance.acceptedAt).toLocaleString()}</p>
|
Signed On
|
||||||
|
</p>
|
||||||
|
<p className="font-bold text-ink-900 mt-0.5">
|
||||||
|
{new Date(activeAcceptance.acceptedAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-ink-400 text-[10px] uppercase">IP Address</p>
|
<p className="font-semibold text-ink-400 text-[10px] uppercase">
|
||||||
<p className="font-bold text-ink-900 mt-0.5 font-mono">{activeAcceptance.ipAddress}</p>
|
IP Address
|
||||||
|
</p>
|
||||||
|
<p className="font-bold text-ink-900 mt-0.5 font-mono">
|
||||||
|
{activeAcceptance.ipAddress}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-ink-400 text-[10px] uppercase">Version</p>
|
<p className="font-semibold text-ink-400 text-[10px] uppercase">
|
||||||
<p className="font-bold text-ink-900 mt-0.5">v{activeAcceptance.document.version}</p>
|
Version
|
||||||
|
</p>
|
||||||
|
<p className="font-bold text-ink-900 mt-0.5">
|
||||||
|
v{activeAcceptance.document.version}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{activeAcceptance.signatureHash && (
|
{activeAcceptance.signatureHash && (
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-ink-400 text-[10px] uppercase">Verification Hash</p>
|
<p className="font-semibold text-ink-400 text-[10px] uppercase">
|
||||||
|
Verification Hash
|
||||||
|
</p>
|
||||||
<p className="font-mono text-[10px] text-ink-650 bg-ink-50 p-1.5 rounded border border-ink-150 break-all mt-0.5">
|
<p className="font-mono text-[10px] text-ink-650 bg-ink-50 p-1.5 rounded border border-ink-150 break-all mt-0.5">
|
||||||
{activeAcceptance.signatureHash}
|
{activeAcceptance.signatureHash}
|
||||||
</p>
|
</p>
|
||||||
@ -617,7 +741,9 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-ink-400 italic">No verification metadata available.</p>
|
<p className="text-xs text-ink-400 italic">
|
||||||
|
No verification metadata available.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -625,20 +751,23 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => onVerify(currentTab)}
|
onClick={() => onVerify(currentTab)}
|
||||||
disabled={!activeAcceptance}
|
disabled={!activeAcceptance}
|
||||||
variant={isCurrentVerified ? 'secondary' : 'primary'}
|
variant={isCurrentVerified ? "secondary" : "primary"}
|
||||||
className="w-full flex justify-center items-center gap-1.5"
|
className="w-full flex justify-center items-center gap-1.5"
|
||||||
>
|
>
|
||||||
{isCurrentVerified ? (
|
{isCurrentVerified ? (
|
||||||
<>
|
<>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
<CheckCircle className="w-4 h-4 text-emerald-600" />
|
<CheckCircle className="w-4 h-4 text-emerald-600" />
|
||||||
<span>Document Verified</span>
|
<span>Document Verified</span>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span>Verify {currentTab} Signature</span>
|
<span>Verify {currentTab} Signature</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-[10px] text-ink-400 text-center font-medium leading-normal">
|
<p className="text-[10px] text-ink-400 text-center font-medium leading-normal">
|
||||||
Marking this verified unlocks approval options for the administrator.
|
Marking this verified unlocks approval options for the
|
||||||
|
administrator.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -649,11 +778,13 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
<div className="text-xs font-semibold text-ink-500">
|
<div className="text-xs font-semibold text-ink-500">
|
||||||
{verifiedDocs.nda && verifiedDocs.msa ? (
|
{verifiedDocs.nda && verifiedDocs.msa ? (
|
||||||
<span className="text-emerald-700 font-bold flex items-center gap-1.5">
|
<span className="text-emerald-700 font-bold flex items-center gap-1.5">
|
||||||
<CheckCircle className="w-4.5 h-4.5" /> Both agreements verified. Access approval unlocked.
|
<CheckCircle className="w-4.5 h-4.5" /> Both agreements
|
||||||
|
verified. Access approval unlocked.
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<AlertTriangle className="w-4 h-4 text-amber-500" /> Please review and verify both the NDA and MSA documents.
|
<AlertTriangle className="w-4 h-4 text-amber-500" /> Please
|
||||||
|
review and verify both the NDA and MSA documents.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -668,12 +799,12 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
className="font-bold shadow-md hover:shadow-lg transition-shadow"
|
className="font-bold shadow-md hover:shadow-lg transition-shadow"
|
||||||
>
|
>
|
||||||
{isApproving ? 'Approving Partner...' : 'Approve Partner Access'}
|
{isApproving ? "Approving Partner..." : "Approve Partner Access"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
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]);
|
}, [addToast]);
|
||||||
|
|
||||||
const icons = {
|
const icons = {
|
||||||
success: <CheckCircle2 className="w-4 h-4 text-emerald-500 shrink-0 mt-0.5" />,
|
success: <CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0 mt-0.5" />,
|
||||||
error: <AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />,
|
error: <AlertCircle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />,
|
||||||
warning: <AlertTriangle className="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />,
|
warning: <AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />,
|
||||||
info: <Info className="w-4 h-4 text-blue-500 shrink-0 mt-0.5" />,
|
info: <Info className="w-5 h-5 text-blue-500 shrink-0 mt-0.5" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const borders = {
|
const borders = {
|
||||||
@ -80,7 +80,7 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
<ToastContext.Provider value={{ toast: addToast, success, error, warning, info }}>
|
<ToastContext.Provider value={{ toast: addToast, success, error, warning, info }}>
|
||||||
{children}
|
{children}
|
||||||
{/* Toast container viewport */}
|
{/* Toast container viewport */}
|
||||||
<div className="fixed top-6 right-6 z-[200] flex flex-col gap-3 w-full max-w-sm pointer-events-none">
|
<div className="fixed top-6 right-6 z-[200] flex flex-col gap-4 w-[calc(100%-3rem)] max-w-md pointer-events-none">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{toasts.map((t) => (
|
{toasts.map((t) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -88,20 +88,20 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
initial={{ opacity: 0, y: -20, scale: 0.95 }}
|
initial={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.9, y: -10 }}
|
exit={{ opacity: 0, scale: 0.9, y: -10 }}
|
||||||
className={`pointer-events-auto flex items-start gap-3 p-4 bg-ink-0 border border-ink-200 rounded-xl shadow-premium ${borders[t.type]} overflow-hidden`}
|
className={`pointer-events-auto flex items-start gap-4 p-5 bg-ink-0 border border-ink-200 rounded-xl shadow-premium ${borders[t.type]} overflow-hidden`}
|
||||||
>
|
>
|
||||||
{icons[t.type]}
|
{icons[t.type]}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="text-xs font-bold text-ink-900 leading-snug">{t.message}</h4>
|
<h4 className="text-sm md:text-base font-bold text-ink-900 leading-snug">{t.message}</h4>
|
||||||
{t.description && (
|
{t.description && (
|
||||||
<p className="text-[10px] text-ink-500 font-semibold leading-normal mt-1">{t.description}</p>
|
<p className="text-xs md:text-sm text-ink-500 font-semibold leading-normal mt-1.5">{t.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeToast(t.id)}
|
onClick={() => removeToast(t.id)}
|
||||||
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors shrink-0 cursor-pointer"
|
className="p-1.5 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors shrink-0 cursor-pointer"
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -5,12 +5,14 @@ interface SignatureCaptureProps {
|
|||||||
onSignatureComplete: (signatureDataUrl: string) => void;
|
onSignatureComplete: (signatureDataUrl: string) => void;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
initialSignature?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
|
export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
|
||||||
onSignatureComplete,
|
onSignatureComplete,
|
||||||
width = 600,
|
width = 600,
|
||||||
height = 200
|
height = 200,
|
||||||
|
initialSignature = null
|
||||||
}) => {
|
}) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const [isDrawing, setIsDrawing] = useState(false);
|
const [isDrawing, setIsDrawing] = useState(false);
|
||||||
@ -38,7 +40,19 @@ export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
|
|||||||
canvas.style.width = `${width}px`;
|
canvas.style.width = `${width}px`;
|
||||||
canvas.style.height = `${height}px`;
|
canvas.style.height = `${height}px`;
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
}, [width, height]);
|
|
||||||
|
if (initialSignature) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
};
|
||||||
|
img.src = initialSignature;
|
||||||
|
setHasSignature(true);
|
||||||
|
} else {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
setHasSignature(false);
|
||||||
|
}
|
||||||
|
}, [width, height, initialSignature]);
|
||||||
|
|
||||||
// Adjust stroke color if theme toggles during active view
|
// Adjust stroke color if theme toggles during active view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -114,6 +128,7 @@ export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
|
|||||||
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
setHasSignature(false);
|
setHasSignature(false);
|
||||||
|
onSignatureComplete('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type { Asset } from '../../../types/assets';
|
|||||||
import type { User } from '../../../types/auth';
|
import type { User } from '../../../types/auth';
|
||||||
import Modal from '../../../components/ui/Modal';
|
import Modal from '../../../components/ui/Modal';
|
||||||
import Button from '../../../components/ui/Button';
|
import Button from '../../../components/ui/Button';
|
||||||
|
import MarkdownViewer from '../../../components/ui/MarkdownViewer';
|
||||||
|
|
||||||
interface AssetViewerModalProps {
|
interface AssetViewerModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -202,9 +203,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
|||||||
<h1 className="text-xl font-extrabold text-ink-950 font-sans">{asset.title}</h1>
|
<h1 className="text-xl font-extrabold text-ink-950 font-sans">{asset.title}</h1>
|
||||||
<p className="text-xs text-ink-500 mt-1 font-mono">{asset.url}</p>
|
<p className="text-xs text-ink-500 mt-1 font-mono">{asset.url}</p>
|
||||||
</div>
|
</div>
|
||||||
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
|
<MarkdownViewer markdown={textPreviewContent} />
|
||||||
{textPreviewContent}
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -270,9 +269,13 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
|||||||
<h1 className="text-xl font-extrabold text-ink-950 font-sans">{asset.title}</h1>
|
<h1 className="text-xl font-extrabold text-ink-950 font-sans">{asset.title}</h1>
|
||||||
<p className="text-xs text-ink-500 mt-1 font-sans">Plain Text / Markdown Format</p>
|
<p className="text-xs text-ink-500 mt-1 font-sans">Plain Text / Markdown Format</p>
|
||||||
</div>
|
</div>
|
||||||
|
{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">
|
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
|
||||||
{textPreviewContent}
|
{textPreviewContent}
|
||||||
</pre>
|
</pre>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import type { BlogPost } from "../../../types";
|
import type { BlogPost } from "../../../types";
|
||||||
import { apiClient } from "../../../lib/api-client";
|
import { getBlogPosts, createBlogPost } from "../../../services/blog-api";
|
||||||
import { useAuth } from "../../auth/store/AuthContext";
|
import { useAuthStore } from "../../../hooks/use-auth";
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
User,
|
User,
|
||||||
@ -15,7 +15,7 @@ import Modal from "../../../components/ui/Modal";
|
|||||||
import { PageLayout } from "../../../components/layout/PageLayout";
|
import { PageLayout } from "../../../components/layout/PageLayout";
|
||||||
|
|
||||||
export const BlogCatalog: React.FC = () => {
|
export const BlogCatalog: React.FC = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuthStore();
|
||||||
const [posts, setPosts] = useState<BlogPost[]>([]);
|
const [posts, setPosts] = useState<BlogPost[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const isAdmin = user?.role === "ADMIN";
|
const isAdmin = user?.role === "ADMIN";
|
||||||
@ -33,8 +33,8 @@ export const BlogCatalog: React.FC = () => {
|
|||||||
const fetchPosts = async () => {
|
const fetchPosts = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<BlogPost[]>("/blog");
|
const data = await getBlogPosts();
|
||||||
setPosts(response.data);
|
setPosts(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
@ -70,8 +70,8 @@ export const BlogCatalog: React.FC = () => {
|
|||||||
author: "Technical Architect",
|
author: "Technical Architect",
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await apiClient.post<BlogPost>("/blog/new", payload);
|
const newPost = await createBlogPost(payload);
|
||||||
setPosts((prev) => [response.data, ...prev]);
|
setPosts((prev) => [newPost, ...prev]);
|
||||||
|
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setContent("");
|
setContent("");
|
||||||
|
|||||||
@ -153,10 +153,31 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
// Check existing acceptances to skip steps
|
// Check existing acceptances to skip steps
|
||||||
getMyAcceptances().then(data => {
|
getMyAcceptances().then(data => {
|
||||||
const hasNDA = data.some((a: any) => a.document.type === 'NDA');
|
const ndaAcceptance = data.find((a: any) => a.document.type === 'NDA');
|
||||||
const hasMSA = data.some((a: any) => a.document.type === 'MSA');
|
const msaAcceptance = data.find((a: any) => a.document.type === 'MSA');
|
||||||
if (hasNDA && !hasMSA) setStep(2);
|
|
||||||
if (hasNDA && hasMSA) setStep(3);
|
if (ndaAcceptance) {
|
||||||
|
if (ndaAcceptance.signatureBase64) {
|
||||||
|
setNdaSignature(ndaAcceptance.signatureBase64);
|
||||||
|
setNdaMode('draw');
|
||||||
|
} else if (ndaAcceptance.documentUrl) {
|
||||||
|
setNdaUploadUrl(ndaAcceptance.documentUrl);
|
||||||
|
setNdaMode('upload');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msaAcceptance) {
|
||||||
|
if (msaAcceptance.signatureBase64) {
|
||||||
|
setMsaSignature(msaAcceptance.signatureBase64);
|
||||||
|
setMsaMode('draw');
|
||||||
|
} else if (msaAcceptance.documentUrl) {
|
||||||
|
setMsaUploadUrl(msaAcceptance.documentUrl);
|
||||||
|
setMsaMode('upload');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ndaAcceptance && !msaAcceptance) setStep(2);
|
||||||
|
if (ndaAcceptance && msaAcceptance) setStep(3);
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
}
|
}
|
||||||
}, [user, navigate]);
|
}, [user, navigate]);
|
||||||
@ -249,7 +270,7 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
|
|
||||||
{mode === 'draw' ? (
|
{mode === 'draw' ? (
|
||||||
<div className="flex-1 flex flex-col justify-center">
|
<div className="flex-1 flex flex-col justify-center">
|
||||||
<SignatureCapture onSignatureComplete={setSignature} />
|
<SignatureCapture onSignatureComplete={setSignature} initialSignature={signature} />
|
||||||
{signature && (
|
{signature && (
|
||||||
<div className="mt-6 flex items-center justify-between p-4 bg-ink-100 border border-ink-300 rounded-xl">
|
<div className="mt-6 flex items-center justify-between p-4 bg-ink-100 border border-ink-300 rounded-xl">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@ -1,59 +1,70 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import { CheckCircle, Clock, Search, XCircle, Eye } from 'lucide-react';
|
import { CheckCircle, Clock, Search, XCircle, Eye } from "lucide-react";
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { usePendingPartnersQuery } from '../../hooks/use-legal-query';
|
import { usePendingPartnersQuery } from "../../hooks/use-legal-query";
|
||||||
import { useApprovePartnerMutation } from '../../hooks/use-legal-mutation';
|
import { useApprovePartnerMutation } from "../../hooks/use-legal-mutation";
|
||||||
import PageHeader from '../../components/ui/PageHeader';
|
import PageHeader from "../../components/ui/PageHeader";
|
||||||
import Button from '../../components/ui/Button';
|
import Button from "../../components/ui/Button";
|
||||||
import { useToast } from '../../hooks/use-toast';
|
import { useToast } from "../../hooks/use-toast";
|
||||||
import { DocumentPreviewModal } from '../../components/ui/DocumentPreviewModal';
|
import { DocumentPreviewModal } from "../../components/ui/DocumentPreviewModal";
|
||||||
import { PageLayout } from '../../components/layout/PageLayout';
|
import { PageLayout } from "../../components/layout/PageLayout";
|
||||||
|
|
||||||
export const ApprovalsPage: React.FC = () => {
|
export const ApprovalsPage: React.FC = () => {
|
||||||
const { success, error } = useToast();
|
const { success, error } = useToast();
|
||||||
const { data: partners = [], isLoading } = usePendingPartnersQuery();
|
const { data: partners = [], isLoading } = usePendingPartnersQuery();
|
||||||
const approveMutation = useApprovePartnerMutation();
|
const approveMutation = useApprovePartnerMutation();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
// Preview Modal state
|
// Preview Modal state
|
||||||
const [selectedPartner, setSelectedPartner] = useState<any | null>(null);
|
const [selectedPartner, setSelectedPartner] = useState<any | null>(null);
|
||||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
const [verifiedDocs, setVerifiedDocs] = useState<Record<string, { nda: boolean; msa: boolean }>>({});
|
const [verifiedDocs, setVerifiedDocs] = useState<
|
||||||
|
Record<string, { nda: boolean; msa: boolean }>
|
||||||
|
>({});
|
||||||
|
|
||||||
const approvePartner = (partnerId: string, email: string) => {
|
const approvePartner = (partnerId: string, email: string) => {
|
||||||
approveMutation.mutate(partnerId, {
|
approveMutation.mutate(partnerId, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
success("Partner access approved", `Access has been granted to ${email}.`);
|
success(
|
||||||
|
"Partner access approved",
|
||||||
|
`Access has been granted to ${email}.`,
|
||||||
|
);
|
||||||
setIsPreviewOpen(false);
|
setIsPreviewOpen(false);
|
||||||
setSelectedPartner(null);
|
setSelectedPartner(null);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
error("Approval failed", err.response?.data?.error || "Could not approve partner access.");
|
error(
|
||||||
}
|
"Approval failed",
|
||||||
|
err.response?.data?.error || "Could not approve partner access.",
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenPreview = (partner: any, _docType: 'NDA' | 'MSA') => {
|
const handleOpenPreview = (partner: any, _docType: "NDA" | "MSA") => {
|
||||||
setSelectedPartner(partner);
|
setSelectedPartner(partner);
|
||||||
setIsPreviewOpen(true);
|
setIsPreviewOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVerify = (partnerId: string, docType: 'NDA' | 'MSA') => {
|
const handleVerify = (partnerId: string, docType: "NDA" | "MSA") => {
|
||||||
setVerifiedDocs(prev => {
|
setVerifiedDocs((prev) => {
|
||||||
const partnerStatus = prev[partnerId] || { nda: false, msa: false };
|
const partnerStatus = prev[partnerId] || { nda: false, msa: false };
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[partnerId]: {
|
[partnerId]: {
|
||||||
...partnerStatus,
|
...partnerStatus,
|
||||||
[docType === 'NDA' ? 'nda' : 'msa']: true
|
[docType === "NDA" ? "nda" : "msa"]: true,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
success(`${docType} document signature verified.`, "Verification status updated.");
|
success(
|
||||||
|
`${docType} document signature verified.`,
|
||||||
|
"Verification status updated.",
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredPartners = partners.filter(p =>
|
const filteredPartners = partners.filter((p) =>
|
||||||
p.email.toLowerCase().includes(searchTerm.toLowerCase())
|
p.email.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@ -69,11 +80,6 @@ export const ApprovalsPage: React.FC = () => {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Approvals Queue"
|
title="Approvals Queue"
|
||||||
subtitle="Review and approve partner legal documents to grant platform access."
|
subtitle="Review and approve partner legal documents to grant platform access."
|
||||||
badge={
|
|
||||||
<span className="bg-ink-900 text-ink-0 text-[10px] px-2 py-0.5 rounded-full font-bold border border-ink-700 shadow-sm uppercase tracking-wider shrink-0">
|
|
||||||
{partners.length} Pending
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -116,23 +122,38 @@ export const ApprovalsPage: React.FC = () => {
|
|||||||
<div className="w-12 h-12 rounded-full bg-ink-50 flex items-center justify-center mx-auto mb-4 border border-ink-200">
|
<div className="w-12 h-12 rounded-full bg-ink-50 flex items-center justify-center mx-auto mb-4 border border-ink-200">
|
||||||
<CheckCircle className="w-6 h-6 text-ink-900" />
|
<CheckCircle className="w-6 h-6 text-ink-900" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-ink-900 font-bold text-sm">Queue is empty</p>
|
<p className="text-ink-900 font-bold text-sm">
|
||||||
<p className="text-ink-500 text-xs mt-1">All partners have been reviewed.</p>
|
Queue is empty
|
||||||
|
</p>
|
||||||
|
<p className="text-ink-500 text-xs mt-1">
|
||||||
|
All partners have been reviewed.
|
||||||
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredPartners.map(partner => {
|
filteredPartners.map((partner) => {
|
||||||
const nda = partner.acceptances.find(a => a.document.type === 'NDA');
|
const nda = partner.acceptances.find(
|
||||||
const msa = partner.acceptances.find(a => a.document.type === 'MSA');
|
(a) => a.document.type === "NDA",
|
||||||
|
);
|
||||||
|
const msa = partner.acceptances.find(
|
||||||
|
(a) => a.document.type === "MSA",
|
||||||
|
);
|
||||||
|
|
||||||
const partnerVerified = verifiedDocs[partner.id] || { nda: false, msa: false };
|
const partnerVerified = verifiedDocs[partner.id] || {
|
||||||
|
nda: false,
|
||||||
|
msa: false,
|
||||||
|
};
|
||||||
const isEligible = partnerVerified.nda && partnerVerified.msa;
|
const isEligible = partnerVerified.nda && partnerVerified.msa;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.tr
|
<motion.tr
|
||||||
key={partner.id}
|
key={partner.id}
|
||||||
initial={{ opacity: 1 }}
|
initial={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0, x: -20, backgroundColor: 'rgba(0, 0, 0, 0.02)' }}
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
x: -20,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.02)",
|
||||||
|
}}
|
||||||
className="hover:bg-ink-50 transition-colors group"
|
className="hover:bg-ink-50 transition-colors group"
|
||||||
>
|
>
|
||||||
<td className="px-5 py-4">
|
<td className="px-5 py-4">
|
||||||
@ -141,7 +162,9 @@ export const ApprovalsPage: React.FC = () => {
|
|||||||
{partner.email.charAt(0).toUpperCase()}
|
{partner.email.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-bold text-ink-900 text-sm">{partner.email}</p>
|
<p className="font-bold text-ink-900 text-sm">
|
||||||
|
{partner.email}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-ink-500 flex items-center gap-1">
|
<p className="text-xs text-ink-500 flex items-center gap-1">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{new Date(partner.createdAt).toLocaleDateString()}
|
{new Date(partner.createdAt).toLocaleDateString()}
|
||||||
@ -154,23 +177,29 @@ export const ApprovalsPage: React.FC = () => {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle className="w-4 h-4 text-emerald-600" />
|
<CheckCircle className="w-4 h-4 text-emerald-600" />
|
||||||
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
|
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
|
||||||
{nda.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
|
{nda.documentUrl
|
||||||
|
? "Uploaded PDF"
|
||||||
|
: "Digital Sign"}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleOpenPreview(partner, 'NDA')}
|
onClick={() => handleOpenPreview(partner, "NDA")}
|
||||||
className="inline-flex items-center gap-1.5 text-xs font-bold text-ink-700 hover:text-ink-900 cursor-pointer ml-2 bg-ink-50 hover:bg-ink-100 border border-ink-200 px-2.5 py-1 rounded-lg transition-all"
|
className="inline-flex items-center gap-1.5 text-xs font-bold text-ink-700 hover:text-ink-900 cursor-pointer ml-2 bg-ink-50 hover:bg-ink-100 border border-ink-200 px-2.5 py-1 rounded-lg transition-all"
|
||||||
>
|
>
|
||||||
<Eye className="w-3.5 h-3.5" />
|
<Eye className="w-3.5 h-3.5" />
|
||||||
<span>Preview</span>
|
<span>Preview</span>
|
||||||
</button>
|
</button>
|
||||||
{partnerVerified.nda && (
|
{partnerVerified.nda && (
|
||||||
<span className="text-[10px] text-emerald-600 font-bold uppercase tracking-wider ml-1 bg-emerald-50 px-1.5 py-0.5 rounded border border-emerald-200">Verified</span>
|
<span className="text-[10px] text-emerald-600 font-bold uppercase tracking-wider ml-1 bg-emerald-50 px-1.5 py-0.5 rounded border border-emerald-200">
|
||||||
|
Verified
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-ink-400">
|
<div className="flex items-center gap-2 text-ink-400">
|
||||||
<XCircle className="w-4 h-4" />
|
<XCircle className="w-4 h-4" />
|
||||||
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">Missing</span>
|
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">
|
||||||
|
Missing
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@ -179,34 +208,49 @@ export const ApprovalsPage: React.FC = () => {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle className="w-4 h-4 text-emerald-600" />
|
<CheckCircle className="w-4 h-4 text-emerald-600" />
|
||||||
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
|
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
|
||||||
{msa.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
|
{msa.documentUrl
|
||||||
|
? "Uploaded PDF"
|
||||||
|
: "Digital Sign"}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleOpenPreview(partner, 'MSA')}
|
onClick={() => handleOpenPreview(partner, "MSA")}
|
||||||
className="inline-flex items-center gap-1.5 text-xs font-bold text-ink-700 hover:text-ink-900 cursor-pointer ml-2 bg-ink-50 hover:bg-ink-100 border border-ink-200 px-2.5 py-1 rounded-lg transition-all"
|
className="inline-flex items-center gap-1.5 text-xs font-bold text-ink-700 hover:text-ink-900 cursor-pointer ml-2 bg-ink-50 hover:bg-ink-100 border border-ink-200 px-2.5 py-1 rounded-lg transition-all"
|
||||||
>
|
>
|
||||||
<Eye className="w-3.5 h-3.5" />
|
<Eye className="w-3.5 h-3.5" />
|
||||||
<span>Preview</span>
|
<span>Preview</span>
|
||||||
</button>
|
</button>
|
||||||
{partnerVerified.msa && (
|
{partnerVerified.msa && (
|
||||||
<span className="text-[10px] text-emerald-600 font-bold uppercase tracking-wider ml-1 bg-emerald-50 px-1.5 py-0.5 rounded border border-emerald-200">Verified</span>
|
<span className="text-[10px] text-emerald-600 font-bold uppercase tracking-wider ml-1 bg-emerald-50 px-1.5 py-0.5 rounded border border-emerald-200">
|
||||||
|
Verified
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-ink-400">
|
<div className="flex items-center gap-2 text-ink-400">
|
||||||
<XCircle className="w-4 h-4" />
|
<XCircle className="w-4 h-4" />
|
||||||
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">Missing</span>
|
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">
|
||||||
|
Missing
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-4 text-right">
|
<td className="px-5 py-4 text-right">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => approvePartner(partner.id, partner.email)}
|
onClick={() =>
|
||||||
disabled={!isEligible || (approveMutation.isPending && approveMutation.variables === partner.id)}
|
approvePartner(partner.id, partner.email)
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
!isEligible ||
|
||||||
|
(approveMutation.isPending &&
|
||||||
|
approveMutation.variables === partner.id)
|
||||||
|
}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{approveMutation.isPending && approveMutation.variables === partner.id ? 'Approving...' : 'Approve Access'}
|
{approveMutation.isPending &&
|
||||||
|
approveMutation.variables === partner.id
|
||||||
|
? "Approving..."
|
||||||
|
: "Approve Access"}
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</motion.tr>
|
</motion.tr>
|
||||||
@ -230,10 +274,17 @@ export const ApprovalsPage: React.FC = () => {
|
|||||||
partnerEmail={selectedPartner.email}
|
partnerEmail={selectedPartner.email}
|
||||||
partnerCreatedAt={selectedPartner.createdAt}
|
partnerCreatedAt={selectedPartner.createdAt}
|
||||||
acceptances={selectedPartner.acceptances}
|
acceptances={selectedPartner.acceptances}
|
||||||
verifiedDocs={verifiedDocs[selectedPartner.id] || { nda: false, msa: false }}
|
verifiedDocs={
|
||||||
|
verifiedDocs[selectedPartner.id] || { nda: false, msa: false }
|
||||||
|
}
|
||||||
onVerify={(docType) => handleVerify(selectedPartner.id, docType)}
|
onVerify={(docType) => handleVerify(selectedPartner.id, docType)}
|
||||||
onApprovePartner={() => approvePartner(selectedPartner.id, selectedPartner.email)}
|
onApprovePartner={() =>
|
||||||
isApproving={approveMutation.isPending && approveMutation.variables === selectedPartner.id}
|
approvePartner(selectedPartner.id, selectedPartner.email)
|
||||||
|
}
|
||||||
|
isApproving={
|
||||||
|
approveMutation.isPending &&
|
||||||
|
approveMutation.variables === selectedPartner.id
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
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