Compare commits

..

No commits in common. "main" and "feat/yaseen" have entirely different histories.

115 changed files with 3638 additions and 21131 deletions

13
.gitignore vendored
View File

@ -29,16 +29,3 @@ uploads/*
.next/* .next/*
.env.local .env.local
.vite/* .vite/*
# Developer Documentation
/rules.md
/memory.md
/phases.md
/architecture.md
/Guide.md
# Dedicated Documentation Folder
/documents/
/minio-seed/
ai-advisor.md
testing-strategy.md

View File

@ -0,0 +1,95 @@
const fs = require('fs');
async function runTests() {
const BASE_URL = 'http://localhost:5001/api/v1';
let token = '';
try {
console.log('1. Testing Health Endpoint...');
const health = await fetch(`${BASE_URL}/health`);
const healthData = await health.json();
console.log('Health:', healthData);
if (!health.ok) throw new Error('Health check failed');
console.log('\n2. Testing Registration...');
const regRes = await fetch(`${BASE_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'admin@tech4biz.com', password: 'securepassword', role: 'ADMIN' })
});
console.log('Registration Status (Admin):', regRes.status);
if (!regRes.ok && regRes.status !== 400) throw new Error('Registration failed');
const regPartnerRes = await fetch(`${BASE_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'partner@tech4biz.com', password: 'securepassword', role: 'PARTNER_USER' })
});
console.log('Registration Status (Partner):', regPartnerRes.status);
if (!regPartnerRes.ok && regPartnerRes.status !== 400) throw new Error('Partner Registration failed');
console.log('\n3. Testing Login...');
const loginRes = await fetch(`${BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'admin@tech4biz.com', password: 'securepassword' })
});
const loginData = await loginRes.json();
console.log('Login Status:', loginRes.status);
if (!loginRes.ok) throw new Error('Login failed');
token = loginData.accessToken;
console.log('Received Access Token: ', token.substring(0, 15) + '...');
console.log('\n4. Testing Organization Creation...');
const orgRes = await fetch(`${BASE_URL}/organizations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ name: 'Tech4Biz Partners' })
});
const orgData = await orgRes.json();
console.log('Organization Created:', orgData);
if (!orgRes.ok) throw new Error('Org creation failed');
console.log('\n5. Testing Legal Document Creation...');
const legalRes = await fetch(`${BASE_URL}/legal/documents`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ type: 'NDA', version: '1.0', content: 'You must not disclose anything.' })
});
const legalData = await legalRes.json();
console.log('Legal Document Created:', legalData);
if (!legalRes.ok) throw new Error('Legal creation failed');
console.log('\n6. Testing Asset Upload...');
// Create a dummy file
fs.writeFileSync('test-file.txt', 'This is a test file for upload.');
const formData = new FormData();
const fileBlob = new Blob([fs.readFileSync('test-file.txt')], { type: 'text/plain' });
formData.append('file', fileBlob, 'test-file.txt');
formData.append('title', 'My Secret Document');
const uploadRes = await fetch(`${BASE_URL}/assets/upload`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
const uploadData = await uploadRes.json();
console.log('Asset Uploaded:', uploadData);
if (!uploadRes.ok) throw new Error('Upload failed');
fs.unlinkSync('test-file.txt');
console.log('\n✅ ALL TESTS PASSED SUCCESSFULLY!');
} catch (error) {
console.error('\n❌ TEST FAILED:', error);
}
}
runTests();

File diff suppressed because it is too large Load Diff

View File

@ -6,29 +6,22 @@
"scripts": { "scripts": {
"start": "node dist/app.js", "start": "node dist/app.js",
"dev": "nodemon src/app.ts", "dev": "nodemon src/app.ts",
"build": "tsc", "build": "tsc"
"test:smtp": "ts-node scripts/test-smtp.ts"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1083.0", "@aws-sdk/client-s3": "^3.1083.0",
"@aws-sdk/s3-request-presigner": "^3.1083.0", "@aws-sdk/s3-request-presigner": "^3.1083.0",
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"axios": "^1.18.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cheerio": "^1.2.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mammoth": "^1.12.0",
"multer": "^2.2.0", "multer": "^2.2.0",
"nodemailer": "^9.0.3",
"pdf-parse": "^2.4.5",
"pg": "^8.22.0", "pg": "^8.22.0",
"xlsx": "^0.18.5",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
@ -39,8 +32,6 @@
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.2.0", "@types/multer": "^2.2.0",
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"@types/nodemailer": "^8.0.1",
"@types/pdf-parse": "^1.1.5",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"nodemon": "^3.1.0", "nodemon": "^3.1.0",
"prisma": "^7.8.0", "prisma": "^7.8.0",

View File

@ -24,19 +24,6 @@ model Organization {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
users User[] users User[]
sharedAssets SharedAsset[] sharedAssets SharedAsset[]
branches Branch[]
}
model Branch {
id String @id @default(uuid())
name String
code String? @unique
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
users User[]
sharedAssets SharedAsset[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model User { model User {
@ -49,131 +36,38 @@ model User {
inviteToken String? @unique inviteToken String? @unique
inviteTokenExp DateTime? inviteTokenExp DateTime?
organizationId String? organizationId String?
branchId String?
onboardingStatus String @default("PENDING_ONBOARDING") onboardingStatus String @default("PENDING_ONBOARDING")
partnerGroup String?
assignedNdaId String?
assignedMsaId String?
website String?
sector String?
companySize String?
defaultTheme String? @default("dark")
companyName String?
showEcosystemTab Boolean @default(true)
assignedNda LegalDocument? @relation("AssignedNda", fields: [assignedNdaId], references: [id], onDelete: SetNull)
assignedMsa LegalDocument? @relation("AssignedMsa", fields: [assignedMsaId], references: [id], onDelete: SetNull)
organization Organization? @relation(fields: [organizationId], references: [id]) organization Organization? @relation(fields: [organizationId], references: [id])
branch Branch? @relation(fields: [branchId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
acceptances LegalAcceptance[] acceptances LegalAcceptance[]
auditLogs AuditLog[] auditLogs AuditLog[]
sharedAssets SharedAsset[] sharedAssets SharedAsset[]
downloadRequests DownloadRequest[] downloadRequests DownloadRequest[]
chatSessions ChatSession[]
} }
model Asset { model Asset {
id String @id @default(uuid()) id String @id @default(uuid())
title String title String
type String type String
size Int size Int
url String url String
version Int @default(1) version Int @default(1)
uploadedBy String uploadedBy String
description String? @db.Text description String? @db.Text
categoryId String? categoryId String?
subcategory String? subcategory String?
tags String[] tags String[]
downloadsCount Int @default(0) downloadsCount Int @default(0)
githubUrl String? githubUrl String?
status String @default("published") status String @default("published")
isDownloadable Boolean @default(true) isDownloadable Boolean @default(true)
thumbnailUrl String? folderId String?
problemStatement String? @db.Text folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
solution String? @db.Text sharedWith SharedAsset[]
contentType String? downloadRequests DownloadRequest[]
includeInKnowledgeBase Boolean @default(true) createdAt DateTime @default(now())
knowledgeScope String? @default("CATALOG_SHARED") updatedAt DateTime @updatedAt
folderId String?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
verticals Vertical[] @relation("AssetVerticals")
techStacks TechStack[] @relation("AssetTechStacks")
engagementTypes EngagementType[] @relation("AssetEngagementTypes")
complianceStandards ComplianceStandard[] @relation("AssetComplianceStandards")
sharedWith SharedAsset[]
downloadRequests DownloadRequest[]
assetGroups AssetGroup[]
embeddings AssetEmbedding[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Vertical {
id String @id @default(uuid())
name String @unique
slug String @unique
icon String?
description String?
color String?
orderIndex Int @default(0)
isActive Boolean @default(true)
assets Asset[] @relation("AssetVerticals")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model TechStack {
id String @id @default(uuid())
name String @unique
slug String @unique
category String // "Languages & Frameworks", "AI & ML", "Data & Backend", "Cloud & Infra"
icon String?
description String?
color String?
orderIndex Int @default(0)
isActive Boolean @default(true)
assets Asset[] @relation("AssetTechStacks")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model EngagementType {
id String @id @default(uuid())
name String @unique
slug String @unique
icon String?
description String?
color String?
orderIndex Int @default(0)
isActive Boolean @default(true)
assets Asset[] @relation("AssetEngagementTypes")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ComplianceStandard {
id String @id @default(uuid())
name String @unique
slug String @unique
icon String?
description String?
color String?
orderIndex Int @default(0)
isActive Boolean @default(true)
assets Asset[] @relation("AssetComplianceStandards")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AssetNotification {
id String @id @default(uuid())
title String
message String @db.Text
sentBy String
targetOrgIds String[]
assetIds String[]
createdAt DateTime @default(now())
} }
model SharedAsset { model SharedAsset {
@ -181,12 +75,10 @@ model SharedAsset {
assetId String assetId String
organizationId String organizationId String
userId String? userId String?
branchId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade) asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
branch Branch? @relation(fields: [branchId], references: [id], onDelete: Cascade)
@@unique([assetId, organizationId, userId]) @@unique([assetId, organizationId, userId])
} }
@ -214,29 +106,26 @@ model Folder {
} }
model LegalDocument { model LegalDocument {
id String @id @default(uuid()) id String @id @default(uuid())
type DocumentType type DocumentType
version String version String
content String content String
isActive Boolean @default(false) isActive Boolean @default(false)
pdfUrl String? pdfUrl String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
acceptances LegalAcceptance[] acceptances LegalAcceptance[]
assignedNdaUsers User[] @relation("AssignedNda")
assignedMsaUsers User[] @relation("AssignedMsa")
} }
model LegalAcceptance { model LegalAcceptance {
id String @id @default(uuid()) id String @id @default(uuid())
docId String docId String
userId String userId String
ipAddress String ipAddress String
signatureHash String? signatureHash String?
documentUrl String? documentUrl String?
signatureBase64 String? acceptedAt DateTime @default(now())
acceptedAt DateTime @default(now()) document LegalDocument @relation(fields: [docId], references: [id])
document LegalDocument @relation(fields: [docId], references: [id]) user User @relation(fields: [userId], references: [id])
user User @relation(fields: [userId], references: [id])
} }
model AuditLog { model AuditLog {
@ -248,80 +137,3 @@ 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 AssetGroup {
id String @id @default(uuid())
name String @unique
description String?
assets Asset[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model EcosystemOffering {
id String @id @default(uuid())
name String @unique
type String // "PRODUCT" | "SERVICE"
tagline String
description String @db.Text
benefits String[]
websiteUrl String
ctaText String @default("Visit Website")
logoIcon String @default("Globe")
logoUrl String?
mediaUrl String?
mediaType String? // "IMAGE" | "VIDEO" | "GIF"
includeInKnowledgeBase Boolean @default(true)
orderIndex Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ContentShowcase {
id String @id @default(uuid())
title String
description String? @db.Text
youtubeUrl String
thumbnailUrl String?
redirectUrl String?
redirectLabel String? @default("Learn More")
includeInKnowledgeBase Boolean @default(true)
orderIndex Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AssetEmbedding {
id String @id @default(uuid())
assetId String
chunkIndex Int
chunkType String @default("TEXT") // TEXT, SLIDE, SHEET, PAGE, TRANSCRIPT
sourceMetadata Json? // OKF Standard JSON: { title, location: "Page 4" | "Slide 2" | "Sheet Data", okfCategory }
content String @db.Text
vector String // Vector array string representation for similarity search
createdAt DateTime @default(now())
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
@@index([assetId])
}
model ChatSession {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
messages ChatMessage[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ChatMessage {
id String @id @default(uuid())
sessionId String
session ChatSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
sender String // "USER" | "ASSISTANT"
content String @db.Text
citations Json? // Array of OKF standardized source citations
createdAt DateTime @default(now())
}

View File

@ -1,148 +0,0 @@
import dotenv from 'dotenv';
dotenv.config();
import prisma from '../src/utils/db';
const INITIAL_VERTICALS = [
{ name: 'Cybersecurity', slug: 'cybersecurity', icon: 'Shield', color: '#ef4444', description: 'OT, Infrastructure & Data Defense' },
{ name: 'AI & ML', slug: 'ai-ml', icon: 'Cpu', color: '#8b5cf6', description: 'Artificial Intelligence & Machine Learning' },
{ name: 'Healthcare & Pharma', slug: 'healthcare-pharma', icon: 'Activity', color: '#ec4899', description: 'Medical, Diagnostics & BioTech' },
{ name: 'Finance & Banking', slug: 'finance-banking', icon: 'Landmark', color: '#10b981', description: 'FinTech, Payments & Risk Analytics' },
{ name: 'Insurance', slug: 'insurance', icon: 'FileCheck', color: '#3b82f6', description: 'InsurTech, Claims & Underwriting' },
{ name: 'Energy & Utilities', slug: 'energy-utilities', icon: 'Zap', color: '#f59e0b', description: 'Smart Grid, Power & Renewables' },
{ name: 'Agriculture', slug: 'agriculture', icon: 'Leaf', color: '#84cc16', description: 'AgriTech, Precision Farming & Supply' },
{ name: 'Education', slug: 'education', icon: 'GraduationCap', color: '#06b6d4', description: 'EdTech, LMS & Institutional Tools' },
{ name: 'Manufacturing & IoT', slug: 'manufacturing-iot', icon: 'Factory', color: '#6366f1', description: 'Industrial Automation & Edge IoT' },
{ name: 'Automotive', slug: 'automotive', icon: 'Car', color: '#14b8a6', description: 'Connected Vehicles & Telematics' },
{ name: 'Retail & Supply Chain', slug: 'retail-supply-chain', icon: 'ShoppingBag', color: '#d97706', description: 'E-Commerce, Logistics & Smart Retail' },
{ name: 'Blockchain', slug: 'blockchain', icon: 'Link', color: '#0284c7', description: 'Smart Contracts & Web3 Infrastructure' },
];
function inferContentType(type: string, subcategory?: string | null): string {
const sub = (subcategory || '').toLowerCase().trim();
if (sub === 'case study') return 'case_study';
if (sub === 'showcase') return 'showcase';
if (sub === 'news letter' || sub === 'marketing milestone') return 'newsletter';
if (sub === 'portfolio' || sub === 'company deck' || sub === 'product showcase' || sub === 'rnd innovation') return 'portfolio';
if (sub === 'mvp') return 'mvp';
if (sub === 'workflow automation' || sub === 'worlflow automation') return 'workflow';
if (sub === 'use case') return 'use_case';
if (sub === 'test drive resources') return 'test_drive';
if (type === 'case_study') return 'case_study';
if (type === 'url') return 'showcase';
if (type.includes('pdf') || type.includes('document') || type.includes('word')) return 'document';
if (type.includes('sheet') || type.includes('csv') || type.includes('excel')) return 'spreadsheet';
if (type.includes('presentation') || type.includes('powerpoint')) return 'presentation';
if (type.includes('image')) return 'image';
return 'document';
}
function matchVerticalSlugs(title: string, description?: string | null): string[] {
const text = `${title} ${description || ''}`.toLowerCase();
const matched = new Set<string>();
if (/cybersecurity|security|threat|ot |defence|defense|ransomware|audit|hacker|firewall|fpga|compliance|kyc|aml/.test(text)) {
matched.add('cybersecurity');
}
if (/ai|machine learning|resnet|densenet|nlp|chatbot|genai|deep learning|prediction|predictive|forecasting|gpt|n8n|rag|speech/.test(text)) {
matched.add('ai-ml');
}
if (/health|medical|pharma|drug|cancer|hospital|patient|doctor|eye|blood|hematology|x-ray|brain tumor|bio|biotech|wearable|ventilator|ct scan/.test(text)) {
matched.add('healthcare-pharma');
}
if (/bank|fintech|payment|fraud|credit|loan|financial|accounting|cash|revenue|investor|audit|trade|b2b/.test(text)) {
matched.add('finance-banking');
}
if (/insurance|claims|underwriting|insurtech|policy|catastrophe|actuary/.test(text)) {
matched.add('insurance');
}
if (/energy|grid|power|renewable|ev |electric vehicle|utility|utilities|battery|charging|power plant|oms|ems|metering|solar|wind/.test(text)) {
matched.add('energy-utilities');
}
if (/agri|farm|crop|livestock|soil|aqua|pest|aquaponics|harvest|yield/.test(text)) {
matched.add('agriculture');
}
if (/education|student|learning|lms|classroom|exam|academic|textbook|proctoring|university|school/.test(text)) {
matched.add('education');
}
if (/manufactur|industrial|iot|edge|predictive maintenance|digital twin|esp32|ble board|pcb|robotics|factory|plc/.test(text)) {
matched.add('manufacturing-iot');
}
if (/vehicle|automotive|fleet|telematics|adas|car|driving|mobility|maas/.test(text)) {
matched.add('automotive');
}
if (/retail|e-commerce|supply chain|inventory|mall|store|basket|procurement|logistics|fmcg|pos/.test(text)) {
matched.add('retail-supply-chain');
}
if (/blockchain|smart contract|credentialing|traceability/.test(text)) {
matched.add('blockchain');
}
return Array.from(matched);
}
async function main() {
console.log('🚀 Starting Data Normalization & Taxonomy Seeding...');
// 1. Seed Verticals
const verticalMap = new Map<string, string>(); // slug -> id
for (const v of INITIAL_VERTICALS) {
const upserted = await prisma.vertical.upsert({
where: { slug: v.slug },
update: { name: v.name, icon: v.icon, color: v.color, description: v.description },
create: v,
});
verticalMap.set(v.slug, upserted.id);
}
console.log(`${verticalMap.size} Verticals ready.`);
// 2. Fix typos in Subcategories
await prisma.asset.updateMany({
where: { subcategory: { in: ['Showcaase', 'showcase'] } },
data: { subcategory: 'Showcase' },
});
await prisma.asset.updateMany({
where: { subcategory: 'Worlflow Automation' },
data: { subcategory: 'Workflow Automation' },
});
await prisma.asset.updateMany({
where: { subcategory: 'Use Case', categoryId: 'Marketing' },
data: { categoryId: 'Technical' },
});
await prisma.asset.updateMany({
where: { subcategory: 'MVP', categoryId: 'Presentations' },
data: { categoryId: 'Resources' },
});
console.log('✅ Subcategory typos & category alignments fixed.');
// 3. Process all assets
const assets = await prisma.asset.findMany();
let updatedCount = 0;
for (const asset of assets) {
const contentType = inferContentType(asset.type, asset.subcategory);
const matchedSlugs = matchVerticalSlugs(asset.title, asset.description);
const verticalIds = matchedSlugs.map(slug => verticalMap.get(slug)).filter(Boolean) as string[];
await prisma.asset.update({
where: { id: asset.id },
data: {
contentType,
verticals: {
set: verticalIds.map(id => ({ id })),
},
},
});
updatedCount++;
}
console.log(`🎉 Successfully normalized ${updatedCount} assets with content types & vertical tags!`);
}
main()
.catch(err => {
console.error('❌ Migration failed:', err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -1,87 +0,0 @@
import dotenv from 'dotenv';
import nodemailer from 'nodemailer';
import path from 'path';
// Load environment variables from .env
dotenv.config({ path: path.resolve(__dirname, '../.env') });
async function main() {
const host = process.env.SMTP_HOST || 'localhost';
const port = parseInt(process.env.SMTP_PORT || '587', 10);
const user = process.env.SMTP_USER || '';
const pass = process.env.SMTP_PASS || '';
const from = process.env.SMTP_FROM || user;
const enableReal = process.env.ENABLE_REAL_EMAILS;
const nodeEnv = process.env.NODE_ENV;
console.log('==================================================');
console.log(' SMTP CREDENTIALS & SERVICE TEST ');
console.log('==================================================');
console.log(`• NODE_ENV : ${nodeEnv || '(not set)'}`);
console.log(`• ENABLE_REAL_EMAILS : ${enableReal || '(not set)'}`);
console.log(`• SMTP_HOST : ${host}`);
console.log(`• SMTP_PORT : ${port}`);
console.log(`• SMTP_USER : ${user || '(empty)'}`);
console.log(`• SMTP_PASS : ${pass ? '********' : '(empty)'}`);
console.log(`• SMTP_FROM : ${from}`);
console.log('--------------------------------------------------');
if (enableReal !== 'true') {
console.warn('⚠️ WARNING: ENABLE_REAL_EMAILS is not set to "true". Real email dispatch is disabled in application code.');
}
const transporter = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: user && pass ? { user, pass } : undefined,
tls: {
rejectUnauthorized: false,
},
});
console.log('\n[1/2] Verifying SMTP connection & authentication credentials...');
try {
const verified = await transporter.verify();
console.log('✅ SUCCESS: SMTP Server is reachable and authentication credentials are VALID!');
} catch (err: any) {
console.error('❌ FAILED: SMTP Connection or Authentication failed!');
console.error(`Reason: ${err.message || err}`);
if (err.code === 'ECONNREFUSED') {
console.error(`👉 Suggestion: Port ${port} is blocked or not accepting connections on ${host}. Check firewall rules.`);
} else if (err.responseCode === 535 || err.code === 'EAUTH') {
console.error('👉 Suggestion: Invalid SMTP_USER or SMTP_PASS.');
}
process.exit(1);
}
// Optional: If an email argument is provided, send a test email
const recipient = process.argv[2];
if (recipient) {
console.log(`\n[2/2] Sending test email to: ${recipient}...`);
try {
const info = await transporter.sendMail({
from: from || user,
to: recipient,
subject: 'Tech4Biz Channel Partner SMTP Verification',
text: 'This is a test email sent from the Tech4Biz SMTP Test Script.',
html: '<div style="font-family: sans-serif; padding: 20px; border: 1px solid #ccc;"><h3>SMTP Test Successful!</h3><p>Your backend SMTP configuration is working properly.</p></div>',
});
console.log(`✅ SUCCESS: Email delivered successfully to ${recipient}!`);
console.log(`• Message ID: ${info.messageId}`);
} catch (err: any) {
console.error(`❌ FAILED: Could not send test email to ${recipient}.`);
console.error(`Reason: ${err.message || err}`);
}
} else {
console.log('\n To send an actual test email, run:');
console.log(' npx ts-node scripts/test-smtp.ts <your-email-address>');
}
console.log('==================================================\n');
}
main().catch((err) => {
console.error('Unexpected error:', err);
process.exit(1);
});

26
Channel-Backend/seed.js Normal file
View File

@ -0,0 +1,26 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function seed() {
await prisma.legalDocument.create({
data: {
type: 'NDA',
version: '1.0',
content: 'This is the standard Non-Disclosure Agreement content...',
isActive: true,
}
});
await prisma.legalDocument.create({
data: {
type: 'MSA',
version: '1.0',
content: 'This is the standard Master Services Agreement content...',
isActive: true,
}
});
console.log('Documents seeded.');
}
seed().catch(console.error).finally(() => prisma.$disconnect());

View File

@ -2,7 +2,6 @@ import dotenv from 'dotenv';
dotenv.config(); dotenv.config();
import prisma from './src/utils/db'; import prisma from './src/utils/db';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import { seedFourGroupTaxonomy } from './src/utils/seed-taxonomy';
async function seed() { async function seed() {
console.log('Starting database seeding...'); console.log('Starting database seeding...');
@ -74,7 +73,67 @@ async function seed() {
console.log(`Admin User already exists: ${adminEmail}`); console.log(`Admin User already exists: ${adminEmail}`);
} }
// 4. Seed Partner: Fully active & approved // 4. Seed Partner 1: Awaiting onboarding
const onboardingEmail = 'pending-onboarding@partner.com';
let onboardingUser = await prisma.user.findUnique({ where: { email: onboardingEmail } });
if (!onboardingUser) {
onboardingUser = await prisma.user.create({
data: {
email: onboardingEmail,
passwordHash,
role: 'PARTNER_USER',
onboardingStatus: 'PENDING_ONBOARDING',
mfaEnabled: false,
organizationId: partnerOrg.id
}
});
console.log(`Seeded Partner (Pending Onboarding): ${onboardingEmail}`);
} else {
console.log(`Partner (Pending Onboarding) already exists: ${onboardingEmail}`);
}
// 5. Seed Partner 2: Awaiting admin approval (Signed NDA & MSA)
const pendingApprovalEmail = 'pending-approval@partner.com';
let pendingApprovalUser = await prisma.user.findUnique({ where: { email: pendingApprovalEmail } });
if (!pendingApprovalUser) {
pendingApprovalUser = await prisma.user.create({
data: {
email: pendingApprovalEmail,
passwordHash,
role: 'PARTNER_USER',
onboardingStatus: 'PENDING_APPROVAL',
mfaEnabled: false,
organizationId: partnerOrg.id
}
});
console.log(`Seeded Partner (Pending Approval): ${pendingApprovalEmail}`);
// Seed LegalAcceptance for NDA
await prisma.legalAcceptance.create({
data: {
docId: ndaDoc.id,
userId: pendingApprovalUser.id,
ipAddress: '127.0.0.1',
signatureHash: 'sha256-dummyndaaccept1234567890abcdef',
acceptedAt: new Date()
}
});
// Seed LegalAcceptance for MSA
await prisma.legalAcceptance.create({
data: {
docId: msaDoc.id,
userId: pendingApprovalUser.id,
ipAddress: '127.0.0.1',
signatureHash: 'sha256-dummymsaaccept1234567890abcdef',
acceptedAt: new Date()
}
});
console.log('Seeded Legal Acceptances for NDA/MSA for pending-approval partner.');
} else {
console.log(`Partner (Pending Approval) already exists: ${pendingApprovalEmail}`);
}
// 6. Seed Partner 3: Fully active & approved
const activeEmail = 'active-partner@partner.com'; const activeEmail = 'active-partner@partner.com';
let activeUser = await prisma.user.findUnique({ where: { email: activeEmail } }); let activeUser = await prisma.user.findUnique({ where: { email: activeEmail } });
if (!activeUser) { if (!activeUser) {
@ -115,92 +174,6 @@ async function seed() {
console.log(`Partner (Approved) already exists: ${activeEmail}`); console.log(`Partner (Approved) already exists: ${activeEmail}`);
} }
// 5. Seed Ecosystem Offerings
const offerings = [
{
name: 'CodeNuk',
type: 'PRODUCT',
tagline: 'Accelerate software delivery with deterministic AI-powered backend generation.',
description: 'CodeNuk is an enterprise-grade AI platform that transforms Software Requirements Specifications (SRS) into production-ready backend foundations. By deterministically generating architecture, database schemas, APIs, security components, testing frameworks, and deployment-ready assets directly from business requirements, CodeNuk eliminates weeks of repetitive engineering effort while ensuring consistency, traceability, and architectural standardization.',
benefits: [
'Accelerates software development and time-to-market.',
'Standardizes backend architecture across projects.',
'Minimizes manual engineering effort and setup time.',
'Improves code consistency and traceability to business requirements.',
'Delivers deployment-ready backend assets with built-in testing and documentation.',
'Commercial model based on one-time project pricing instead of recurring subscriptions.'
],
websiteUrl: 'https://codenuk.com',
ctaText: 'Visit CodeNuk',
logoIcon: 'Code',
logoUrl: 'codenuk',
orderIndex: 0
},
{
name: 'Tech4Biz Solutions',
type: 'SERVICE',
tagline: 'A strategic Technology Execution Partner delivering end-to-end digital engineering and transformation services.',
description: 'Tech4Biz Solutions partners with enterprises to architect, build, integrate, and scale secure, future-ready technology solutions. Our expertise spans software engineering, AI, cloud, automation, IoT, and cybersecurity, enabling organizations to accelerate digital transformation, modernize legacy systems, and deliver technology initiatives with speed, quality, and confidence.',
benefits: [
'Custom Software Engineering & Cloud Solutions',
'AI, Automation & IoT Integration',
'Legacy Modernization & Cybersecurity Audit',
'Strategic Technology Execution & Ownership'
],
websiteUrl: 'https://www.tech4bizsolutions.com',
ctaText: 'Visit Tech4Biz',
logoIcon: 'Briefcase',
logoUrl: 'tech4biz',
orderIndex: 1
},
{
name: 'Audittrax Labs',
type: 'SERVICE',
tagline: 'An AI-powered platform delivering Tech Due Diligence, Technical Advisory, and Continuous Assurance.',
description: 'Audittrax Labs enables enterprises, investors, and business leaders to make informed technology decisions through Tech Due Diligence, Technical Advisory, and AI-driven audit, risk, and compliance services. By combining continuous assurance, automated controls monitoring, and expert technical assessments, the platform helps organizations evaluate technology landscapes, mitigate risk, strengthen governance, and accelerate confident business decisions.',
benefits: [
'AI-Driven Compliance & Tech Auditing',
'Comprehensive Tech Due Diligence for Investors',
'Automated Security & Risk Monitoring',
'Technical Advisory & Governance Solutions'
],
websiteUrl: 'https://auditraxlabs.com',
ctaText: 'Visit Audittrax',
logoIcon: 'Shield',
logoUrl: 'auditraxlabs',
orderIndex: 2
},
{
name: 'Cloudtopiaa',
type: 'PRODUCT',
tagline: 'An enterprise cloud platform delivering secure, scalable, and high-performance infrastructure services.',
description: 'Cloudtopiaa enables organizations to accelerate their cloud journey through enterprise-grade infrastructure, storage, networking, security, and cloud-native services. Designed for modern workloads, the platform helps businesses migrate, deploy, manage, and scale applications with improved resilience, operational efficiency, and cost optimization.',
benefits: [
'Enterprise-grade secure infrastructure',
'High-performance storage, networking, and cloud-native services',
'Cost optimization & operational efficiency audit',
'Seamless cloud migration and automation tools'
],
websiteUrl: 'https://cloudtopiaa.com',
ctaText: 'Visit Cloudtopiaa',
logoIcon: 'Cloud',
logoUrl: 'cloudtopiaa',
orderIndex: 3
}
];
for (const offering of offerings) {
await prisma.ecosystemOffering.upsert({
where: { name: offering.name },
update: offering,
create: offering
});
}
console.log('Seeded Ecosystem Offerings.');
// 6. Seed 4-Group Asset Taxonomy
await seedFourGroupTaxonomy();
console.log('Seeding completed successfully.'); console.log('Seeding completed successfully.');
} }

View File

@ -15,14 +15,8 @@ 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 ecosystemRoutes from './routes/ecosystem.routes';
import taxonomyRoutes from './routes/taxonomy.routes';
import notificationRoutes from './routes/notification.routes';
import chatRoutes from './routes/chat.routes';
import branchRoutes from './routes/branch.routes';
import { ensureBucketExists } from './utils/s3'; import { ensureBucketExists } from './utils/s3';
import { originStorage } from './utils/origin-storage';
const app: Express = express(); const app: Express = express();
const PORT = process.env.PORT || 5000; const PORT = process.env.PORT || 5000;
@ -33,7 +27,7 @@ app.use(helmet({
useDefaults: false, useDefaults: false,
directives: { directives: {
"default-src": helmet.contentSecurityPolicy.dangerouslyDisableDefaultSrc, "default-src": helmet.contentSecurityPolicy.dangerouslyDisableDefaultSrc,
"frame-ancestors": ["'self'", "http://localhost:5173", "http://localhost:5000", "https://*.ngrok-free.dev", "https://*.ngrok.io"], "frame-ancestors": ["'self'", "http://localhost:5173", "http://localhost:5000"],
}, },
}, },
frameguard: false, frameguard: false,
@ -43,30 +37,6 @@ app.use(express.json());
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use(cookieParser()); app.use(cookieParser());
// Capture request origin and run in AsyncLocalStorage context
app.use((req: Request, res: Response, next: NextFunction) => {
let clientOrigin: string | undefined;
const originHeader = req.get('origin');
if (originHeader) {
clientOrigin = originHeader;
} else {
const refererHeader = req.get('referer');
if (refererHeader) {
try {
clientOrigin = new URL(refererHeader).origin;
} catch {
// ignore invalid urls
}
}
}
if (clientOrigin) {
originStorage.run(clientOrigin, next);
} else {
next();
}
});
// Dynamic MinIO object delivery handler // Dynamic MinIO object delivery handler
app.get('/uploads/:filename', async (req: Request, res: Response, next: NextFunction) => { app.get('/uploads/:filename', async (req: Request, res: Response, next: NextFunction) => {
try { try {
@ -110,13 +80,8 @@ app.get('/uploads/:filename', async (req: Request, res: Response, next: NextFunc
// API Routes // API Routes
app.use('/api/v1/auth', authRoutes); app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/assets', assetRoutes); app.use('/api/v1/assets', assetRoutes);
app.use('/api/v1/assets', notificationRoutes);
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/ecosystem', ecosystemRoutes);
app.use('/api/v1/taxonomy', taxonomyRoutes);
app.use('/api/v1/chat', chatRoutes);
app.use('/api/v1', branchRoutes);
app.get('/api/v1/health', (req: Request, res: Response) => { app.get('/api/v1/health', (req: Request, res: Response) => {
res.status(200).json({ status: 'success', message: 'API is fully functional and real.' }); res.status(200).json({ status: 'success', message: 'API is fully functional and real.' });

View File

@ -1,29 +0,0 @@
import dotenv from 'dotenv';
dotenv.config();
import prisma from './utils/db';
import { ChatService } from './services/chat.service';
async function main() {
console.log('[Auto-Indexing] Starting catalog RAG auto-indexing for restored assets...');
const chatService = new ChatService();
const assets = await prisma.asset.findMany({
where: { includeInKnowledgeBase: true },
select: { id: true }
});
console.log(`[Auto-Indexing] Found ${assets.length} assets enabled for Knowledge Base.`);
const assetIds = assets.map(a => a.id);
await chatService.autoIndexCatalog(assetIds);
const embeddingCount = await prisma.assetEmbedding.count();
console.log(`[Auto-Indexing] Successfully indexed ${embeddingCount} OKF vector embedding chunks into database.`);
process.exit(0);
}
main().catch((err) => {
console.error('[Auto-Indexing] Failed:', err);
process.exit(1);
});

View File

@ -3,43 +3,21 @@ import path from 'path';
import { PutObjectCommand } from '@aws-sdk/client-s3'; import { PutObjectCommand } from '@aws-sdk/client-s3';
import { s3Client, BUCKET_NAME } from '../utils/s3'; import { s3Client, BUCKET_NAME } from '../utils/s3';
import { AssetService } from '../services/asset.service'; import { AssetService } from '../services/asset.service';
import { ScraperService } from '../services/scraper.service';
import { AuthRequest } from '../middleware/auth.middleware'; import { AuthRequest } from '../middleware/auth.middleware';
export class AssetController { export class AssetController {
private assetService = new AssetService(); private assetService = new AssetService();
private scraperService = new ScraperService();
public scrapeCaseStudy = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const url = req.query.url as string;
if (!url) {
return res.status(400).json({ error: 'URL parameter is required' });
}
const data = await this.scraperService.scrapeCaseStudy(url);
res.status(200).json(data);
} catch (err) { next(err); }
}
public uploadAsset = async (req: AuthRequest, res: Response, next: NextFunction) => { public uploadAsset = async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
const files = req.files as { [fieldname: string]: Express.Multer.File[] } | undefined; const isUrlAsset = req.body.isUrlAsset === 'true' || req.body.isUrlAsset === true || req.body.type === 'url';
const assetFile = files?.file?.[0] || req.file;
const thumbnailFile = files?.thumbnail?.[0];
const isUrlAsset = req.body.isUrlAsset === 'true' || req.body.isUrlAsset === true || req.body.type === 'url' || req.body.type === 'case_study'; if (!isUrlAsset && !req.file) {
if (!isUrlAsset && !assetFile) {
throw new Error('No file uploaded'); throw new Error('No file uploaded');
} }
const uploaderId = req.user?.userId || 'system'; const uploaderId = req.user?.userId || 'system';
const description = req.body.description;
if (!description || !description.trim()) {
return res.status(400).json({ error: 'Description is required' });
}
// Parse shares if present // Parse shares if present
let shares = req.body.shares; let shares = req.body.shares;
if (typeof shares === 'string' && shares.trim()) { if (typeof shares === 'string' && shares.trim()) {
@ -47,15 +25,15 @@ export class AssetController {
} }
let fileUrl = ''; let fileUrl = '';
if (!isUrlAsset && assetFile) { if (!isUrlAsset && req.file) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const filename = uniqueSuffix + path.extname(assetFile.originalname); const filename = uniqueSuffix + path.extname(req.file.originalname);
await s3Client.send(new PutObjectCommand({ await s3Client.send(new PutObjectCommand({
Bucket: BUCKET_NAME, Bucket: BUCKET_NAME,
Key: filename, Key: filename,
Body: assetFile.buffer, Body: req.file.buffer,
ContentType: assetFile.mimetype, ContentType: req.file.mimetype,
})); }));
fileUrl = `/uploads/${filename}`; fileUrl = `/uploads/${filename}`;
@ -63,42 +41,10 @@ export class AssetController {
fileUrl = req.body.url; fileUrl = req.body.url;
} }
// Handle thumbnail file upload if present
let thumbnailUrl: string | null = req.body.thumbnailUrl || null;
if (thumbnailFile) {
if (!thumbnailFile.mimetype.startsWith('image/')) {
return res.status(400).json({ error: 'Thumbnail must be an image file' });
}
const thumbSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const thumbFilename = 'thumb-' + thumbSuffix + path.extname(thumbnailFile.originalname);
await s3Client.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: thumbFilename,
Body: thumbnailFile.buffer,
ContentType: thumbnailFile.mimetype,
}));
thumbnailUrl = `/uploads/${thumbFilename}`;
}
const parseJsonOrArray = (val: any) => {
if (!val) return undefined;
if (typeof val === 'string' && val.trim()) {
try { return JSON.parse(val); } catch { return val.split(',').map((id: string) => id.trim()).filter(Boolean); }
}
return val;
};
let verticalIds = parseJsonOrArray(req.body.verticalIds);
let techStackIds = parseJsonOrArray(req.body.techStackIds);
let engagementTypeIds = parseJsonOrArray(req.body.engagementTypeIds);
let complianceIds = parseJsonOrArray(req.body.complianceIds);
const assetData = { const assetData = {
title: req.body.title || (assetFile ? assetFile.originalname : 'URL Asset'), title: req.body.title || (req.file ? req.file.originalname : 'URL Asset'),
type: req.body.type || (isUrlAsset ? 'url' : assetFile!.mimetype), type: isUrlAsset ? 'url' : req.file!.mimetype,
size: isUrlAsset ? 0 : assetFile!.size, size: isUrlAsset ? 0 : req.file!.size,
url: fileUrl, url: fileUrl,
uploadedBy: uploaderId, uploadedBy: uploaderId,
description: req.body.description || null, description: req.body.description || null,
@ -108,13 +54,6 @@ export class AssetController {
githubUrl: req.body.githubUrl || null, githubUrl: req.body.githubUrl || null,
status: req.body.status || 'published', status: req.body.status || 'published',
isDownloadable: req.body.isDownloadable === 'true' || req.body.isDownloadable === true, isDownloadable: req.body.isDownloadable === 'true' || req.body.isDownloadable === true,
thumbnailUrl: thumbnailUrl,
problemStatement: req.body.problemStatement || null,
solution: req.body.solution || null,
verticalIds,
techStackIds,
engagementTypeIds,
complianceIds,
shares, shares,
sharedOrgIds: req.body.sharedOrgIds || null, sharedOrgIds: req.body.sharedOrgIds || null,
}; };
@ -124,48 +63,10 @@ export class AssetController {
} catch (err) { next(err); } } catch (err) { next(err); }
} }
public uploadThumbnail = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const files = req.files as { [fieldname: string]: Express.Multer.File[] } | undefined;
const file = req.file || files?.thumbnail?.[0];
if (!file) {
return res.status(400).json({ error: 'No thumbnail file uploaded' });
}
if (!file.mimetype.startsWith('image/')) {
return res.status(400).json({ error: 'Thumbnail must be an image file' });
}
const thumbSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const thumbFilename = 'thumb-' + thumbSuffix + path.extname(file.originalname);
await s3Client.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: thumbFilename,
Body: file.buffer,
ContentType: file.mimetype,
}));
const thumbnailUrl = `/uploads/${thumbFilename}`;
res.status(200).json({ thumbnailUrl });
} catch (err) { next(err); }
}
public listAssets = async (req: AuthRequest, res: Response, next: NextFunction) => { public listAssets = async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
const userContext = req.user ? { role: req.user.role, userId: req.user.userId } : undefined; const userContext = req.user ? { role: req.user.role, userId: req.user.userId } : undefined;
const filters = { const assets = await this.assetService.getAssets(userContext);
search: req.query.search as string,
verticalIds: req.query.verticalIds ? (req.query.verticalIds as string).split(',') : undefined,
techStackIds: req.query.techStackIds ? (req.query.techStackIds as string).split(',') : undefined,
engagementTypeIds: req.query.engagementTypeIds ? (req.query.engagementTypeIds as string).split(',') : undefined,
complianceIds: req.query.complianceIds ? (req.query.complianceIds as string).split(',') : undefined,
contentTypes: req.query.contentTypes ? (req.query.contentTypes as string).split(',') : undefined,
subcategories: req.query.subcategories ? (req.query.subcategories as string).split(',') : undefined,
tags: req.query.tags ? (req.query.tags as string).split(',') : undefined,
sortBy: req.query.sortBy as any,
};
const assets = await this.assetService.getAssets(userContext, filters);
res.status(200).json(assets); res.status(200).json(assets);
} catch(err) { next(err); } } catch(err) { next(err); }
} }
@ -187,20 +88,6 @@ export class AssetController {
} catch (err) { next(err); } } catch (err) { next(err); }
} }
public bulkShareAssets = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { assetIds, shares } = req.body;
if (!Array.isArray(assetIds)) {
return res.status(400).json({ error: 'assetIds must be an array' });
}
if (!Array.isArray(shares)) {
return res.status(400).json({ error: 'shares must be an array' });
}
await this.assetService.bulkShareAssets(assetIds, shares);
res.status(200).json({ message: 'Assets shared successfully' });
} catch (err) { next(err); }
}
public shareAsset = async (req: AuthRequest, res: Response, next: NextFunction) => { public shareAsset = async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
const { organizationIds } = req.body; const { organizationIds } = req.body;
@ -259,51 +146,4 @@ export class AssetController {
res.status(200).json(request); res.status(200).json(request);
} catch (err) { next(err); } } catch (err) { next(err); }
} }
public createAssetGroup = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { name, description, assetIds } = req.body;
if (!name) {
return res.status(400).json({ error: 'Group name is required' });
}
const group = await this.assetService.createAssetGroup(name, description, assetIds || []);
res.status(201).json(group);
} catch (err) { next(err); }
}
public listAssetGroups = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const groups = await this.assetService.getAssetGroups();
res.status(200).json(groups);
} catch (err) { next(err); }
}
public deleteAssetGroup = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await this.assetService.deleteAssetGroup(req.params.id);
res.status(204).send();
} catch (err) { next(err); }
}
public addAssetsToGroup = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { assetIds } = req.body;
if (!Array.isArray(assetIds)) {
return res.status(400).json({ error: 'assetIds must be an array' });
}
const group = await this.assetService.addAssetsToGroup(req.params.id, assetIds);
res.status(200).json(group);
} catch (err) { next(err); }
}
public removeAssetsFromGroup = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { assetIds } = req.body;
if (!Array.isArray(assetIds)) {
return res.status(400).json({ error: 'assetIds must be an array' });
}
const group = await this.assetService.removeAssetsFromGroup(req.params.id, assetIds);
res.status(200).json(group);
} catch (err) { next(err); }
}
} }

View File

@ -25,62 +25,12 @@ export class AuthController {
public invitePartner = async (req: Request, res: Response, next: NextFunction) => { public invitePartner = async (req: Request, res: Response, next: NextFunction) => {
try { try {
const { email, organizationId, partnerGroup, assignedNdaId, assignedMsaId, sharedAssetIds, mfaEnabled, showEcosystemTab } = z.object({ const { email, organizationId } = z.object({ email: z.string().email(), organizationId: z.string().uuid().optional() }).parse(req.body);
email: z.string().email(), const result = await this.authService.invitePartner(email, organizationId);
organizationId: z.string().uuid().optional(),
partnerGroup: z.string().optional().nullable(),
assignedNdaId: z.string().optional().nullable(),
assignedMsaId: z.string().optional().nullable(),
sharedAssetIds: z.array(z.string().uuid()).optional(),
mfaEnabled: z.boolean().optional(),
showEcosystemTab: z.boolean().optional(),
}).parse(req.body);
const result = await this.authService.invitePartner(email, {
organizationId,
partnerGroup: partnerGroup || undefined,
assignedNdaId: assignedNdaId || undefined,
assignedMsaId: assignedMsaId || undefined,
sharedAssetIds,
mfaEnabled,
showEcosystemTab,
});
res.status(201).json({ message: 'Invite created', token: result.inviteToken }); res.status(201).json({ message: 'Invite created', token: result.inviteToken });
} catch(err) { next(err); } } catch(err) { next(err); }
} }
public updatePartner = async (req: Request, res: Response, next: NextFunction) => {
try {
const { partnerId } = req.params;
const { partnerGroup, assignedNdaId, assignedMsaId, sharedAssetIds, mfaEnabled, showEcosystemTab } = z.object({
partnerGroup: z.string().optional().nullable(),
assignedNdaId: z.string().optional().nullable(),
assignedMsaId: z.string().optional().nullable(),
sharedAssetIds: z.array(z.string().uuid()).optional(),
mfaEnabled: z.boolean().optional(),
showEcosystemTab: z.boolean().optional(),
}).parse(req.body);
const result = await this.authService.updatePartner(partnerId, {
partnerGroup: (partnerGroup === null || partnerGroup === '') ? null : partnerGroup,
assignedNdaId: assignedNdaId === undefined ? undefined : assignedNdaId,
assignedMsaId: assignedMsaId === undefined ? undefined : assignedMsaId,
sharedAssetIds,
mfaEnabled,
showEcosystemTab,
});
res.status(200).json(result);
} catch(err) { next(err); }
}
public resendInvite = async (req: Request, res: Response, next: NextFunction) => {
try {
const { partnerId } = req.params;
const result = await this.authService.resendInvite(partnerId);
res.status(200).json(result);
} catch(err) { next(err); }
}
public validateInvite = async (req: Request, res: Response, next: NextFunction) => { public validateInvite = async (req: Request, res: Response, next: NextFunction) => {
try { try {
const { token } = req.params; const { token } = req.params;
@ -157,24 +107,5 @@ export class AuthController {
res.status(200).json(user); res.status(200).json(user);
} catch(err) { next(err); } } catch(err) { next(err); }
}; };
public updateProfile = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = (req as any).user?.userId;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { password, companyName, website, sector, companySize, defaultTheme } = req.body;
const updatedUser = await this.authService.updateProfile(userId, {
password,
companyName,
website,
sector,
companySize,
defaultTheme
});
res.status(200).json(updatedUser);
} catch (err) { next(err); }
};
} }

View File

@ -1,39 +0,0 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../middleware/auth.middleware';
import { BranchService } from '../services/branch.service';
const branchService = new BranchService();
export class BranchController {
public createBranch = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { organizationId } = req.params;
const branch = await branchService.createBranch(organizationId, req.body);
res.status(201).json(branch);
} catch (err) { next(err); }
};
public getBranches = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { organizationId } = req.params;
const branches = await branchService.getOrganizationBranches(organizationId);
res.status(200).json(branches);
} catch (err) { next(err); }
};
public updateBranch = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const branch = await branchService.updateBranch(id, req.body);
res.status(200).json(branch);
} catch (err) { next(err); }
};
public deleteBranch = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await branchService.deleteBranch(id);
res.status(200).json({ message: 'Branch deleted successfully' });
} catch (err) { next(err); }
};
}

View File

@ -1,103 +0,0 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from '../middleware/auth.middleware';
import { ChatService } from '../services/chat.service';
import prisma from '../utils/db';
const chatService = new ChatService();
export class ChatController {
public queryChat = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { prompt, sessionId, attachedEntities } = req.body;
const userId = req.user?.userId;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const result = await chatService.processPrompt(userId, prompt || 'Analyze attached workbench items', sessionId, attachedEntities);
res.status(200).json(result);
} catch (err) {
next(err);
}
};
public getHistory = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const userId = req.user?.userId;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const sessions = await prisma.chatSession.findMany({
where: { userId },
include: {
messages: {
take: 2,
orderBy: { createdAt: 'asc' }
}
},
orderBy: { updatedAt: 'desc' },
take: 20,
});
res.status(200).json(sessions);
} catch (err) {
next(err);
}
};
public createSession = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const userId = req.user?.userId;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const session = await prisma.chatSession.create({
data: { userId }
});
res.status(201).json({ sessionId: session.id, session });
} catch (err) {
next(err);
}
};
public getSessionMessages = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const userId = req.user?.userId;
const { sessionId } = req.params;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const session = await prisma.chatSession.findFirst({
where: { id: sessionId, userId },
include: {
messages: {
orderBy: { createdAt: 'asc' }
}
}
});
if (!session) {
return res.status(404).json({ error: 'Chat session not found' });
}
res.status(200).json(session);
} catch (err) {
next(err);
}
};
public syncAssetKnowledge = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { assetId } = req.params;
await chatService.ingestAssetKnowledge(assetId);
res.status(200).json({ message: 'Asset knowledge re-indexed successfully' });
} catch (err) {
next(err);
}
};
}

View File

@ -1,541 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import path from 'path';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { s3Client, BUCKET_NAME } from '../utils/s3';
import prisma from '../utils/db';
export class EcosystemController {
public listOfferings = async (req: Request, res: Response, next: NextFunction) => {
try {
const user = (req as any).user;
const isAdmin = user?.role === 'ADMIN';
const offerings = await prisma.ecosystemOffering.findMany({
where: isAdmin ? undefined : { isActive: true },
orderBy: { orderIndex: 'asc' }
});
res.status(200).json(offerings);
} catch (err) {
next(err);
}
};
public createOffering = async (req: Request, res: Response, next: NextFunction) => {
try {
const data = req.body;
const created = await prisma.ecosystemOffering.create({
data: {
name: data.name,
type: data.type,
tagline: data.tagline,
description: data.description,
benefits: data.benefits || [],
websiteUrl: data.websiteUrl,
ctaText: data.ctaText || 'Visit Website',
logoIcon: data.logoIcon || 'Globe',
logoUrl: data.logoUrl || null,
mediaUrl: data.mediaUrl || null,
mediaType: data.mediaType || null,
orderIndex: data.orderIndex !== undefined ? data.orderIndex : 0,
isActive: data.isActive !== undefined ? data.isActive : true,
}
});
res.status(201).json(created);
} catch (err) {
next(err);
}
};
public updateOffering = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const data = req.body;
const updated = await prisma.ecosystemOffering.update({
where: { id },
data: {
name: data.name,
type: data.type,
tagline: data.tagline,
description: data.description,
benefits: data.benefits,
websiteUrl: data.websiteUrl,
ctaText: data.ctaText,
logoIcon: data.logoIcon,
logoUrl: data.logoUrl,
mediaUrl: data.mediaUrl,
mediaType: data.mediaType,
orderIndex: data.orderIndex,
isActive: data.isActive,
}
});
res.status(200).json(updated);
} catch (err) {
next(err);
}
};
public deleteOffering = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await prisma.ecosystemOffering.delete({
where: { id }
});
res.status(200).json({ message: 'Offering deleted successfully' });
} catch (err) {
next(err);
}
};
public uploadFile = async (req: Request, res: Response, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const filename = uniqueSuffix + path.extname(req.file.originalname);
await s3Client.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: filename,
Body: req.file.buffer,
ContentType: req.file.mimetype,
}));
const fileUrl = `/uploads/${filename}`;
res.status(200).json({ url: fileUrl });
} catch (err) {
next(err);
}
};
// ── Content Showcase (YouTube Videos) ──
public listShowcaseContent = async (req: Request, res: Response, next: NextFunction) => {
try {
const user = (req as any).user;
const isAdmin = user?.role === 'ADMIN';
const items = await prisma.contentShowcase.findMany({
where: isAdmin ? undefined : { isActive: true },
orderBy: { orderIndex: 'asc' }
});
res.status(200).json(items);
} catch (err) {
next(err);
}
};
public createShowcaseContent = async (req: Request, res: Response, next: NextFunction) => {
try {
const data = req.body;
let title = data.title;
let description = data.description || null;
let thumbnailUrl = data.thumbnailUrl || null;
if ((!title || !description || !thumbnailUrl) && data.youtubeUrl) {
const meta = await this.scrapeMetaInternal(data.youtubeUrl);
if (meta) {
if (!title) title = meta.title;
if (!description) description = meta.description || null;
if (!thumbnailUrl) thumbnailUrl = meta.thumbnailUrl || null;
}
}
// Auto-extract YouTube thumbnail if not provided and it's YouTube
if (!thumbnailUrl && data.youtubeUrl) {
const videoId = this.extractYouTubeVideoId(data.youtubeUrl);
if (videoId) {
thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
}
}
const created = await prisma.contentShowcase.create({
data: {
title: title || 'Media Showcase Item',
description,
youtubeUrl: data.youtubeUrl,
thumbnailUrl,
redirectUrl: data.redirectUrl || null,
redirectLabel: data.redirectLabel || 'Learn More',
orderIndex: data.orderIndex !== undefined ? data.orderIndex : 0,
isActive: data.isActive !== undefined ? data.isActive : true,
}
});
res.status(201).json(created);
} catch (err) {
next(err);
}
};
public updateShowcaseContent = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const data = req.body;
let title = data.title;
let description = data.description;
let thumbnailUrl = data.thumbnailUrl;
if (data.youtubeUrl) {
const meta = await this.scrapeMetaInternal(data.youtubeUrl);
if (meta) {
if (title === undefined || title === null || title === '') title = meta.title;
if (description === undefined) description = meta.description || null;
if (thumbnailUrl === undefined) thumbnailUrl = meta.thumbnailUrl || null;
}
}
if (!thumbnailUrl && data.youtubeUrl) {
const videoId = this.extractYouTubeVideoId(data.youtubeUrl);
if (videoId) {
thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
}
}
const updated = await prisma.contentShowcase.update({
where: { id },
data: {
title,
description,
youtubeUrl: data.youtubeUrl,
thumbnailUrl,
redirectUrl: data.redirectUrl,
redirectLabel: data.redirectLabel,
orderIndex: data.orderIndex,
isActive: data.isActive,
}
});
res.status(200).json(updated);
} catch (err) {
next(err);
}
};
public deleteShowcaseContent = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await prisma.contentShowcase.delete({
where: { id }
});
res.status(200).json({ message: 'Content deleted successfully' });
} catch (err) {
next(err);
}
};
// Helper: Extract video ID from various YouTube URL formats
private extractYouTubeVideoId(url: string): string | null {
try {
const urlObj = new URL(url);
if (urlObj.hostname.includes('youtu.be')) {
return urlObj.pathname.slice(1).split(/[?#]/)[0];
}
if (urlObj.pathname.includes('/shorts/') || urlObj.pathname.includes('/embed/')) {
const parts = urlObj.pathname.split('/');
return parts.pop()?.split(/[?#]/)[0] || null;
}
if (urlObj.searchParams.has('v')) {
return urlObj.searchParams.get('v');
}
} catch (e) {
// Fallback to regex
}
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
/(?:youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) return match[1];
}
return null;
}
private async scrapeMetaInternal(url: string): Promise<{ title?: string; description?: string; thumbnailUrl?: string } | null> {
try {
const normUrl = url.trim();
const lowerUrl = normUrl.toLowerCase();
// Case 1: Twitter / X
if (lowerUrl.includes('twitter.com') || lowerUrl.includes('x.com')) {
const oembedUrl = `https://publish.twitter.com/oembed?url=${encodeURIComponent(normUrl)}`;
const resOembed = await fetch(oembedUrl);
if (resOembed.ok) {
const data = await resOembed.json() as any;
let description = '';
if (data.html) {
const pMatch = data.html.match(/<p[^>]*>([\s\S]*?)<\/p>/i);
if (pMatch) {
description = pMatch[1].replace(/<[^>]*>/g, '').trim();
}
}
return {
title: data.author_name ? `Tweet by ${data.author_name}` : 'Tweet Content',
description,
thumbnailUrl: undefined
};
}
}
// Case 2: Instagram
if (lowerUrl.includes('instagram.com')) {
const oembedUrl = `https://graph.facebook.com/v25.0/instagram_oembed?url=${encodeURIComponent(normUrl)}`;
const resOembed = await fetch(oembedUrl);
if (resOembed.ok) {
const data = await resOembed.json() as any;
return {
title: data.author_name ? `Instagram post by ${data.author_name}` : 'Instagram Post/Reel',
description: data.title || 'Instagram Content',
thumbnailUrl: data.thumbnail_url || undefined
};
}
}
// Case 3: YouTube
const videoId = this.extractYouTubeVideoId(normUrl);
if (videoId) {
let title = '';
let thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
try {
const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`;
const oembedRes = await fetch(oembedUrl);
if (oembedRes.ok) {
const data = await oembedRes.json() as any;
title = data.title || '';
thumbnailUrl = data.thumbnail_url || thumbnailUrl;
}
} catch (err) {
console.error('oEmbed fetch error:', err);
}
let description = '';
try {
const watchUrl = `https://www.youtube.com/watch?v=${videoId}`;
const pageRes = await fetch(watchUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9'
}
});
if (pageRes.ok) {
const html = await pageRes.text();
const playerResponse = this.extractJsonFromHtml(html, 'ytInitialPlayerResponse = ');
if (playerResponse && playerResponse.videoDetails) {
description = playerResponse.videoDetails.shortDescription || '';
if (playerResponse.videoDetails.title && !title) {
title = playerResponse.videoDetails.title;
}
}
if (!description) {
const descMatch = html.match(/<meta\s+property="og:description"\s+content="([^"]*)"/i) ||
html.match(/<meta\s+name="description"\s+content="([^"]*)"/i);
if (descMatch && descMatch[1]) {
description = descMatch[1]
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'");
}
}
}
} catch (err) {
console.error('HTML scrape error:', err);
}
return {
title: title || undefined,
description: description || undefined,
thumbnailUrl
};
}
} catch (e) {
console.error('scrapeMetaInternal error:', e);
}
return null;
}
private extractJsonFromHtml(html: string, targetStr: string): any {
const idx = html.indexOf(targetStr);
if (idx === -1) return null;
const start = idx + targetStr.length;
let openBraces = 0;
let inString = false;
let escape = false;
let endIdx = -1;
for (let i = start; i < html.length; i++) {
const char = html[i];
if (escape) {
escape = false;
continue;
}
if (char === '\\') {
escape = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (!inString) {
if (char === '{') {
openBraces++;
} else if (char === '}') {
openBraces--;
if (openBraces === 0) {
endIdx = i + 1;
break;
}
}
}
}
if (endIdx !== -1) {
try {
const jsonStr = html.slice(start, endIdx);
return JSON.parse(jsonStr);
} catch (err) {
console.error('Failed to parse extracted JSON:', err);
}
}
return null;
}
public getYoutubeMeta = async (req: Request, res: Response, next: NextFunction) => {
try {
const { url } = req.query;
if (!url || typeof url !== 'string') {
return res.status(400).json({ error: 'URL parameter is required' });
}
const normUrl = url.trim();
const lowerUrl = normUrl.toLowerCase();
// Case 1: Twitter / X
if (lowerUrl.includes('twitter.com') || lowerUrl.includes('x.com')) {
const oembedUrl = `https://publish.twitter.com/oembed?url=${encodeURIComponent(normUrl)}`;
try {
const resOembed = await fetch(oembedUrl);
if (resOembed.ok) {
const data = await resOembed.json() as any;
let description = '';
if (data.html) {
const pMatch = data.html.match(/<p[^>]*>([\s\S]*?)<\/p>/i);
if (pMatch) {
description = pMatch[1].replace(/<[^>]*>/g, '').trim();
}
}
return res.status(200).json({
title: data.author_name ? `Tweet by ${data.author_name}` : 'Tweet Content',
description,
thumbnailUrl: null
});
}
} catch (err) {
console.error('Twitter oEmbed fetch error:', err);
}
return res.status(200).json({
title: 'Tweet Post',
description: '',
thumbnailUrl: null
});
}
// Case 2: Instagram
if (lowerUrl.includes('instagram.com')) {
// Since Instagram requires Facebook API, return defaults so user can input manually
return res.status(200).json({
title: 'Instagram Post/Reel',
description: '',
thumbnailUrl: null
});
}
// Case 3: YouTube
const videoId = this.extractYouTubeVideoId(normUrl);
if (!videoId) {
return res.status(400).json({ error: 'Invalid YouTube, Instagram, or Twitter/X URL' });
}
// 1. Fetch from oEmbed (gives us a clean title and thumbnail immediately)
const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`;
let title = '';
let thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
try {
const oembedRes = await fetch(oembedUrl);
if (oembedRes.ok) {
const data = await oembedRes.json() as any;
title = data.title || '';
thumbnailUrl = data.thumbnail_url || thumbnailUrl;
}
} catch (err) {
console.error('oEmbed fetch error:', err);
}
// 2. Fetch raw page to extract the full description
let description = '';
try {
const watchUrl = `https://www.youtube.com/watch?v=${videoId}`;
const pageRes = await fetch(watchUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9'
}
});
if (pageRes.ok) {
const html = await pageRes.text();
// Try to extract from ytInitialPlayerResponse first (full description)
const playerResponse = this.extractJsonFromHtml(html, 'ytInitialPlayerResponse = ');
if (playerResponse && playerResponse.videoDetails) {
description = playerResponse.videoDetails.shortDescription || '';
if (playerResponse.videoDetails.title && !title) {
title = playerResponse.videoDetails.title;
}
}
// Fallback to meta tags if description is still empty
if (!description) {
const descMatch = html.match(/<meta\s+property="og:description"\s+content="([^"]*)"/i) ||
html.match(/<meta\s+name="description"\s+content="([^"]*)"/i) ||
html.match(/<meta\s+content="([^"]*)"\s+property="og:description"/i) ||
html.match(/<meta\s+content="([^"]*)"\s+name="description"/i);
if (descMatch && descMatch[1]) {
description = descMatch[1]
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'");
}
}
}
} catch (err) {
console.error('HTML scrape error:', err);
}
res.status(200).json({
title,
description,
thumbnailUrl
});
} catch (err) {
next(err);
}
};
}

View File

@ -25,12 +25,8 @@ export class LegalController {
public getActive = async (req: Request, res: Response, next: NextFunction) => { public getActive = async (req: Request, res: Response, next: NextFunction) => {
try { try {
const type = req.params.type.toUpperCase() as DocumentType; const type = req.params.type.toUpperCase() as DocumentType;
const userId = (req as any).user?.userId; const doc = await this.legalService.getActiveDocument(type);
const doc = await this.legalService.getActiveDocument(type, userId);
if (!doc) { if (!doc) {
if (userId) {
return res.status(200).json({ skipped: true });
}
return res.status(404).json({ error: 'No active document found' }); return res.status(404).json({ error: 'No active document found' });
} }
res.status(200).json(doc); res.status(200).json(doc);
@ -60,7 +56,7 @@ export class LegalController {
// Get active document for this type // Get active document for this type
const type = documentType.toUpperCase() as DocumentType; const type = documentType.toUpperCase() as DocumentType;
const activeDoc = await this.legalService.getActiveDocument(type, userId); const activeDoc = await this.legalService.getActiveDocument(type);
if (!activeDoc) { if (!activeDoc) {
return res.status(404).json({ error: 'No active document found to sign' }); return res.status(404).json({ error: 'No active document found to sign' });
@ -73,14 +69,7 @@ export class LegalController {
signatureHash = crypto.createHash('sha256').update(signatureBase64).digest('hex'); signatureHash = crypto.createHash('sha256').update(signatureBase64).digest('hex');
} }
const acceptance = await this.legalService.recordAcceptance( const acceptance = await this.legalService.recordAcceptance(activeDoc.id, userId, ipAddress, signatureHash || undefined, documentUrl);
activeDoc.id,
userId,
ipAddress,
signatureHash || undefined,
documentUrl,
signatureBase64 || undefined
);
// Check if they completed all onboarding steps // Check if they completed all onboarding steps
await this.legalService.checkOnboardingCompletion(userId); await this.legalService.checkOnboardingCompletion(userId);
@ -111,13 +100,6 @@ export class LegalController {
} catch(err) { next(err); } } catch(err) { next(err); }
} }
public listAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const docs = await this.legalService.getAllDocuments();
res.status(200).json(docs);
} catch(err) { next(err); }
}
public uploadSignedDoc = async (req: AuthRequest, res: Response, next: NextFunction) => { public uploadSignedDoc = async (req: AuthRequest, res: Response, next: NextFunction) => {
try { try {
if (!req.file) throw new Error('No file uploaded'); if (!req.file) throw new Error('No file uploaded');

View File

@ -1,72 +0,0 @@
import { Response, NextFunction } from 'express';
import prisma from '../utils/db';
import { AuthRequest } from '../middleware/auth.middleware';
import { MailService } from '../services/mail.service';
export class NotificationController {
private mailService = new MailService();
public sendAssetAnnouncement = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { title, message, targetOrgIds, assetIds } = req.body;
if (!title || !message) {
return res.status(400).json({ error: 'Title and message are required' });
}
let senderEmail = 'admin@tech4biz.com';
if (req.user?.userId) {
const adminUser = await prisma.user.findUnique({ where: { id: req.user.userId }, select: { email: true } });
if (adminUser?.email) senderEmail = adminUser.email;
}
// Log notification record
const notification = await prisma.assetNotification.create({
data: {
title,
message,
sentBy: senderEmail,
targetOrgIds: Array.isArray(targetOrgIds) ? targetOrgIds : ['ALL'],
assetIds: Array.isArray(assetIds) ? assetIds : [],
}
});
// Find recipient users
const userWhere: any = { role: 'PARTNER_USER' };
if (Array.isArray(targetOrgIds) && targetOrgIds.length > 0 && !targetOrgIds.includes('ALL')) {
userWhere.organizationId = { in: targetOrgIds };
}
const partnerUsers = await prisma.user.findMany({
where: userWhere,
select: { email: true }
});
const recipientEmails = partnerUsers.map(u => u.email).filter(Boolean);
// Trigger email notifications asynchronously via mailService
if (recipientEmails.length > 0) {
this.mailService.sendCustomAnnouncement({
recipients: recipientEmails,
subject: `📢 Asset Announcement: ${title}`,
messageBody: message,
}).catch(err => console.error('Failed to dispatch announcement emails:', err));
}
res.status(201).json({
message: 'Notification sent successfully',
recipientsCount: recipientEmails.length,
notification,
});
} catch (err) { next(err); }
};
public getNotificationLogs = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const logs = await prisma.assetNotification.findMany({
orderBy: { createdAt: 'desc' },
take: 50,
});
res.status(200).json(logs);
} catch (err) { next(err); }
};
}

View File

@ -1,207 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import prisma from '../utils/db';
import { AuthRequest } from '../middleware/auth.middleware';
export class TaxonomyController {
// Public/Authenticated: Get all active verticals
public getVerticals = async (req: Request, res: Response, next: NextFunction) => {
try {
const verticals = await prisma.vertical.findMany({
where: { isActive: true },
include: {
_count: {
select: { assets: true }
}
},
orderBy: { orderIndex: 'asc' },
});
res.status(200).json(verticals);
} catch (err) { next(err); }
};
// Public/Authenticated: Get taxonomy metadata with real-time asset counts across all 4 groups
public getTaxonomyMeta = async (req: Request, res: Response, next: NextFunction) => {
try {
const [verticals, techStacks, engagementTypes, complianceStandards, assets] = await Promise.all([
prisma.vertical.findMany({
where: { isActive: true },
include: { _count: { select: { assets: true } } },
orderBy: { orderIndex: 'asc' },
}),
prisma.techStack.findMany({
where: { isActive: true },
include: { _count: { select: { assets: true } } },
orderBy: { orderIndex: 'asc' },
}),
prisma.engagementType.findMany({
where: { isActive: true },
include: { _count: { select: { assets: true } } },
orderBy: { orderIndex: 'asc' },
}),
prisma.complianceStandard.findMany({
where: { isActive: true },
include: { _count: { select: { assets: true } } },
orderBy: { orderIndex: 'asc' },
}),
prisma.asset.findMany({
select: {
categoryId: true,
subcategory: true,
contentType: true,
tags: true,
}
})
]);
const categoryCounts: Record<string, number> = {};
const subcategoryCounts: Record<string, number> = {};
const contentTypeCounts: Record<string, number> = {};
const tagCounts: Record<string, number> = {};
assets.forEach(a => {
if (a.categoryId) {
categoryCounts[a.categoryId] = (categoryCounts[a.categoryId] || 0) + 1;
}
if (a.subcategory) {
subcategoryCounts[a.subcategory] = (subcategoryCounts[a.subcategory] || 0) + 1;
}
if (a.contentType) {
contentTypeCounts[a.contentType] = (contentTypeCounts[a.contentType] || 0) + 1;
}
if (Array.isArray(a.tags)) {
a.tags.forEach(t => {
if (t) tagCounts[t] = (tagCounts[t] || 0) + 1;
});
}
});
res.status(200).json({
verticals,
techStacks,
engagementTypes,
complianceStandards,
categories: Object.entries(categoryCounts).map(([name, count]) => ({ name, count })),
subcategories: Object.entries(subcategoryCounts).map(([name, count]) => ({ name, count })),
contentTypes: Object.entries(contentTypeCounts).map(([name, count]) => ({ name, count })),
tags: Object.entries(tagCounts).map(([name, count]) => ({ name, count })),
totalAssets: assets.length,
});
} catch (err) { next(err); }
};
// Admin: Create Vertical
public createVertical = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { name, icon, description, color } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
const vertical = await prisma.vertical.create({
data: { name, slug, icon, description, color }
});
res.status(201).json(vertical);
} catch (err) { next(err); }
};
// Admin: Update Vertical
public updateVertical = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const { name, icon, description, color, isActive, orderIndex } = req.body;
const data: any = {};
if (name !== undefined) {
data.name = name;
data.slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
}
if (icon !== undefined) data.icon = icon;
if (description !== undefined) data.description = description;
if (color !== undefined) data.color = color;
if (isActive !== undefined) data.isActive = isActive;
if (orderIndex !== undefined) data.orderIndex = orderIndex;
const vertical = await prisma.vertical.update({
where: { id },
data,
});
res.status(200).json(vertical);
} catch (err) { next(err); }
};
// Admin: Delete Vertical
public deleteVertical = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await prisma.vertical.delete({ where: { id } });
res.status(204).send();
} catch (err) { next(err); }
};
// Admin: Create Tech Stack
public createTechStack = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { name, category, icon, description, color } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
const item = await prisma.techStack.create({
data: { name, slug, category: category || 'Languages & Frameworks', icon, description, color: color || '#64748b' }
});
res.status(201).json(item);
} catch (err) { next(err); }
};
// Admin: Delete Tech Stack
public deleteTechStack = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await prisma.techStack.delete({ where: { id } });
res.status(204).send();
} catch (err) { next(err); }
};
// Admin: Create Engagement Type
public createEngagementType = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { name, icon, description, color } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
const item = await prisma.engagementType.create({
data: { name, slug, icon, description, color: color || '#0284c7' }
});
res.status(201).json(item);
} catch (err) { next(err); }
};
// Admin: Delete Engagement Type
public deleteEngagementType = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await prisma.engagementType.delete({ where: { id } });
res.status(204).send();
} catch (err) { next(err); }
};
// Admin: Create Compliance Standard
public createComplianceStandard = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { name, icon, description, color } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)+/g, '');
const item = await prisma.complianceStandard.create({
data: { name, slug, icon, description, color: color || '#10b981' }
});
res.status(201).json(item);
} catch (err) { next(err); }
};
// Admin: Delete Compliance Standard
public deleteComplianceStandard = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await prisma.complianceStandard.delete({ where: { id } });
res.status(204).send();
} catch (err) { next(err); }
};
}

View File

@ -8,19 +8,8 @@ const assetController = new AssetController();
router.use(authenticate); router.use(authenticate);
router.post('/upload', requireRole('ADMIN'), upload.fields([{ name: 'file', maxCount: 1 }, { name: 'thumbnail', maxCount: 1 }]), assetController.uploadAsset); router.post('/upload', requireRole('ADMIN'), upload.single('file'), assetController.uploadAsset);
router.post('/upload-thumbnail', requireRole('ADMIN'), upload.single('thumbnail'), assetController.uploadThumbnail);
router.get('/scrape-case-study', requireRole('ADMIN'), assetController.scrapeCaseStudy);
router.get('/', assetController.listAssets); router.get('/', assetController.listAssets);
router.patch('/bulk-share', requireRole('ADMIN'), assetController.bulkShareAssets);
// Asset groups endpoints
router.get('/groups/list', assetController.listAssetGroups);
router.post('/groups/create', requireRole('ADMIN'), assetController.createAssetGroup);
router.delete('/groups/:id', requireRole('ADMIN'), assetController.deleteAssetGroup);
router.patch('/groups/:id/add-assets', requireRole('ADMIN'), assetController.addAssetsToGroup);
router.patch('/groups/:id/remove-assets', requireRole('ADMIN'), assetController.removeAssetsFromGroup);
router.get('/:id', assetController.getAsset); router.get('/:id', assetController.getAsset);
router.patch('/:id', requireRole('ADMIN'), assetController.updateAsset); router.patch('/:id', requireRole('ADMIN'), assetController.updateAsset);
router.post('/:id/share', requireRole('ADMIN'), assetController.shareAsset); router.post('/:id/share', requireRole('ADMIN'), assetController.shareAsset);

View File

@ -12,12 +12,9 @@ router.post('/refresh', authController.refresh);
// Invite Flow // Invite Flow
router.get('/me', authenticate, authController.getCurrentUser); router.get('/me', authenticate, authController.getCurrentUser);
router.put('/profile', authenticate, authController.updateProfile);
router.post('/invite', authenticate, requireRole('ADMIN'), authController.invitePartner); router.post('/invite', authenticate, requireRole('ADMIN'), authController.invitePartner);
router.get('/invite/:token', authController.validateInvite); router.get('/invite/:token', authController.validateInvite);
router.post('/invite/accept', authController.acceptInvite); router.post('/invite/accept', authController.acceptInvite);
router.get('/partners', authenticate, requireRole('ADMIN'), authController.listPartners); router.get('/partners', authenticate, requireRole('ADMIN'), authController.listPartners);
router.put('/partners/:partnerId', authenticate, requireRole('ADMIN'), authController.updatePartner);
router.post('/partners/:partnerId/resend-invite', authenticate, requireRole('ADMIN'), authController.resendInvite);
export default router; export default router;

View File

@ -1,15 +0,0 @@
import { Router } from 'express';
import { BranchController } from '../controllers/branch.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const branchController = new BranchController();
router.use(authenticate);
router.get('/organizations/:organizationId/branches', branchController.getBranches);
router.post('/organizations/:organizationId/branches', requireRole('ADMIN'), branchController.createBranch);
router.put('/branches/:id', requireRole('ADMIN'), branchController.updateBranch);
router.delete('/branches/:id', requireRole('ADMIN'), branchController.deleteBranch);
export default router;

View File

@ -1,16 +0,0 @@
import { Router } from 'express';
import { ChatController } from '../controllers/chat.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const chatController = new ChatController();
router.use(authenticate);
router.post('/query', chatController.queryChat);
router.get('/history', chatController.getHistory);
router.post('/sessions', chatController.createSession);
router.get('/sessions/:sessionId', chatController.getSessionMessages);
router.post('/sync/:assetId', requireRole('ADMIN'), chatController.syncAssetKnowledge);
export default router;

View File

@ -1,22 +0,0 @@
import { Router } from 'express';
import { EcosystemController } from '../controllers/ecosystem.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
import { upload } from '../middleware/upload.middleware';
const router = Router();
const controller = new EcosystemController();
router.get('/offerings', authenticate, controller.listOfferings);
router.post('/offerings', authenticate, requireRole('ADMIN'), controller.createOffering);
router.put('/offerings/:id', authenticate, requireRole('ADMIN'), controller.updateOffering);
router.delete('/offerings/:id', authenticate, requireRole('ADMIN'), controller.deleteOffering);
router.post('/upload', authenticate, requireRole('ADMIN'), upload.single('file'), controller.uploadFile);
// Content Showcase (YouTube Videos)
router.get('/showcase', authenticate, controller.listShowcaseContent);
router.get('/video-meta', authenticate, requireRole('ADMIN'), controller.getYoutubeMeta);
router.post('/showcase', authenticate, requireRole('ADMIN'), controller.createShowcaseContent);
router.put('/showcase/:id', authenticate, requireRole('ADMIN'), controller.updateShowcaseContent);
router.delete('/showcase/:id', authenticate, requireRole('ADMIN'), controller.deleteShowcaseContent);
export default router;

View File

@ -10,7 +10,6 @@ router.use(authenticate);
// Admins create documents // Admins create documents
router.post('/documents', requireRole('ADMIN'), legalController.create); router.post('/documents', requireRole('ADMIN'), legalController.create);
router.get('/documents', requireRole('ADMIN'), legalController.listAll);
// Anyone can view active docs and sign them // Anyone can view active docs and sign them
router.get('/documents/active/:type', legalController.getActive); router.get('/documents/active/:type', legalController.getActive);

View File

@ -1,11 +0,0 @@
import { Router } from 'express';
import { NotificationController } from '../controllers/notification.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const controller = new NotificationController();
router.post('/notify', authenticate, requireRole('ADMIN'), controller.sendAssetAnnouncement);
router.get('/logs', authenticate, requireRole('ADMIN'), controller.getNotificationLogs);
export default router;

View File

@ -1,24 +0,0 @@
import { Router } from 'express';
import { TaxonomyController } from '../controllers/taxonomy.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const controller = new TaxonomyController();
router.get('/verticals', authenticate, controller.getVerticals);
router.get('/meta', authenticate, controller.getTaxonomyMeta);
router.post('/verticals', authenticate, requireRole('ADMIN'), controller.createVertical);
router.put('/verticals/:id', authenticate, requireRole('ADMIN'), controller.updateVertical);
router.delete('/verticals/:id', authenticate, requireRole('ADMIN'), controller.deleteVertical);
router.post('/tech-stacks', authenticate, requireRole('ADMIN'), controller.createTechStack);
router.delete('/tech-stacks/:id', authenticate, requireRole('ADMIN'), controller.deleteTechStack);
router.post('/engagement-types', authenticate, requireRole('ADMIN'), controller.createEngagementType);
router.delete('/engagement-types/:id', authenticate, requireRole('ADMIN'), controller.deleteEngagementType);
router.post('/compliance-standards', authenticate, requireRole('ADMIN'), controller.createComplianceStandard);
router.delete('/compliance-standards/:id', authenticate, requireRole('ADMIN'), controller.deleteComplianceStandard);
export default router;

View File

@ -7,7 +7,7 @@ async function migrateLocalUploads() {
console.log('[Migration] Starting local uploads migration to MinIO...'); console.log('[Migration] Starting local uploads migration to MinIO...');
await ensureBucketExists(); await ensureBucketExists();
const localUploadsDir = path.join(__dirname, '../../minio-seed'); const localUploadsDir = path.join(__dirname, '../../uploads');
if (!fs.existsSync(localUploadsDir)) { if (!fs.existsSync(localUploadsDir)) {
console.log('[Migration] No local uploads directory found.'); console.log('[Migration] No local uploads directory found.');
return; return;

View File

@ -1,209 +0,0 @@
import dotenv from 'dotenv';
dotenv.config();
import prisma from './utils/db';
async function seedTaxonomy() {
console.log('[Taxonomy-Seed] Starting taxonomy re-seeding with exact user specifications...');
await prisma.vertical.deleteMany();
await prisma.techStack.deleteMany();
await prisma.engagementType.deleteMany();
await prisma.complianceStandard.deleteMany();
console.log('[Taxonomy-Seed] Cleared existing taxonomy tables.');
// 1. Group 1: Industry Verticals (11 Entries)
const verticalsData = [
{ name: 'Cybersecurity & OT Security', slug: 'cybersecurity-ot-security', icon: 'Shield', description: 'ICS/SCADA protection, threat detection, and OT security architecture.', color: '#ef4444', orderIndex: 1 },
{ name: 'Healthcare & Pharma', slug: 'healthcare-pharma', icon: 'Heart', description: 'Patient monitoring, clinical workflows, and pharma tech.', color: '#10b981', orderIndex: 2 },
{ name: 'Finance & Banking', slug: 'finance-banking', icon: 'CreditCard', description: 'Core banking systems, fraud detection, and fintech platforms.', color: '#3b82f6', orderIndex: 3 },
{ name: 'Insurance', slug: 'insurance', icon: 'ShieldCheck', description: 'InsurTech systems, claim automation, and actuarial analytics.', color: '#0284c7', orderIndex: 4 },
{ name: 'Energy & Utilities', slug: 'energy-utilities', icon: 'Zap', description: 'Grid monitoring, renewable management, and infrastructure tech.', color: '#06b6d4', orderIndex: 5 },
{ name: 'Agriculture', slug: 'agriculture', icon: 'Sprout', description: 'AgriTech telemetry, precision farming, and supply analytics.', color: '#84cc16', orderIndex: 6 },
{ name: 'Education', slug: 'education', icon: 'GraduationCap', description: 'EdTech platforms, AI tutoring, and campus management.', color: '#f59e0b', orderIndex: 7 },
{ name: 'Manufacturing & IoT', slug: 'manufacturing-iot', icon: 'Cpu', description: 'Predictive maintenance, IIoT telemetry, and smart factory tech.', color: '#8b5cf6', orderIndex: 8 },
{ name: 'Automotive', slug: 'automotive', icon: 'Car', description: 'Connected vehicles, EV telemetry, and autonomous systems.', color: '#ec4899', orderIndex: 9 },
{ name: 'Retail & Supply Chain', slug: 'retail-supply-chain', icon: 'ShoppingBag', description: 'Smart basket automation, inventory AI, and logistics.', color: '#f97316', orderIndex: 10 },
{ name: 'Blockchain', slug: 'blockchain', icon: 'Link', description: 'Distributed ledgers, smart contracts, and Web3 security.', color: '#6366f1', orderIndex: 11 },
];
const createdVerticals: Record<string, string> = {};
for (const item of verticalsData) {
const v = await prisma.vertical.create({ data: item });
createdVerticals[item.name] = v.id;
}
// 2. Group 2: Technology Stack (21 Entries across 4 Categories)
const techStacksData = [
// Languages/Frameworks
{ name: 'Java / Spring Boot', slug: 'java-spring-boot', category: 'Languages & Frameworks', icon: 'Code', description: 'Enterprise backend services and Spring ecosystem.', color: '#3b82f6', orderIndex: 1 },
{ name: 'Node.js', slug: 'nodejs', category: 'Languages & Frameworks', icon: 'Server', description: 'Event-driven JavaScript/TypeScript backend runtimes.', color: '#10b981', orderIndex: 2 },
{ name: 'Python', slug: 'python', category: 'Languages & Frameworks', icon: 'FileCode', description: 'Data science, AI models, and microservices.', color: '#f59e0b', orderIndex: 3 },
{ name: 'React', slug: 'react', category: 'Languages & Frameworks', icon: 'Layout', description: 'Modern web component UIs and frontend state management.', color: '#06b6d4', orderIndex: 4 },
{ name: 'Go', slug: 'golang', category: 'Languages & Frameworks', icon: 'Cpu', description: 'High-performance cloud-native microservices.', color: '#0284c7', orderIndex: 5 },
{ name: '.NET', slug: 'dotnet', category: 'Languages & Frameworks', icon: 'Layers', description: 'C# enterprise applications and Microsoft ecosystem.', color: '#6366f1', orderIndex: 6 },
// AI/ML
{ name: 'AI & ML', slug: 'ai-ml', category: 'AI & ML', icon: 'Sparkles', description: 'Core artificial intelligence and machine learning models.', color: '#8b5cf6', orderIndex: 7 },
{ name: 'LLM / Agentic', slug: 'llm-agentic', category: 'AI & ML', icon: 'Bot', description: 'Large language models, multi-agent frameworks, and reasoning engines.', color: '#a855f7', orderIndex: 8 },
{ name: 'RAG', slug: 'rag', category: 'AI & ML', icon: 'Database', description: 'Retrieval-Augmented Generation and vector search systems.', color: '#ec4899', orderIndex: 9 },
{ name: 'Computer Vision', slug: 'computer-vision', category: 'AI & ML', icon: 'Camera', description: 'Real-time video analytics and optical recognition.', color: '#f43f5e', orderIndex: 10 },
{ name: 'ML Pipelines', slug: 'ml-pipelines', category: 'AI & ML', icon: 'GitBranch', description: 'MLOps, model retraining, and feature stores.', color: '#d946ef', orderIndex: 11 },
{ name: 'Deepfake / Detection', slug: 'deepfake-detection', category: 'AI & ML', icon: 'Eye', description: 'Synthetic media verification and anti-spoofing.', color: '#ef4444', orderIndex: 12 },
// Data/Backend
{ name: 'PostgreSQL', slug: 'postgresql', category: 'Data & Backend', icon: 'Database', description: 'Relational database with JSONB and vector capabilities.', color: '#3b82f6', orderIndex: 13 },
{ name: 'Temporal', slug: 'temporal', category: 'Data & Backend', icon: 'Clock', description: 'Durable workflow execution and saga orchestrations.', color: '#10b981', orderIndex: 14 },
{ name: 'Kafka', slug: 'kafka', category: 'Data & Backend', icon: 'Activity', description: 'Distributed event streaming and message pub/sub.', color: '#f59e0b', orderIndex: 15 },
{ name: 'Event-driven', slug: 'event-driven', category: 'Data & Backend', icon: 'Zap', description: 'Asynchronous event architecture and CQRS patterns.', color: '#06b6d4', orderIndex: 16 },
{ name: 'Microservices', slug: 'microservices', category: 'Data & Backend', icon: 'Grid', description: 'Decoupled service APIs and domain-driven design.', color: '#6366f1', orderIndex: 17 },
// Cloud/Infra
{ name: 'AWS', slug: 'aws', category: 'Cloud & Infra', icon: 'Cloud', description: 'Amazon Web Services cloud infrastructure.', color: '#f97316', orderIndex: 18 },
{ name: 'Sovereign / On-prem', slug: 'sovereign-onprem', category: 'Cloud & Infra', icon: 'Lock', description: 'Sovereign cloud hosting and air-gapped on-premise deployments.', color: '#64748b', orderIndex: 19 },
{ name: 'Kubernetes', slug: 'kubernetes', category: 'Cloud & Infra', icon: 'Box', description: 'K8s container orchestration and mesh networking.', color: '#0284c7', orderIndex: 20 },
{ name: 'IaaS', slug: 'iaas', category: 'Cloud & Infra', icon: 'Server', description: 'Infrastructure-as-a-Service and virtualized bare metal.', color: '#475569', orderIndex: 21 },
];
const createdTechStacks: Record<string, string> = {};
for (const item of techStacksData) {
const ts = await prisma.techStack.create({ data: item });
createdTechStacks[item.name] = ts.id;
}
// 3. Group 3: Engagement Type (4 Entries)
const engagementTypesData = [
{ name: 'Build', slug: 'build', icon: 'Wrench', description: 'Greenfield product engineering and 0-to-1 development.', color: '#3b82f6', orderIndex: 1 },
{ name: 'Rescue', slug: 'rescue', icon: 'LifeBuoy', description: 'Turnaround engineering, legacy modernization, and critical fixes.', color: '#ef4444', orderIndex: 2 },
{ name: 'Scale', slug: 'scale', icon: 'TrendingUp', description: 'Performance optimization, architecture scaling, and throughput expansion.', color: '#10b981', orderIndex: 3 },
{ name: 'Due Diligence', slug: 'due-diligence', icon: 'FileSearch', description: 'Technical audits, code reviews, and M&A architecture assessments.', color: '#f59e0b', orderIndex: 4 },
];
const createdEngagementTypes: Record<string, string> = {};
for (const item of engagementTypesData) {
const et = await prisma.engagementType.create({ data: item });
createdEngagementTypes[item.name] = et.id;
}
// 4. Group 4: Compliance / Regulatory (5 Entries)
const complianceStandardsData = [
{ name: 'HIPAA', slug: 'hipaa', icon: 'Activity', description: 'Health Insurance Portability and Accountability Act.', color: '#ec4899', orderIndex: 1 },
{ name: 'GxP', slug: 'gxp', icon: 'ShieldCheck', description: 'Good Practice quality guidelines for pharma and life sciences.', color: '#10b981', orderIndex: 2 },
{ name: 'APRA CPS 230', slug: 'apra-cps-230', icon: 'Building', description: 'APRA Operational Risk Management standard for banking.', color: '#3b82f6', orderIndex: 3 },
{ name: 'SOC 2', slug: 'soc-2', icon: 'FileCheck', description: 'SOC 2 security, availability, and confidentiality controls.', color: '#06b6d4', orderIndex: 4 },
{ name: 'GDPR / Sovereign', slug: 'gdpr-sovereign', icon: 'Lock', description: 'EU General Data Protection Regulation and data sovereignty.', color: '#8b5cf6', orderIndex: 5 },
];
const createdCompliance: Record<string, string> = {};
for (const item of complianceStandardsData) {
const cs = await prisma.complianceStandard.create({ data: item });
createdCompliance[item.name] = cs.id;
}
console.log('[Taxonomy-Seed] Successfully seeded 11 Verticals, 21 Tech Stacks, 4 Engagement Types, and 5 Compliance Standards.');
// 5. Re-map Catalog Assets to the Exact Taxonomy Entries
const assets = await prisma.asset.findMany();
console.log(`[Taxonomy-Seed] Mapping exact taxonomy relations for ${assets.length} catalog assets...`);
let updatedCount = 0;
for (const asset of assets) {
const text = (asset.title + ' ' + (asset.description || '') + ' ' + (asset.tags || []).join(' ')).toLowerCase();
const targetVerticals: string[] = [];
const targetTechs: string[] = [];
const targetEngagements: string[] = [];
const targetCompliance: string[] = [];
// Verticals mapping
if (text.includes('cyber') || text.includes('security') || text.includes('scada') || text.includes('ot security')) {
if (createdVerticals['Cybersecurity & OT Security']) targetVerticals.push(createdVerticals['Cybersecurity & OT Security']);
}
if (text.includes('health') || text.includes('patient') || text.includes('medical') || text.includes('pharma') || text.includes('diabetic')) {
if (createdVerticals['Healthcare & Pharma']) targetVerticals.push(createdVerticals['Healthcare & Pharma']);
}
if (text.includes('bank') || text.includes('finance') || text.includes('fintech') || text.includes('payment')) {
if (createdVerticals['Finance & Banking']) targetVerticals.push(createdVerticals['Finance & Banking']);
}
if (text.includes('insurance') || text.includes('claim')) {
if (createdVerticals['Insurance']) targetVerticals.push(createdVerticals['Insurance']);
}
if (text.includes('energy') || text.includes('grid') || text.includes('metering') || text.includes('utility') || text.includes('water')) {
if (createdVerticals['Energy & Utilities']) targetVerticals.push(createdVerticals['Energy & Utilities']);
}
if (text.includes('agri') || text.includes('farm') || text.includes('crop')) {
if (createdVerticals['Agriculture']) targetVerticals.push(createdVerticals['Agriculture']);
}
if (text.includes('student') || text.includes('education') || text.includes('textbook') || text.includes('school') || text.includes('plagiarism')) {
if (createdVerticals['Education']) targetVerticals.push(createdVerticals['Education']);
}
if (text.includes('manufactur') || text.includes('iot') || text.includes('sensor') || text.includes('factory')) {
if (createdVerticals['Manufacturing & IoT']) targetVerticals.push(createdVerticals['Manufacturing & IoT']);
}
if (text.includes('auto') || text.includes('vehicle') || text.includes('car') || text.includes('ev ')) {
if (createdVerticals['Automotive']) targetVerticals.push(createdVerticals['Automotive']);
}
if (text.includes('retail') || text.includes('basket') || text.includes('store') || text.includes('supply')) {
if (createdVerticals['Retail & Supply Chain']) targetVerticals.push(createdVerticals['Retail & Supply Chain']);
}
if (text.includes('blockchain') || text.includes('ledger') || text.includes('web3')) {
if (createdVerticals['Blockchain']) targetVerticals.push(createdVerticals['Blockchain']);
}
// Tech Stacks mapping
if (text.includes('ai') || text.includes('ml') || text.includes('model') || text.includes('predictive') || text.includes('chatbot')) {
if (createdTechStacks['AI & ML']) targetTechs.push(createdTechStacks['AI & ML']);
}
if (text.includes('llm') || text.includes('agent') || text.includes('gpt') || text.includes('deepseek')) {
if (createdTechStacks['LLM / Agentic']) targetTechs.push(createdTechStacks['LLM / Agentic']);
}
if (text.includes('rag') || text.includes('vector') || text.includes('retrieval')) {
if (createdTechStacks['RAG']) targetTechs.push(createdTechStacks['RAG']);
}
if (text.includes('vision') || text.includes('camera') || text.includes('image')) {
if (createdTechStacks['Computer Vision']) targetTechs.push(createdTechStacks['Computer Vision']);
}
if (text.includes('postgres') || text.includes('db') || text.includes('sql')) {
if (createdTechStacks['PostgreSQL']) targetTechs.push(createdTechStacks['PostgreSQL']);
}
if (text.includes('aws') || text.includes('cloud') || text.includes('server')) {
if (createdTechStacks['AWS']) targetTechs.push(createdTechStacks['AWS']);
}
// Default Fallbacks
if (targetVerticals.length === 0 && createdVerticals['Cybersecurity & OT Security']) {
targetVerticals.push(createdVerticals['Cybersecurity & OT Security']);
}
if (targetTechs.length === 0 && createdTechStacks['AI & ML']) {
targetTechs.push(createdTechStacks['AI & ML']);
}
if (createdEngagementTypes['Build']) {
targetEngagements.push(createdEngagementTypes['Build']);
}
if (createdCompliance['SOC 2']) {
targetCompliance.push(createdCompliance['SOC 2']);
}
await prisma.asset.update({
where: { id: asset.id },
data: {
verticals: { connect: targetVerticals.map(id => ({ id })) },
techStacks: { connect: targetTechs.map(id => ({ id })) },
engagementTypes: { connect: targetEngagements.map(id => ({ id })) },
complianceStandards: { connect: targetCompliance.map(id => ({ id })) },
}
});
updatedCount++;
}
console.log(`[Taxonomy-Seed] Successfully mapped exact taxonomy relations for ${updatedCount} assets.`);
process.exit(0);
}
seedTaxonomy().catch(err => {
console.error('[Taxonomy-Seed] Failed:', err);
process.exit(1);
});

View File

@ -1,61 +1,8 @@
import prisma from '../utils/db'; import prisma from '../utils/db';
import crypto from 'crypto';
import { MailService } from './mail.service';
import { s3Client, BUCKET_NAME } from '../utils/s3';
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
export class AssetService { export class AssetService {
private mailService = new MailService();
public async triggerOnboardingForEligiblePartners(orgIds: string[], userIds: string[]) {
// Find all users in PENDING_ASSETS state who belong to either the userIds list or orgIds list
const users = await prisma.user.findMany({
where: {
onboardingStatus: 'PENDING_ASSETS',
OR: [
{ id: { in: userIds } },
{ organizationId: { in: orgIds } }
]
}
});
for (const user of users) {
// Find count of shared assets for this user (either user-specific or organization-level)
const count = await prisma.sharedAsset.count({
where: {
OR: [
{ userId: user.id },
{ organizationId: user.organizationId || undefined, userId: null }
]
}
});
if (count > 0) {
let inviteToken = user.inviteToken;
let inviteTokenExp = user.inviteTokenExp;
if (!inviteToken) {
inviteToken = crypto.randomBytes(32).toString('hex');
inviteTokenExp = new Date(Date.now() + 24 * 60 * 60 * 1000);
}
await prisma.user.update({
where: { id: user.id },
data: {
onboardingStatus: 'PENDING_ONBOARDING',
inviteToken,
inviteTokenExp,
}
});
// Trigger the SMTP email
await this.mailService.sendInviteEmail(user.email, inviteToken!);
}
}
}
public async createAsset(data: any) { public async createAsset(data: any) {
const { sharedOrgIds, shares, tags, verticalIds, techStackIds, engagementTypeIds, complianceIds, ...rest } = data; const { sharedOrgIds, shares, tags, ...rest } = data;
// Parse tags // Parse tags
let parsedTags: string[] = []; let parsedTags: string[] = [];
@ -69,43 +16,14 @@ export class AssetService {
} }
} }
const parseIds = (val: any): string[] => {
if (Array.isArray(val)) return val;
if (typeof val === 'string' && val.trim()) {
try { return JSON.parse(val); }
catch { return val.split(',').map((s: string) => s.trim()).filter(Boolean); }
}
return [];
};
const parsedVerticalIds = parseIds(verticalIds);
const parsedTechStackIds = parseIds(techStackIds);
const parsedEngagementTypeIds = parseIds(engagementTypeIds);
const parsedComplianceIds = parseIds(complianceIds);
const asset = await prisma.asset.create({ const asset = await prisma.asset.create({
data: { data: {
...rest, ...rest,
tags: parsedTags, tags: parsedTags,
...(parsedVerticalIds.length > 0 ? {
verticals: { connect: parsedVerticalIds.map(id => ({ id })) }
} : {}),
...(parsedTechStackIds.length > 0 ? {
techStacks: { connect: parsedTechStackIds.map(id => ({ id })) }
} : {}),
...(parsedEngagementTypeIds.length > 0 ? {
engagementTypes: { connect: parsedEngagementTypeIds.map(id => ({ id })) }
} : {}),
...(parsedComplianceIds.length > 0 ? {
complianceStandards: { connect: parsedComplianceIds.map(id => ({ id })) }
} : {}),
} }
}); });
// Handle immediate sharing // Handle immediate sharing
const orgIdsToCheck: string[] = [];
const userIdsToCheck: string[] = [];
if (shares) { if (shares) {
let parsedShares: any[] = []; let parsedShares: any[] = [];
if (Array.isArray(shares)) { if (Array.isArray(shares)) {
@ -127,10 +45,6 @@ export class AssetService {
})), })),
skipDuplicates: true, skipDuplicates: true,
}); });
parsedShares.forEach(s => {
if (s.organizationId) orgIdsToCheck.push(s.organizationId);
if (s.userId) userIdsToCheck.push(s.userId);
});
} }
} else if (sharedOrgIds) { } else if (sharedOrgIds) {
let orgIds: string[] = []; let orgIds: string[] = [];
@ -153,165 +67,65 @@ export class AssetService {
})), })),
skipDuplicates: true, skipDuplicates: true,
}); });
orgIdsToCheck.push(...orgIds);
} }
} }
if (orgIdsToCheck.length > 0 || userIdsToCheck.length > 0) {
await this.triggerOnboardingForEligiblePartners(orgIdsToCheck, userIdsToCheck);
}
return this.getAssetById(asset.id); return this.getAssetById(asset.id);
} }
public async getAssets( public async getAssets(userContext?: { role: string; userId: string }) {
userContext?: { role: string; userId: string },
filters?: {
search?: string;
verticalIds?: string[];
techStackIds?: string[];
engagementTypeIds?: string[];
complianceIds?: string[];
contentTypes?: string[];
subcategories?: string[];
tags?: string[];
sortBy?: 'newest' | 'oldest' | 'title_asc' | 'title_desc' | 'type';
}
) {
if (!userContext) { if (!userContext) {
return []; return [];
} }
const whereClause: any = {}; if (userContext.role === 'ADMIN') {
return await prisma.asset.findMany({
if (userContext.role !== 'ADMIN') { include: {
// For clients/partners, find user organization first
const user = await prisma.user.findUnique({
where: { id: userContext.userId }
});
if (!user || !user.organizationId) {
return [];
}
// Support comma-separated multiple groups
const userGroups = user.partnerGroup
? user.partnerGroup.split(',').map(s => s.trim().toLowerCase())
: [];
whereClause.status = 'published';
whereClause.OR = [
{
sharedWith: { sharedWith: {
some: { include: {
organizationId: user.organizationId, organization: {
OR: [ select: { id: true, name: true }
{ userId: null }, },
{ userId: user.id } user: {
] select: { id: true, email: true }
}
} }
} },
} downloadRequests: {
]; include: {
user: {
if (userGroups.length > 0) { select: { id: true, email: true }
whereClause.OR.push({
assetGroups: {
some: {
name: {
in: userGroups,
mode: 'insensitive'
} }
} }
} }
}); },
} orderBy: { createdAt: 'desc' }
}
// Apply Filter Criteria (Additive AND logic)
const andConditions: any[] = [];
if (filters?.search && filters.search.trim()) {
const q = filters.search.trim();
andConditions.push({
OR: [
{ title: { contains: q, mode: 'insensitive' } },
{ description: { contains: q, mode: 'insensitive' } },
{ subcategory: { contains: q, mode: 'insensitive' } },
{ categoryId: { contains: q, mode: 'insensitive' } },
{ tags: { has: q } },
]
}); });
} }
if (filters?.verticalIds && filters.verticalIds.length > 0) { // For clients/partners, find user organization first
andConditions.push({ const user = await prisma.user.findUnique({
verticals: { where: { id: userContext.userId }
some: { id: { in: filters.verticalIds } } });
}
});
}
if (filters?.techStackIds && filters.techStackIds.length > 0) { if (!user || !user.organizationId) {
andConditions.push({ return [];
techStacks: {
some: { id: { in: filters.techStackIds } }
}
});
} }
if (filters?.engagementTypeIds && filters.engagementTypeIds.length > 0) {
andConditions.push({
engagementTypes: {
some: { id: { in: filters.engagementTypeIds } }
}
});
}
if (filters?.complianceIds && filters.complianceIds.length > 0) {
andConditions.push({
complianceStandards: {
some: { id: { in: filters.complianceIds } }
}
});
}
if (filters?.contentTypes && filters.contentTypes.length > 0) {
andConditions.push({
contentType: { in: filters.contentTypes }
});
}
if (filters?.subcategories && filters.subcategories.length > 0) {
andConditions.push({
subcategory: { in: filters.subcategories }
});
}
if (filters?.tags && filters.tags.length > 0) {
andConditions.push({
tags: { hasSome: filters.tags }
});
}
if (andConditions.length > 0) {
whereClause.AND = andConditions;
}
// Order By
let orderBy: any = { createdAt: 'desc' };
if (filters?.sortBy === 'oldest') orderBy = { createdAt: 'asc' };
else if (filters?.sortBy === 'title_asc') orderBy = { title: 'asc' };
else if (filters?.sortBy === 'title_desc') orderBy = { title: 'desc' };
else if (filters?.sortBy === 'type') orderBy = { type: 'asc' };
return await prisma.asset.findMany({ return await prisma.asset.findMany({
where: whereClause, where: {
status: 'published',
sharedWith: {
some: {
organizationId: user.organizationId,
OR: [
{ userId: null },
{ userId: user.id }
]
}
}
},
include: { include: {
verticals: true,
techStacks: true,
engagementTypes: true,
complianceStandards: true,
sharedWith: { sharedWith: {
include: { include: {
organization: { organization: {
@ -322,15 +136,11 @@ export class AssetService {
} }
} }
}, },
downloadRequests: userContext.role === 'ADMIN' ? { downloadRequests: {
include: {
user: { select: { id: true, email: true } }
}
} : {
where: { userId: userContext.userId } where: { userId: userContext.userId }
} }
}, },
orderBy, orderBy: { createdAt: 'desc' }
}); });
} }
@ -338,10 +148,6 @@ export class AssetService {
return await prisma.asset.findUnique({ return await prisma.asset.findUnique({
where: { id }, where: { id },
include: { include: {
verticals: true,
techStacks: true,
engagementTypes: true,
complianceStandards: true,
sharedWith: { sharedWith: {
include: { include: {
organization: { organization: {
@ -364,26 +170,10 @@ export class AssetService {
} }
public async updateAsset(id: string, data: any) { public async updateAsset(id: string, data: any) {
const { shares, sharedOrgIds, tags, verticalIds, techStackIds, engagementTypeIds, complianceIds, ...rest } = data; const { sharedOrgIds, shares, tags, ...rest } = data;
const updateData: any = { ...rest }; const updateData: any = { ...rest };
if (rest.thumbnailUrl !== undefined) {
const existing = await prisma.asset.findUnique({ where: { id }, select: { thumbnailUrl: true } });
if (existing?.thumbnailUrl && existing.thumbnailUrl.startsWith('/uploads/') && existing.thumbnailUrl !== rest.thumbnailUrl) {
const oldFilename = existing.thumbnailUrl.replace('/uploads/', '');
try {
await s3Client.send(new DeleteObjectCommand({
Bucket: BUCKET_NAME,
Key: oldFilename,
}));
console.log(`[S3] Cleaned up replaced thumbnail file: ${oldFilename}`);
} catch (err) {
console.error(`[S3] Failed to clean up replaced thumbnail file ${oldFilename}:`, err);
}
}
}
if (tags !== undefined) { if (tags !== undefined) {
let parsedTags: string[] = []; let parsedTags: string[] = [];
if (Array.isArray(tags)) { if (Array.isArray(tags)) {
@ -398,36 +188,11 @@ export class AssetService {
updateData.tags = parsedTags; updateData.tags = parsedTags;
} }
const parseIds = (val: any): string[] => {
if (Array.isArray(val)) return val;
if (typeof val === 'string') {
try { return JSON.parse(val); }
catch { return val.split(',').map((s: string) => s.trim()).filter(Boolean); }
}
return [];
};
if (verticalIds !== undefined) {
updateData.verticals = { set: parseIds(verticalIds).map(vid => ({ id: vid })) };
}
if (techStackIds !== undefined) {
updateData.techStacks = { set: parseIds(techStackIds).map(tid => ({ id: tid })) };
}
if (engagementTypeIds !== undefined) {
updateData.engagementTypes = { set: parseIds(engagementTypeIds).map(eid => ({ id: eid })) };
}
if (complianceIds !== undefined) {
updateData.complianceStandards = { set: parseIds(complianceIds).map(cid => ({ id: cid })) };
}
await prisma.asset.update({ await prisma.asset.update({
where: { id }, where: { id },
data: updateData data: updateData
}); });
const orgIdsToCheck: string[] = [];
const userIdsToCheck: string[] = [];
if (shares !== undefined) { if (shares !== undefined) {
let parsedShares: any[] = []; let parsedShares: any[] = [];
if (Array.isArray(shares)) { if (Array.isArray(shares)) {
@ -450,10 +215,6 @@ export class AssetService {
})), })),
skipDuplicates: true skipDuplicates: true
}); });
parsedShares.forEach(s => {
if (s.organizationId) orgIdsToCheck.push(s.organizationId);
if (s.userId) userIdsToCheck.push(s.userId);
});
} }
} else if (sharedOrgIds !== undefined) { } else if (sharedOrgIds !== undefined) {
let orgIds: string[] = []; let orgIds: string[] = [];
@ -477,14 +238,9 @@ export class AssetService {
})), })),
skipDuplicates: true skipDuplicates: true
}); });
orgIdsToCheck.push(...orgIds);
} }
} }
if (orgIdsToCheck.length > 0 || userIdsToCheck.length > 0) {
await this.triggerOnboardingForEligiblePartners(orgIdsToCheck, userIdsToCheck);
}
return this.getAssetById(id); return this.getAssetById(id);
} }
@ -497,9 +253,6 @@ export class AssetService {
})), })),
skipDuplicates: true skipDuplicates: true
}); });
await this.triggerOnboardingForEligiblePartners(organizationIds, []);
return this.getAssetById(assetId); return this.getAssetById(assetId);
} }
@ -514,28 +267,6 @@ export class AssetService {
return this.getAssetById(assetId); return this.getAssetById(assetId);
} }
public async bulkShareAssets(assetIds: string[], shares: any[]) {
await prisma.$transaction([
prisma.sharedAsset.deleteMany({
where: { assetId: { in: assetIds } }
}),
prisma.sharedAsset.createMany({
data: assetIds.flatMap(assetId =>
shares.map(s => ({
assetId,
organizationId: s.organizationId,
userId: s.userId || null,
}))
),
skipDuplicates: true
})
]);
const orgIds = shares.map(s => s.organizationId).filter(Boolean);
const userIds = shares.map(s => s.userId).filter(Boolean);
await this.triggerOnboardingForEligiblePartners(orgIds, userIds);
}
public async incrementDownloadCount(id: string) { public async incrementDownloadCount(id: string) {
return await prisma.asset.update({ return await prisma.asset.update({
where: { id }, where: { id },
@ -546,33 +277,6 @@ export class AssetService {
} }
public async deleteAsset(id: string) { public async deleteAsset(id: string) {
const asset = await prisma.asset.findUnique({ where: { id } });
if (asset) {
if (asset.url && asset.url.startsWith('/uploads/')) {
const filename = asset.url.replace('/uploads/', '');
try {
await s3Client.send(new DeleteObjectCommand({
Bucket: BUCKET_NAME,
Key: filename,
}));
console.log(`[S3] Deleted asset file: ${filename}`);
} catch (err) {
console.error(`[S3] Failed to delete file ${filename}:`, err);
}
}
if (asset.thumbnailUrl && asset.thumbnailUrl.startsWith('/uploads/')) {
const thumbFilename = asset.thumbnailUrl.replace('/uploads/', '');
try {
await s3Client.send(new DeleteObjectCommand({
Bucket: BUCKET_NAME,
Key: thumbFilename,
}));
console.log(`[S3] Deleted thumbnail file: ${thumbFilename}`);
} catch (err) {
console.error(`[S3] Failed to delete thumbnail file ${thumbFilename}:`, err);
}
}
}
return await prisma.asset.delete({ where: { id } }); return await prisma.asset.delete({ where: { id } });
} }
@ -609,62 +313,4 @@ export class AssetService {
data: { status: 'REJECTED' } data: { status: 'REJECTED' }
}); });
} }
public async createAssetGroup(name: string, description?: string, assetIds: string[] = []) {
return await prisma.assetGroup.create({
data: {
name,
description,
assets: {
connect: assetIds.map(id => ({ id }))
}
},
include: {
assets: true
}
});
}
public async addAssetsToGroup(groupId: string, assetIds: string[]) {
return await prisma.assetGroup.update({
where: { id: groupId },
data: {
assets: {
connect: assetIds.map(id => ({ id }))
}
},
include: {
assets: true
}
});
}
public async removeAssetsFromGroup(groupId: string, assetIds: string[]) {
return await prisma.assetGroup.update({
where: { id: groupId },
data: {
assets: {
disconnect: assetIds.map(id => ({ id }))
}
},
include: {
assets: true
}
});
}
public async getAssetGroups() {
return await prisma.assetGroup.findMany({
include: {
assets: true
},
orderBy: { createdAt: 'desc' }
});
}
public async deleteAssetGroup(id: string) {
return await prisma.assetGroup.delete({
where: { id }
});
}
} }

View File

@ -2,17 +2,14 @@ import prisma from '../utils/db';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { AppError } from '../utils/errors'; import { AppError } from '../utils/errors';
import crypto from 'crypto';
import { MailService } from './mail.service';
export class AuthService { export class AuthService {
private mailService = new MailService();
private async getOrCreateOrganizationForEmail(email: string) { private async getOrCreateOrganizationForEmail(email: string) {
const domain = email.split('@')[1]; const domain = email.split('@')[1];
if (!domain) return null; if (!domain) return null;
const name = domain.toUpperCase(); // Ignore generic/public emails or treat them as their own organization
const name = domain.split('.')[0].toUpperCase();
if (!name) return null; if (!name) return null;
let org = await prisma.organization.findFirst({ let org = await prisma.organization.findFirst({
@ -50,50 +47,20 @@ export class AuthService {
return this.getUserById(user.id); return this.getUserById(user.id);
} }
public async invitePartner(email: string, options: { organizationId?: string, partnerGroup?: string, assignedNdaId?: string, assignedMsaId?: string, sharedAssetIds?: string[], mfaEnabled?: boolean, showEcosystemTab?: boolean } = {}) { public async invitePartner(email: string, organizationId?: string) {
const existing = await prisma.user.findUnique({ where: { email } }); const existing = await prisma.user.findUnique({ where: { email } });
if (existing) throw new AppError('Email already in use', 400); if (existing) throw new AppError('Email already in use', 400);
let orgId = options.organizationId || null; let orgId = organizationId || null;
if (!orgId) { if (!orgId) {
orgId = await this.getOrCreateOrganizationForEmail(email); orgId = await this.getOrCreateOrganizationForEmail(email);
} }
const crypto = require('crypto');
const inviteToken = crypto.randomBytes(32).toString('hex'); const inviteToken = crypto.randomBytes(32).toString('hex');
const inviteTokenExp = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours const inviteTokenExp = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
const hasAssets = options.sharedAssetIds && options.sharedAssetIds.length > 0; await prisma.user.create({
const onboardingStatus = hasAssets ? 'PENDING_ONBOARDING' : 'PENDING_ASSETS';
let assignedNdaId: string | null = null;
if (options.assignedNdaId === 'NONE') {
assignedNdaId = null;
} else if (options.assignedNdaId === undefined || options.assignedNdaId === '') {
// Resolve active default NDA
const activeNda = await prisma.legalDocument.findFirst({
where: { type: 'NDA', isActive: true },
select: { id: true }
});
assignedNdaId = activeNda?.id || null;
} else {
assignedNdaId = options.assignedNdaId;
}
let assignedMsaId: string | null = null;
if (options.assignedMsaId === 'NONE') {
assignedMsaId = null;
} else if (options.assignedMsaId === undefined || options.assignedMsaId === '') {
// Resolve active default MSA
const activeMsa = await prisma.legalDocument.findFirst({
where: { type: 'MSA', isActive: true },
select: { id: true }
});
assignedMsaId = activeMsa?.id || null;
} else {
assignedMsaId = options.assignedMsaId;
}
const user = await prisma.user.create({
data: { data: {
email, email,
passwordHash: '', // Set on accept passwordHash: '', // Set on accept
@ -101,204 +68,13 @@ export class AuthService {
organizationId: orgId, organizationId: orgId,
inviteToken, inviteToken,
inviteTokenExp, inviteTokenExp,
onboardingStatus, onboardingStatus: 'PENDING_ONBOARDING',
mfaEnabled: options.mfaEnabled !== undefined ? options.mfaEnabled : true, mfaEnabled: true,
showEcosystemTab: options.showEcosystemTab !== undefined ? options.showEcosystemTab : true,
partnerGroup: options.partnerGroup || null,
assignedNdaId,
assignedMsaId,
} }
}); });
if (hasAssets && orgId) { // In a real app, send email here
await prisma.sharedAsset.createMany({ return { inviteToken };
data: options.sharedAssetIds!.map(assetId => ({
assetId,
organizationId: orgId!,
userId: user.id,
})),
skipDuplicates: true
});
}
let emailSent = false;
let emailError: string | undefined = undefined;
// Trigger SMTP mail if assets are assigned
if (onboardingStatus === 'PENDING_ONBOARDING') {
try {
await this.mailService.sendInviteEmail(email, inviteToken);
emailSent = true;
} catch (err) {
console.error(`[SMTP ERROR] Non-fatal invitation mail failure: ${(err as Error).message}`);
emailError = (err as Error).message;
}
}
return { inviteToken, emailSent, emailError };
}
public async updatePartner(partnerId: string, options: { partnerGroup?: string | null, assignedNdaId?: string | null, assignedMsaId?: string | null, sharedAssetIds?: string[], mfaEnabled?: boolean, showEcosystemTab?: boolean }) {
const user = await prisma.user.findUnique({
where: { id: partnerId }
});
if (!user) throw new AppError('Partner not found', 404);
const oldStatus = user.onboardingStatus;
const orgId = user.organizationId;
let assignedNdaId = user.assignedNdaId;
if (options.assignedNdaId !== undefined) {
if (options.assignedNdaId === 'NONE') {
assignedNdaId = null;
} else if (options.assignedNdaId === '') {
const activeNda = await prisma.legalDocument.findFirst({
where: { type: 'NDA', isActive: true },
select: { id: true }
});
assignedNdaId = activeNda?.id || null;
} else {
assignedNdaId = options.assignedNdaId;
}
}
let assignedMsaId = user.assignedMsaId;
if (options.assignedMsaId !== undefined) {
if (options.assignedMsaId === 'NONE') {
assignedMsaId = null;
} else if (options.assignedMsaId === '') {
const activeMsa = await prisma.legalDocument.findFirst({
where: { type: 'MSA', isActive: true },
select: { id: true }
});
assignedMsaId = activeMsa?.id || null;
} else {
assignedMsaId = options.assignedMsaId;
}
}
// Update basic fields
await prisma.user.update({
where: { id: partnerId },
data: {
partnerGroup: options.partnerGroup !== undefined ? options.partnerGroup : user.partnerGroup,
assignedNdaId,
assignedMsaId,
mfaEnabled: options.mfaEnabled !== undefined ? options.mfaEnabled : user.mfaEnabled,
showEcosystemTab: options.showEcosystemTab !== undefined ? options.showEcosystemTab : user.showEcosystemTab,
}
});
// Update shared assets if provided
if (options.sharedAssetIds !== undefined) {
// Clear existing shared assets for this user (both user-specific and org-wide)
if (orgId) {
await prisma.sharedAsset.deleteMany({
where: {
OR: [
{ userId: partnerId },
{ organizationId: orgId, userId: null }
]
}
});
} else {
await prisma.sharedAsset.deleteMany({
where: { userId: partnerId }
});
}
// Create new shared assets if any
if (options.sharedAssetIds.length > 0 && orgId) {
await prisma.sharedAsset.createMany({
data: options.sharedAssetIds.map(assetId => ({
assetId,
organizationId: orgId,
userId: partnerId,
})),
skipDuplicates: true
});
}
// Check transition from PENDING_ASSETS to PENDING_ONBOARDING
if (oldStatus === 'PENDING_ASSETS' && options.sharedAssetIds.length > 0) {
let inviteToken = user.inviteToken;
let inviteTokenExp = user.inviteTokenExp;
if (!inviteToken) {
inviteToken = crypto.randomBytes(32).toString('hex');
inviteTokenExp = new Date(Date.now() + 24 * 60 * 60 * 1000);
}
await prisma.user.update({
where: { id: partnerId },
data: {
onboardingStatus: 'PENDING_ONBOARDING',
inviteToken,
inviteTokenExp,
}
});
try {
// Trigger the SMTP email since assets are now assigned!
await this.mailService.sendInviteEmail(user.email, inviteToken!);
} catch (err) {
console.error(`[SMTP ERROR] Non-fatal update transition email failure: ${(err as Error).message}`);
}
} else if (oldStatus === 'PENDING_ONBOARDING' && options.sharedAssetIds.length === 0) {
// Transition back if all assets are removed before onboarding starts
await prisma.user.update({
where: { id: partnerId },
data: {
onboardingStatus: 'PENDING_ASSETS'
}
});
}
}
return this.getUserById(partnerId);
}
public async resendInvite(partnerId: string) {
const user = await prisma.user.findUnique({
where: { id: partnerId }
});
if (!user) throw new AppError('Partner not found', 404);
if (user.role === 'ADMIN') throw new AppError('Cannot send invitation to admins', 400);
if (user.onboardingStatus === 'APPROVED') {
throw new AppError('Partner is already active/approved', 400);
}
let inviteToken = user.inviteToken;
let inviteTokenExp = user.inviteTokenExp;
// Generate token if missing or expired
if (!inviteToken || !inviteTokenExp || inviteTokenExp < new Date()) {
inviteToken = crypto.randomBytes(32).toString('hex');
inviteTokenExp = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
await prisma.user.update({
where: { id: partnerId },
data: {
inviteToken,
inviteTokenExp,
onboardingStatus: 'PENDING_ONBOARDING'
}
});
} else if (user.onboardingStatus === 'PENDING_ASSETS') {
// Force onboarding status to PENDING_ONBOARDING
await prisma.user.update({
where: { id: partnerId },
data: {
onboardingStatus: 'PENDING_ONBOARDING'
}
});
}
try {
await this.mailService.sendInviteEmail(user.email, inviteToken);
} catch (err) {
throw new AppError(`Failed to send invitation email: ${(err as Error).message}`, 500);
}
return { success: true };
} }
public async validateInvite(token: string) { public async validateInvite(token: string) {
@ -316,16 +92,12 @@ export class AuthService {
} }
const passwordHash = await bcrypt.hash(passwordString, 10); const passwordHash = await bcrypt.hash(passwordString, 10);
const noAgreements = !user.assignedNdaId && !user.assignedMsaId;
const newOnboardingStatus = noAgreements ? 'APPROVED' : user.onboardingStatus;
const updatedUser = await prisma.user.update({ const updatedUser = await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
passwordHash, passwordHash,
inviteToken: null, inviteToken: null,
inviteTokenExp: null, inviteTokenExp: null,
onboardingStatus: newOnboardingStatus,
} }
}); });
@ -372,66 +144,19 @@ export class AuthService {
} }
public async listPartners() { public async listPartners() {
const partners = await prisma.user.findMany({ return await prisma.user.findMany({
where: { role: 'PARTNER_USER' }, where: { role: 'PARTNER_USER' },
select: { select: {
id: true, id: true,
email: true, email: true,
onboardingStatus: true, onboardingStatus: true,
mfaEnabled: true, mfaEnabled: true,
showEcosystemTab: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
organizationId: true, organizationId: true,
partnerGroup: true,
assignedNdaId: true,
assignedMsaId: true,
assignedNda: {
select: { id: true, version: true }
},
assignedMsa: {
select: { id: true, version: true }
},
inviteToken: true,
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
const partnersWithSharedAssets = await Promise.all(
partners.map(async (partner) => {
let sharedAssets: any[] = [];
if (partner.organizationId) {
sharedAssets = await prisma.sharedAsset.findMany({
where: {
organizationId: partner.organizationId,
OR: [
{ userId: null },
{ userId: partner.id }
]
},
select: {
assetId: true,
asset: {
select: {
id: true,
title: true,
type: true,
categoryId: true,
subcategory: true,
url: true
}
}
}
});
}
return {
...partner,
sharedAssets
};
})
);
return partnersWithSharedAssets;
} }
public async getUserById(id: string) { public async getUserById(id: string) {
@ -444,17 +169,8 @@ export class AuthService {
mfaEnabled: true, mfaEnabled: true,
organizationId: true, organizationId: true,
onboardingStatus: true, onboardingStatus: true,
partnerGroup: true,
website: true,
sector: true,
companySize: true,
defaultTheme: true,
companyName: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
assignedNdaId: true,
assignedMsaId: true,
showEcosystemTab: true,
organization: { organization: {
select: { select: {
id: true, id: true,
@ -482,17 +198,8 @@ export class AuthService {
mfaEnabled: true, mfaEnabled: true,
organizationId: true, organizationId: true,
onboardingStatus: true, onboardingStatus: true,
partnerGroup: true,
website: true,
sector: true,
companySize: true,
defaultTheme: true,
companyName: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
assignedNdaId: true,
assignedMsaId: true,
showEcosystemTab: true,
organization: { organization: {
select: { select: {
id: true, id: true,
@ -505,102 +212,6 @@ export class AuthService {
} }
} }
if (user && user.role === 'PARTNER_USER' && user.organizationId) {
const sharedAssets = await prisma.sharedAsset.findMany({
where: {
organizationId: user.organizationId,
OR: [
{ userId: null },
{ userId: user.id }
]
},
select: {
assetId: true,
asset: {
select: {
id: true,
title: true,
type: true,
categoryId: true,
subcategory: true,
url: true
}
}
}
});
return {
...user,
sharedAssets
};
}
return user; return user;
} }
public async updateProfile(userId: string, data: {
password?: string;
companyName?: string;
website?: string;
sector?: string;
companySize?: string;
defaultTheme?: string;
}) {
const user = await prisma.user.findUnique({
where: { id: userId },
include: { organization: true }
});
if (!user) throw new AppError('User not found', 404);
const updateData: any = {};
if (data.password) {
updateData.passwordHash = await bcrypt.hash(data.password, 10);
}
if (data.website !== undefined) updateData.website = data.website;
if (data.sector !== undefined) updateData.sector = data.sector;
if (data.companySize !== undefined) updateData.companySize = data.companySize;
if (data.defaultTheme !== undefined) updateData.defaultTheme = data.defaultTheme;
if (data.companyName !== undefined) {
updateData.companyName = data.companyName;
if (user.organizationId) {
await prisma.organization.update({
where: { id: user.organizationId },
data: { name: data.companyName }
});
}
}
const updated = await prisma.user.update({
where: { id: userId },
data: updateData,
select: {
id: true,
email: true,
role: true,
mfaEnabled: true,
organizationId: true,
onboardingStatus: true,
partnerGroup: true,
website: true,
sector: true,
companySize: true,
defaultTheme: true,
companyName: true,
createdAt: true,
updatedAt: true,
assignedNdaId: true,
assignedMsaId: true,
organization: {
select: {
id: true,
name: true,
status: true,
}
}
}
});
return this.getUserById(userId);
}
} }

View File

@ -1,38 +0,0 @@
import prisma from '../utils/db';
export class BranchService {
public async createBranch(organizationId: string, data: { name: string; code?: string }) {
return prisma.branch.create({
data: {
name: data.name,
code: data.code || null,
organizationId,
}
});
}
public async getOrganizationBranches(organizationId: string) {
return prisma.branch.findMany({
where: { organizationId },
include: {
_count: {
select: { users: true, sharedAssets: true }
}
},
orderBy: { createdAt: 'desc' }
});
}
public async updateBranch(id: string, data: { name?: string; code?: string }) {
return prisma.branch.update({
where: { id },
data,
});
}
public async deleteBranch(id: string) {
return prisma.branch.delete({
where: { id }
});
}
}

View File

@ -1,710 +0,0 @@
import prisma from '../utils/db';
import axios from 'axios';
import { ExtractionService, OKFMetadata } from './extraction.service';
export interface CitationItem {
assetId: string;
title: string;
location: string;
type: string;
isRecommended?: boolean;
}
export class ChatService {
private extractionService = new ExtractionService();
private gatewayUrl = process.env.LLM_GATEWAY_URL;
private apiKey = process.env.LLM_API_KEY;
private modelId = process.env.LLM_MODEL_ID;
/**
* Process a user chat prompt with RBAC-scoped, unified RAG retrieval across Assets, Showcase Reels, Ecosystem Offerings, and Legal Documents.
*/
public async processPrompt(
userId: string,
prompt: string,
sessionId?: string,
attachedEntities?: Array<{
id: string;
title: string;
type: string;
entityKind: 'ASSET' | 'SHOWCASE' | 'ECOSYSTEM' | 'LEGAL';
url?: string;
description?: string;
problemStatement?: string;
solution?: string;
thumbnailUrl?: string;
tags?: string[];
}>
) {
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
sharedAssets: true,
assignedNda: true,
assignedMsa: true,
}
});
if (!user) {
throw new Error('User session not found');
}
const partnerGroup = user.partnerGroup ? user.partnerGroup.trim().toLowerCase() : '';
const isClient = user.role === 'PARTNER_USER';
// 1. Determine accessible catalog asset IDs
let accessibleAssetIds: string[] = [];
if (user.role === 'ADMIN') {
const allAssets = await prisma.asset.findMany({ select: { id: true } });
accessibleAssetIds = allAssets.map(a => a.id);
} else {
const shared = await prisma.sharedAsset.findMany({
where: {
OR: [
{ organizationId: user.organizationId || '' },
{ userId: user.id },
user.branchId ? { branchId: user.branchId } : {},
]
},
select: { assetId: true }
});
const sharedIds = shared.map(s => s.assetId);
let groupIds: string[] = [];
if (partnerGroup) {
const groups = await prisma.assetGroup.findMany({
include: { assets: { select: { id: true } } }
});
const matchedGroup = groups.find(g => g.name.trim().toLowerCase() === partnerGroup);
if (matchedGroup) {
groupIds = matchedGroup.assets.map(a => a.id);
}
}
const publicAssets = await prisma.asset.findMany({
where: { includeInKnowledgeBase: true },
select: { id: true }
});
const publicIds = publicAssets.map(a => a.id);
accessibleAssetIds = Array.from(new Set([...sharedIds, ...groupIds, ...publicIds]));
}
// 2. Fetch Embeddings & Data Sources
// Ensure ALL accessible assets have embeddings generated
const embeddedAssets = await prisma.assetEmbedding.findMany({
where: { assetId: { in: accessibleAssetIds } },
select: { assetId: true },
distinct: ['assetId']
});
const embeddedAssetIds = embeddedAssets.map(e => e.assetId);
const missingAssetIds = accessibleAssetIds.filter(id => !embeddedAssetIds.includes(id));
if (missingAssetIds.length > 0) {
await this.autoIndexCatalog(missingAssetIds);
}
const embeddings = await prisma.assetEmbedding.findMany({
where: {
assetId: { in: accessibleAssetIds },
asset: { includeInKnowledgeBase: true }
},
include: {
asset: {
select: {
id: true,
title: true,
type: true,
tags: true,
description: true,
problemStatement: true,
solution: true,
assetGroups: { select: { name: true } },
verticals: { select: { name: true } },
techStacks: { select: { name: true } },
complianceStandards: { select: { name: true } },
}
}
}
});
// 3. Fetch Featured Content (ContentShowcase) & Ecosystem Offerings
const showcaseItems = await prisma.contentShowcase.findMany({
where: { isActive: true }
});
const ecosystemOfferings = await prisma.ecosystemOffering.findMany({
where: { isActive: true }
});
// 4. Hybrid Search Engine with Normalized Fuzzy Term Matching
const normalizeStr = (str: string) => str.toLowerCase().replace(/[^a-z0-9]/g, '');
const promptLower = prompt.toLowerCase().trim();
const promptNorm = normalizeStr(prompt);
const promptTerms = promptLower.split(/\s+/).filter(w => w.length > 2);
const promptVector = JSON.parse(this.extractionService.generateEmbedding(prompt));
const citationsMap = new Map<string, CitationItem>();
const contextLines: string[] = [];
const hasAttachedEntities = attachedEntities && attachedEntities.length > 0;
// Explicit Attached Entities Ingestion (Drag-and-Drop AI Workbench)
if (hasAttachedEntities) {
for (const ent of attachedEntities!) {
if (!ent || !ent.id) continue;
try {
if (ent.entityKind === 'ASSET') {
const dbAsset = await prisma.asset.findUnique({
where: { id: ent.id },
include: {
verticals: true,
techStacks: true,
complianceStandards: true,
}
}).catch(() => null);
if (dbAsset) {
citationsMap.set(dbAsset.id, {
assetId: dbAsset.id,
title: dbAsset.title,
location: 'Inspected Catalog Asset',
type: dbAsset.type,
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED ASSET] Title: "${dbAsset.title}" | Type: "${dbAsset.type}" | Link/URL: "${dbAsset.url}" | Category: "${dbAsset.categoryId || 'General'}" | Subcategory: "${dbAsset.subcategory || '-'}" | Description: "${dbAsset.description || 'N/A'}" | Problem Statement: "${dbAsset.problemStatement || 'N/A'}" | Solution Overview: "${dbAsset.solution || 'N/A'}" | Industry Verticals: "${(dbAsset.verticals || []).map(v => v.name).join(', ')}" | Tech Stack: "${(dbAsset.techStacks || []).map(t => t.name).join(', ')}" | Compliance Standards: "${(dbAsset.complianceStandards || []).map(c => c.name).join(', ')}"\n`
);
} else {
citationsMap.set(ent.id, {
assetId: ent.id,
title: ent.title,
location: 'Inspected Catalog Asset',
type: ent.type || 'ASSET',
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED ASSET] Title: "${ent.title}" | Type: "${ent.type || 'Asset'}" | Description: "${ent.description || 'N/A'}" | Problem Statement: "${ent.problemStatement || 'N/A'}" | Solution Overview: "${ent.solution || 'N/A'}"\n`
);
}
} else if (ent.entityKind === 'SHOWCASE') {
const dbShowcase = await prisma.contentShowcase.findUnique({
where: { id: ent.id }
}).catch(() => null);
if (dbShowcase) {
citationsMap.set(dbShowcase.id, {
assetId: dbShowcase.id,
title: dbShowcase.title,
location: 'Featured Content Showcase',
type: 'case_study',
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED FEATURED REEL] Title: "${dbShowcase.title}" | Video URL: "${dbShowcase.youtubeUrl}" | Description:\n${dbShowcase.description || 'Interactive product reel'}\n`
);
} else {
citationsMap.set(ent.id, {
assetId: ent.id,
title: ent.title,
location: 'Featured Content Showcase',
type: 'case_study',
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED FEATURED REEL] Title: "${ent.title}" | Video URL: "${ent.url || ''}" | Description:\n${ent.description || 'Interactive product reel'}\n`
);
}
} else if (ent.entityKind === 'ECOSYSTEM') {
const dbOffering = await prisma.ecosystemOffering.findUnique({
where: { id: ent.id }
}).catch(() => null);
if (dbOffering) {
citationsMap.set(dbOffering.id, {
assetId: dbOffering.id,
title: dbOffering.name,
location: 'Ecosystem Offering',
type: 'offering',
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED ECOSYSTEM OFFERING] Name: "${dbOffering.name}" | Type: "${dbOffering.type}" | Tagline: "${dbOffering.tagline}" | Website URL: "${dbOffering.websiteUrl}" | Key Benefits: ${((dbOffering.benefits as string[]) || []).join('; ')} | Description:\n${dbOffering.description}\n`
);
} else {
citationsMap.set(ent.id, {
assetId: ent.id,
title: ent.title,
location: 'Ecosystem Offering',
type: 'offering',
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED ECOSYSTEM OFFERING] Name: "${ent.title}" | Description:\n${ent.description || 'Enterprise Ecosystem Offering'}\n`
);
}
} else if (ent.entityKind === 'LEGAL') {
const dbLegal = await prisma.legalDocument.findFirst({
where: { OR: [{ id: ent.id }, { type: ent.title.includes('NDA') ? 'NDA' : 'MSA' }] }
}).catch(() => null);
if (dbLegal) {
citationsMap.set(dbLegal.id, {
assetId: dbLegal.id,
title: ent.title,
location: 'Legal Agreement',
type: 'legal',
isRecommended: false,
});
contextLines.push(
`[WORKBENCH ATTACHED LEGAL AGREEMENT] Type: "${dbLegal.type}" | Version: "${dbLegal.version}" | Content:\n${dbLegal.content}\n`
);
}
}
} catch (err) {
console.error('Failed to parse entity payload in RAG pipeline', err);
}
}
} else {
// ONLY RUN RAG WHEN NO ENTITIES ARE ATTACHED
// A. Match ContentShowcase items (Only on explicit query or exact title match)
const isExplicitShowcaseQuery = promptLower.includes('showcase') || promptLower.includes('video') || promptLower.includes('reel') || promptLower.includes('featured content');
showcaseItems.forEach((sc) => {
const fullText = (sc.title + ' ' + (sc.description || '')).toLowerCase();
const textNorm = normalizeStr(fullText);
let matchCount = 0;
promptTerms.forEach(term => {
const termNorm = normalizeStr(term);
if (termNorm && (fullText.includes(term) || textNorm.includes(termNorm))) {
matchCount++;
}
});
const isExactTitleMatch = promptLower.includes(sc.title.toLowerCase()) || sc.title.toLowerCase().includes(promptLower);
if (isExactTitleMatch || (isExplicitShowcaseQuery && matchCount >= 2)) {
citationsMap.set(sc.id, {
assetId: sc.id,
title: sc.title,
location: 'Featured Content Showcase',
type: 'case_study',
isRecommended: false,
});
contextLines.push(
`[Featured Content Reel] Title: "${sc.title}" | ID: "${sc.id}" | URL: "${sc.youtubeUrl}" | Description:\n${sc.description || 'Interactive product reel'}\n`
);
}
});
// B. Match EcosystemOffering items (Only on explicit query or exact name match)
const isExplicitEcosystemQuery = promptLower.includes('ecosystem') || promptLower.includes('offering') || promptLower.includes('partner product') || promptLower.includes('explore more');
ecosystemOfferings.forEach((eo) => {
const fullText = (eo.name + ' ' + eo.tagline + ' ' + eo.description).toLowerCase();
const textNorm = normalizeStr(fullText);
let matchCount = 0;
promptTerms.forEach(term => {
const termNorm = normalizeStr(term);
if (termNorm && (fullText.includes(term) || textNorm.includes(termNorm))) {
matchCount++;
}
});
const isExactNameMatch = promptLower.includes(eo.name.toLowerCase()) || eo.name.toLowerCase().includes(promptLower);
if (isExactNameMatch || (isExplicitEcosystemQuery && matchCount >= 2)) {
citationsMap.set(eo.id, {
assetId: eo.id,
title: eo.name,
location: 'Ecosystem Offering',
type: 'offering',
isRecommended: false,
});
contextLines.push(
`[Ecosystem Offering] Name: "${eo.name}" | Type: "${eo.type}" | Tagline: "${eo.tagline}" | Website URL: "${eo.websiteUrl}" | Key Benefits: ${((eo.benefits as string[]) || []).join('; ')} | Description:\n${eo.description}\n`
);
}
});
// C. Direct Catalog Asset Title/Description/Taxonomy Search
const catalogAssets = await prisma.asset.findMany({
where: {
id: { in: accessibleAssetIds },
status: 'published',
},
include: {
assetGroups: { select: { name: true } },
verticals: { select: { name: true } },
techStacks: { select: { name: true } },
complianceStandards: { select: { name: true } },
}
});
const stopWords = new Set(['there', 'about', 'where', 'which', 'what', 'have', 'with', 'from', 'this', 'that', 'your', 'portal', 'asset', 'assets', 'product', 'item', 'these', 'those', 'please', 'explain', 'tell']);
const keyTerms = promptTerms.filter(t => !stopWords.has(t));
if (keyTerms.length > 0) {
catalogAssets.forEach(a => {
const fullText = (a.title + ' ' + (a.description || '') + ' ' + (a.tags || []).join(' ') + ' ' + (a.verticals || []).map(v => v.name).join(' ') + ' ' + (a.techStacks || []).map(t => t.name).join(' ')).toLowerCase();
let matchCount = 0;
keyTerms.forEach(kt => {
if (fullText.includes(kt)) matchCount++;
});
if (matchCount >= 1) {
const isRecommended = partnerGroup
? a.assetGroups.some(g => g.name.trim().toLowerCase() === partnerGroup)
: false;
if (!citationsMap.has(a.id)) {
citationsMap.set(a.id, {
assetId: a.id,
title: a.title,
location: 'Catalog Asset Overview',
type: a.type,
isRecommended,
});
contextLines.push(
`[Direct Catalog Asset Match] Title: "${a.title}" | ID: "${a.id}" | Type: "${a.type}" | Description: "${a.description || ''}" | Problem: "${a.problemStatement || ''}" | Solution: "${a.solution || ''}"\n`
);
}
}
});
}
// D. Hybrid Vector Search (Only if key terms present)
const isPureNavPrompt = promptLower.includes('theme') || promptLower.includes('dark mode') || promptLower.includes('light mode') || promptLower.includes('how to change') || promptLower.includes('appearance');
if (!isPureNavPrompt && keyTerms.length > 0) {
const scoredChunks = embeddings.map(emb => {
let vectorScore = 0;
try {
const vec = JSON.parse(emb.vector) as number[];
vectorScore = promptVector.reduce((acc: number, val: number, i: number) => acc + val * (vec[i] || 0), 0);
} catch {
vectorScore = 0;
}
const chunkText = (emb.content + ' ' + emb.asset.title + ' ' + (emb.asset.description || '')).toLowerCase();
let keywordMatches = 0;
keyTerms.forEach(term => {
if (chunkText.includes(term)) keywordMatches += 1;
});
const hybridScore = vectorScore * 0.5 + (keywordMatches / Math.max(keyTerms.length, 1)) * 0.5;
return { chunk: emb, score: hybridScore, keywordMatches };
}).sort((a, b) => b.score - a.score);
const topAssetChunks = scoredChunks.filter(({ score, keywordMatches }) => score >= 0.35 && keywordMatches >= 1).slice(0, 3);
topAssetChunks.forEach(({ chunk }, idx) => {
const meta = (chunk.sourceMetadata as unknown as OKFMetadata) || {
assetId: chunk.assetId,
assetTitle: chunk.asset.title,
assetType: chunk.asset.type,
location: `Segment ${chunk.chunkIndex + 1}`,
};
const isRecommended = partnerGroup
? chunk.asset.assetGroups.some(g => g.name.trim().toLowerCase() === partnerGroup)
: false;
if (!citationsMap.has(chunk.assetId)) {
citationsMap.set(chunk.assetId, {
assetId: chunk.assetId,
title: chunk.asset.title,
location: meta.location,
type: chunk.asset.type,
isRecommended,
});
}
contextLines.push(
`[Catalog Asset ${idx + 1}] Title: "${chunk.asset.title}" | ID: "${chunk.assetId}" | Location: "${meta.location}" | Details:\n${chunk.content}\n`
);
});
}
}
// D. Inject Assigned Legal Documents (NDA / MSA) if prompt asks about NDA / Legal
if (promptLower.includes('nda') || promptLower.includes('msa') || promptLower.includes('agreement') || promptLower.includes('legal') || promptLower.includes('contract')) {
const legals = await prisma.legalDocument.findMany();
legals.forEach((l: { type: string; version: string; content: string }, i: number) => {
contextLines.push(
`[Legal Document ${i + 1}] Type: "${l.type}" | Version: "${l.version}" | Content:\n${l.content.slice(0, 600)}...\n`
);
});
}
const contextBlock = contextLines.length > 0
? contextLines.join('\n---\n')
: 'No specific catalog or showcase snippets needed for this query.';
const portalGuideContext = isClient ? `
Exact Client Portal Layout & Step-by-Step Navigation Guide (${user.email}):
1. Legal Agreements & NDA/MSA Documents:
- Where to find it: Home Dashboard -> Click the "Legal Agreements" card (or navigate directly to /client/agreements).
- Note: There is NO "Legal Agreements" item in the left sidebar menu. Access it exclusively via the Home Dashboard card "Legal Agreements" or direct URL /client/agreements.
- Assigned NDA: ${user.assignedNda ? `Tech4Biz Standard NDA (v${user.assignedNda.version})` : 'Tech4Biz Standard NDA Agreement'} (Status: Active).
- Assigned MSA: ${user.assignedMsa ? `Tech4Biz Standard MSA (v${user.assignedMsa.version})` : 'Tech4Biz Standard MSA Agreement'} (Status: Active).
2. Asset Explorer (/client/assets):
- Where to find it: Left Sidebar menu -> Click "Asset Explorer" (or Home Dashboard -> "Asset Library" card).
- Features: Search shared catalog assets, filter by Taxonomy tags (Industry Verticals, Tech Stacks, Engagement Types, Compliance), view pitch decks, and request downloads.
3. Featured Content Showcase (/client/showcase):
- Where to find it: Left Sidebar menu -> Click "Featured Content".
- Features: Explore interactive video walk-throughs, YouTube/Instagram demo reels, and case studies (including Digital Twin, Diabetic Patient Time Travel, AuRa, Smart Basket, and AI reels).
4. Explore Ecosystem Offerings (/client/ecosystem):
- Where to find it: Left Sidebar menu -> Click "Explore More" (or Home Dashboard -> "Explore More" card).
- Features: Explore ecosystem products & partner integrations (CodeNuk, Cloudtopiaa).
5. Dark/Light Theme & Profile Settings:
- Theme Toggle: Click the Sun/Moon icon located in the bottom area of the left Sidebar under "Appearance".
- Profile Settings: Click "Profile Settings" in the left Sidebar menu to update password or default theme preferences.
` : `
Exact Admin Console Layout Guide for Administrator (${user.email}):
1. Partner Directory (/admin/partners): Left Sidebar -> "Partners".
2. Approvals Queue (/admin/approvals): Left Sidebar -> "Approvals Queue".
3. Legal Templates (/admin/legal): Left Sidebar -> "Legal Templates".
4. Catalog Management (/admin/assets): Left Sidebar -> "Manage Catalog".
5. Ecosystem Manager (/admin/ecosystem): Left Sidebar -> "Ecosystem Manager".
`;
const systemPrompt = `You are Tech4Biz AI Advisor Workbench, the official enterprise assistant for the Channel Partner Portal.
Role & Target Audience:
- User: ${user.email} (${isClient ? 'Client / Partner' : 'Portal Administrator'}).
OUTPUT FORMATTING REQUIREMENTS (CRITICAL FOR QUALITY & SECURITY):
1. **Never Output Database IDs or UUIDs**: Database IDs (UUIDs, hashes, etc.) are strictly confidential internal identifiers. You MUST NEVER output any ID or UUID in your response text to the user.
2. **Strict Guidelines for Suggested Links**:
- **Internal Portal Pages**: If referencing pages inside the portal, you must ONLY use these exact human-accessible relative links:
- Client Portal: \`/client/assets\` (Asset Explorer), \`/client/showcase\` (Featured Content), \`/client/ecosystem\` (Explore More), or \`/client/agreements\` (Legal Agreements).
- Admin Console: \`/admin/assets\` (Manage Catalog), \`/admin/showcase\` (Showcase Manager), \`/admin/ecosystem\` (Ecosystem Manager), or \`/admin/legal\` (Legal Templates).
- **CRITICAL**: Never append database IDs or UUIDs to these paths (e.g., do NOT link to \`/client/assets/123-abc\`). Doing so creates broken pages.
- **External Links**: You may suggest an external resource link (e.g. "[Visit Website](URL)") ONLY if the URL starts with \`http\` or \`https\` and is a public web link (not containing \`localhost\`, \`127.0.0.1\`, or \`/uploads/\`).
- **Fallback**: If no valid link exists, instruct the user to view it via the citation cards below the chat bubble using the **Quick Preview** button or locate it in the catalog.
3. **Multi-Turn Context Awareness**: Maintain full conversational memory. When asked for follow-ups or comparisons of previously mentioned assets, resolve references accurately.
4. **Never Output Concatenated Single-Line Tables**: EVERY Markdown table row MUST be separated by a real newline character (\\n). Never concatenate table rows like "| Col A | Col B | | :--- | :--- |".
5. **Structure Sections Clearly**: Use bold section titles (e.g. "### Summary", "### Key Differences", "### Features & Specifications").
6. **Use Bullet Points for Readability**: When detailing lists of features, target users, or tech stacks, use bulleted lists instead of long unformatted paragraphs.
${hasAttachedEntities ? `
CRITICAL RULES FOR WORKBENCH ATTACHED ENTITIES:
- THE USER HAS EXCLUSIVELY ATTACHED SPECIFIC ENTITIES TO INSPECT: ${attachedEntities?.map(e => `"${e.title}"`).join(', ')}.
- You MUST answer ONLY about the attached items listed under [WORKBENCH ATTACHED ASSET], [WORKBENCH ATTACHED FEATURED REEL], [WORKBENCH ATTACHED ECOSYSTEM OFFERING], or [WORKBENCH ATTACHED LEGAL AGREEMENT].
- Provide a clear, high-impact summary of what these attached items are, their core problem/solution, tech stack, and key features.
- Do NOT list, summarize, or invent any unattached showcase reels (like Digital Twin, Smart Basket, etc.) or unrelated catalog assets! Focus 100% EXCLUSIVELY on the attached items.
` : `
CRITICAL GROUNDING RULES (ZERO HALLUCINATION & STRICT KNOWLEDGE COMPLIANCE):
1. **Rely ONLY on Provided Knowledge Context**: Answer user questions 100% EXCLUSIVELY using the provided Knowledge Context. Do NOT use outside general knowledge or make ungrounded assumptions.
2. **No Invented Demos or Non-Working Links**: If the context does not explicitly list a live demo URL or document file link for an offering (such as CodeNuk, Cloudtopiaa, or Audittrax Labs), state clearly: "You can explore this offering under the 'Explore More' section (/client/ecosystem) or visit their website at [Website URL]." Do NOT invent dummy demo links, raw file paths, or non-working URLs.
3. **If Information is Missing**: If the provided Knowledge Context does not contain the answer, explicitly state: "I don't have detailed information on that in the portal database. Please check the Asset Explorer or contact your account administrator."
`}
Portal Navigation Guidelines:
- STIPULATION: NEVER tell a Client user to look for "Legal Agreements" in the left sidebar. State clearly: "Click the 'Legal Agreements' card on your Home Dashboard or go to /client/agreements".
- STIPULATION: NEVER mention admin console routes (/admin, Partner Directory, Legal Templates) when talking to a Client user.`;
// 0. Fetch Past Conversation History for Session Context & Follow-up Resolution
let pastMessages: { role: string; content: string }[] = [];
let activeSessionId = sessionId;
if (!activeSessionId) {
const session = await prisma.chatSession.create({
data: { userId }
});
activeSessionId = session.id;
} else {
const dbPast = await prisma.chatMessage.findMany({
where: { sessionId: activeSessionId },
orderBy: { createdAt: 'asc' },
take: 12,
});
pastMessages = dbPast.map(m => ({
role: m.sender === 'USER' ? 'user' : 'assistant',
content: m.content
}));
// Extract citations / asset references from recent ASSISTANT messages to handle follow-up queries like "provide more details on this asset"
const recentBotMsgs = dbPast.filter(m => m.sender === 'ASSISTANT' && m.citations);
for (const botMsg of recentBotMsgs) {
const cites = (botMsg.citations as unknown as CitationItem[]) || [];
for (const cite of cites) {
if (cite.assetId && !citationsMap.has(cite.assetId)) {
const dbAsset = await prisma.asset.findUnique({
where: { id: cite.assetId },
include: {
verticals: true,
techStacks: true,
complianceStandards: true,
}
});
if (dbAsset) {
citationsMap.set(dbAsset.id, {
assetId: dbAsset.id,
title: dbAsset.title,
location: 'Previously Discussed Asset',
type: dbAsset.type,
isRecommended: false,
});
contextLines.push(
`[PREVIOUSLY DISCUSSED ASSET IN CONVERSATION] Title: "${dbAsset.title}" | Type: "${dbAsset.type}" | Link/URL: "${dbAsset.url}" | Category: "${dbAsset.categoryId || 'General'}" | Description: "${dbAsset.description || 'N/A'}" | Problem Statement: "${dbAsset.problemStatement || 'N/A'}" | Solution Overview: "${dbAsset.solution || 'N/A'}" | Industry Verticals: "${(dbAsset.verticals || []).map(v => v.name).join(', ')}" | Tech Stack: "${(dbAsset.techStacks || []).map(t => t.name).join(', ')}"\n`
);
}
}
}
}
}
// Save current User prompt first to maintain chronological integrity
await prisma.chatMessage.create({
data: {
sessionId: activeSessionId,
sender: 'USER',
content: prompt,
}
});
const messages = [
{ role: 'system', content: systemPrompt },
...pastMessages,
{ role: 'user', content: `Platform Navigation Guide:\n${portalGuideContext}\n\nKnowledge Context:\n${contextBlock}\n\nUser Question: ${prompt}` }
];
// 6. Call DeepSeek LLM Gateway API
let assistantReply = '';
try {
const response = await axios.post(`${this.gatewayUrl}/chat/completions`, {
model: this.modelId,
messages,
temperature: 0.2,
}, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
}
});
assistantReply = response.data.choices[0]?.message?.content || 'No response generated from AI Engine.';
} catch (err: any) {
console.error('DeepSeek Gateway Error:', err?.response?.data || err.message);
assistantReply = 'Here is the requested information from your shared catalog:\n\n' + contextBlock;
}
// Strict Citation Filtering: Only include Verified Knowledge Source citations if user explicitly attached entities or asked for asset/resource recommendations
const isGeneralHelpQuery = promptLower.includes('password') ||
promptLower.includes('theme') ||
promptLower.includes('appearance') ||
promptLower.includes('profile') ||
promptLower.includes('how to log') ||
promptLower.includes('how to sign') ||
promptLower.includes('help');
const hasAssetKeywords = promptLower.includes('asset') ||
promptLower.includes('recommend') ||
promptLower.includes('find') ||
promptLower.includes('search') ||
promptLower.includes('show') ||
promptLower.includes('list') ||
promptLower.includes('what are') ||
promptLower.includes('is there') ||
promptLower.includes('are there') ||
promptLower.includes('showcase') ||
promptLower.includes('agreement') ||
promptLower.includes('legal') ||
promptLower.includes('document') ||
promptLower.includes('video') ||
promptLower.includes('reel') ||
promptLower.includes('offering') ||
promptLower.includes('which are') ||
promptLower.includes('tell me more');
const finalCitations = (hasAttachedEntities || (hasAssetKeywords && !isGeneralHelpQuery))
? Array.from(citationsMap.values())
: [];
const botMessage = await prisma.chatMessage.create({
data: {
sessionId: activeSessionId,
sender: 'ASSISTANT',
content: assistantReply,
citations: finalCitations as any,
}
});
return {
sessionId: activeSessionId,
message: botMessage,
citations: finalCitations,
};
}
/**
* Auto-indexes active catalog assets into AssetEmbedding for instant knowledge retrieval.
*/
public async autoIndexCatalog(assetIds: string[]) {
const assets = await prisma.asset.findMany({
where: {
id: { in: assetIds },
includeInKnowledgeBase: true,
}
});
for (const asset of assets) {
await this.ingestAssetKnowledge(asset.id);
}
}
/**
* Ingest or re-index an asset's text into vector embeddings.
*/
public async ingestAssetKnowledge(assetId: string) {
const asset = await prisma.asset.findUnique({
where: { id: assetId }
});
if (!asset) return;
await prisma.assetEmbedding.deleteMany({
where: { assetId }
});
if (!asset.includeInKnowledgeBase) return;
const okfChunks = await this.extractionService.extractOKFChunks(asset);
for (const chunk of okfChunks) {
const vector = this.extractionService.generateEmbedding(chunk.content);
await prisma.assetEmbedding.create({
data: {
assetId,
chunkIndex: chunk.chunkIndex,
chunkType: chunk.chunkType,
sourceMetadata: chunk.sourceMetadata as any,
content: chunk.content,
vector,
}
});
}
}
}

View File

@ -1,313 +0,0 @@
import fs from 'fs';
import path from 'path';
const _pdfParse = require('pdf-parse');
const pdfParse = _pdfParse.PDFParse || _pdfParse.default || _pdfParse;
import mammoth from 'mammoth';
import * as XLSX from 'xlsx';
import * as cheerio from 'cheerio';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { s3Client, BUCKET_NAME } from '../utils/s3';
import { ScraperService } from './scraper.service';
export interface OKFMetadata {
assetId: string;
assetTitle: string;
assetType: string;
location: string; // e.g. "Page 3", "Slide 5", "Sheet: Financials", "Transcript", "Section: Architecture"
okfCategory?: string;
isRecommended?: boolean;
}
export interface OKFChunk {
chunkIndex: number;
chunkType: 'PAGE' | 'SLIDE' | 'SHEET' | 'TRANSCRIPT' | 'TEXT';
content: string;
sourceMetadata: OKFMetadata;
}
export class ExtractionService {
private scraperService = new ScraperService();
/**
* Simple, fast deterministic embedding generator for local RAG vector search.
* Generates a 64-dimensional float vector normalized for cosine similarity.
*/
public generateEmbedding(text: string): string {
const dim = 64;
const vector = new Array(dim).fill(0);
const cleaned = text.toLowerCase().replace(/[^\w\s]/g, '');
const words = cleaned.split(/\s+/).filter(Boolean);
for (let i = 0; i < words.length; i++) {
const word = words[i];
for (let j = 0; j < word.length; j++) {
const charCode = word.charCodeAt(j);
const idx = (charCode + j * 7 + i * 3) % dim;
vector[idx] += 1;
}
}
// L2 Normalize
const magnitude = Math.sqrt(vector.reduce((sum, val) => sum + val * val, 0)) || 1;
const normalized = vector.map(v => Number((v / magnitude).toFixed(6)));
return JSON.stringify(normalized);
}
/**
* Extract text chunks from an asset binary or URL using the Open Knowledge Framework (OKF) standard format.
*/
public async extractOKFChunks(asset: {
id: string;
title: string;
type: string;
url: string;
problemStatement?: string | null;
solution?: string | null;
description?: string | null;
}): Promise<OKFChunk[]> {
const chunks: OKFChunk[] = [];
let chunkIndex = 0;
// Base Primary Metadata Chunk (Guarantees 100% indexing for ALL assets including URLs & Documents)
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: `Asset Title: "${asset.title}". Type: "${asset.type}". Description: "${asset.description || ''}". Problem: "${asset.problemStatement || ''}". Solution: "${asset.solution || ''}".`,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: 'Catalog Overview & Metadata',
},
});
// 1. Ingest Problem Statement & Solution metadata if present
if (asset.problemStatement) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: `Problem Statement for ${asset.title}: ${asset.problemStatement}`,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: 'Executive Overview: Problem Statement',
},
});
}
if (asset.solution) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: `Solution Overview for ${asset.title}: ${asset.solution}`,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: 'Executive Overview: Proposed Solution',
},
});
}
// 2. URL Assets / YouTube / Web scraper handling
if (asset.type === 'url' || asset.type === 'case_study' || asset.url.startsWith('http')) {
try {
const scraped = await this.scraperService.scrapeCaseStudy(asset.url);
const fullContent = `${scraped.title}. ${scraped.problemStatement || ''} ${scraped.solution || ''}`;
// Split into 500-token chunks
const subChunks = this.splitText(fullContent, 500);
subChunks.forEach((text, i) => {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TRANSCRIPT',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `Web Link Content: Part ${i + 1}`,
},
});
});
} catch {
if (asset.description) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: `${asset.title}: ${asset.description}`,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: 'URL Asset Metadata',
},
});
}
}
return chunks;
}
// 3. Binary S3 Assets (PDF, Word, Excel, PPTX, Text)
if (asset.url.startsWith('/uploads/')) {
const fileKey = asset.url.replace('/uploads/', '');
let buffer: Buffer;
try {
const response = await s3Client.send(new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: fileKey,
}));
const byteArray = await response.Body?.transformToByteArray();
if (!byteArray) return chunks;
buffer = Buffer.from(byteArray);
} catch (err) {
console.error(`Failed to fetch S3 object ${fileKey} for extraction:`, err);
return chunks;
}
const ext = path.extname(fileKey).toLowerCase();
// A. PDF Files
if (ext === '.pdf' || asset.type.includes('pdf')) {
try {
let pdfText = '';
try {
const parser = new pdfParse({ data: buffer });
const res = await parser.getText();
pdfText = typeof res === 'string' ? res : res?.text || '';
} catch (e1) {
try {
const res = await pdfParse(buffer);
pdfText = typeof res === 'string' ? res : res?.text || '';
} catch (e2) {}
}
if (pdfText) {
const subChunks = this.splitText(pdfText, 600);
subChunks.forEach((text, i) => {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'PAGE',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `PDF Document: Page ${i + 1}`,
},
});
});
}
} catch (e) { console.error('PDF extraction error:', e); }
}
// B. Word Files (.docx, .doc)
else if (ext === '.docx' || ext === '.doc' || asset.type.includes('word')) {
try {
const docResult = await mammoth.extractRawText({ buffer });
const subChunks = this.splitText(docResult.value, 600);
subChunks.forEach((text, i) => {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `Word Document: Section ${i + 1}`,
},
});
});
} catch (e) { console.error('Docx extraction error:', e); }
}
// C. Excel & CSV Files (.xlsx, .xls, .csv)
else if (ext === '.xlsx' || ext === '.xls' || ext === '.csv' || asset.type.includes('spreadsheet') || asset.type.includes('csv')) {
try {
const workbook = XLSX.read(buffer, { type: 'buffer' });
workbook.SheetNames.forEach((sheetName) => {
const sheet = workbook.Sheets[sheetName];
const csvText = XLSX.utils.sheet_to_csv(sheet);
if (csvText && csvText.trim()) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'SHEET',
content: `Sheet [${sheetName}] Data:\n${csvText.slice(0, 1500)}`,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `Spreadsheet Sheet: ${sheetName}`,
},
});
}
});
} catch (e) { console.error('Excel extraction error:', e); }
}
// D. PowerPoint Presentations (.pptx, .ppt)
else if (ext === '.pptx' || ext === '.ppt' || asset.type.includes('presentation')) {
// Simple text extraction from raw slide XML/strings
const rawString = buffer.toString('utf-8').replace(/[^\x20-\x7E]/g, ' ');
const subChunks = this.splitText(rawString, 600);
subChunks.forEach((text, i) => {
if (text.length > 50) {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'SLIDE',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `PowerPoint Presentation: Slide Section ${i + 1}`,
},
});
}
});
}
// E. Plain Text / Markdown / Code (Excluding binaries)
else if (!ext.match(/\.(png|jpe?g|gif|webp|svg|mp4|webm|avi|mp3|wav)$/i)) {
const textContent = buffer.toString('utf-8');
// Only proceed if it looks like actual text (not arbitrary binary data)
if (!textContent.includes('\u0000\u0000')) {
const subChunks = this.splitText(textContent, 600);
subChunks.forEach((text, i) => {
chunks.push({
chunkIndex: chunkIndex++,
chunkType: 'TEXT',
content: text,
sourceMetadata: {
assetId: asset.id,
assetTitle: asset.title,
assetType: asset.type,
location: `Document Text: Segment ${i + 1}`,
},
});
});
}
}
}
return chunks;
}
private splitText(text: string, maxLen: number): string[] {
const cleaned = text.replace(/\s+/g, ' ').trim();
if (!cleaned) return [];
const words = cleaned.split(' ');
const chunks: string[] = [];
let current = '';
for (const word of words) {
if ((current + ' ' + word).length > maxLen) {
if (current) chunks.push(current.trim());
current = word;
} else {
current += (current ? ' ' : '') + word;
}
}
if (current.trim()) chunks.push(current.trim());
return chunks;
}
}

View File

@ -21,25 +21,7 @@ export class LegalService {
}); });
} }
public async getActiveDocument(type: DocumentType, userId?: string) { public async getActiveDocument(type: DocumentType) {
if (userId) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { assignedNdaId: true, assignedMsaId: true }
});
if (user) {
const assignedId = type === 'NDA' ? user.assignedNdaId : user.assignedMsaId;
if (assignedId) {
const doc = await prisma.legalDocument.findUnique({
where: { id: assignedId }
});
if (doc) return doc;
} else {
return null;
}
}
}
return await prisma.legalDocument.findFirst({ return await prisma.legalDocument.findFirst({
where: { type, isActive: true }, where: { type, isActive: true },
}); });
@ -51,32 +33,27 @@ export class LegalService {
ipAddress: string, ipAddress: string,
signatureHash?: string, signatureHash?: string,
documentUrl?: string, documentUrl?: string,
signatureBase64?: string,
) { ) {
// Record acceptance // Record acceptance
const acceptance = await prisma.legalAcceptance.create({ const acceptance = await prisma.legalAcceptance.create({
data: { docId, userId, ipAddress, signatureHash, documentUrl, signatureBase64 }, data: { docId, userId, ipAddress, signatureHash, documentUrl },
}); });
return acceptance; return acceptance;
} }
public async checkOnboardingCompletion(userId: string) { public async checkOnboardingCompletion(userId: string) {
const user = await prisma.user.findUnique({ // Check if both NDA and MSA have been accepted
where: { id: userId },
select: { assignedNdaId: true, assignedMsaId: true }
});
const acceptances = await prisma.legalAcceptance.findMany({ const acceptances = await prisma.legalAcceptance.findMany({
where: { userId }, where: { userId },
include: { document: true } include: { document: true }
}); });
const hasNDA = !user?.assignedNdaId || acceptances.some(a => a.document.type === 'NDA'); const hasNDA = acceptances.some(a => a.document.type === 'NDA');
const hasMSA = !user?.assignedMsaId || acceptances.some(a => a.document.type === 'MSA'); const hasMSA = acceptances.some(a => a.document.type === 'MSA');
if (hasNDA && hasMSA) { if (hasNDA && hasMSA) {
// Both signed or skipped, ready for admin approval. We don't automatically set to APPROVED. // Both signed, ready for admin approval. We don't automatically set to APPROVED.
// But we can ensure it's PENDING_APPROVAL. // But we can ensure it's PENDING_APPROVAL.
await prisma.user.update({ await prisma.user.update({
where: { id: userId }, where: { id: userId },
@ -90,15 +67,7 @@ export class LegalService {
public async getAcceptances(userId: string) { public async getAcceptances(userId: string) {
return await prisma.legalAcceptance.findMany({ return await prisma.legalAcceptance.findMany({
where: { userId }, where: { userId },
select: { include: { document: true },
id: true,
signatureHash: true,
documentUrl: true,
signatureBase64: true,
ipAddress: true,
acceptedAt: true,
document: true,
},
}); });
} }
@ -109,29 +78,13 @@ export class LegalService {
id: true, id: true,
email: true, email: true,
createdAt: true, createdAt: true,
assignedNdaId: true,
assignedMsaId: true,
acceptances: { acceptances: {
select: { include: { document: true }
id: true,
signatureHash: true,
documentUrl: true,
signatureBase64: true,
ipAddress: true,
acceptedAt: true,
document: true,
}
} }
} }
}); });
} }
public async getAllDocuments() {
return await prisma.legalDocument.findMany({
orderBy: { createdAt: 'desc' }
});
}
public async approvePartner(userId: string) { public async approvePartner(userId: string) {
return await prisma.user.update({ return await prisma.user.update({
where: { id: userId }, where: { id: userId },

View File

@ -1,106 +0,0 @@
import nodemailer from 'nodemailer';
import { originStorage } from '../utils/origin-storage';
export class MailService {
private transporter: nodemailer.Transporter;
constructor() {
const host = process.env.SMTP_HOST || 'smtp.mailtrap.io';
const port = parseInt(process.env.SMTP_PORT || '2525', 10);
const user = process.env.SMTP_USER || '';
const pass = process.env.SMTP_PASS || '';
this.transporter = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: user && pass ? { user, pass } : undefined,
tls: {
rejectUnauthorized: false,
},
});
}
public async sendInviteEmail(email: string, inviteToken: string) {
const origin = originStorage.getStore() || process.env.CLIENT_ORIGIN || 'http://localhost:5173';
const inviteUrl = `${origin}/invite?token=${inviteToken}`;
const isRealEmailAllowed = process.env.ENABLE_REAL_EMAILS === 'true';
if (!isRealEmailAllowed) {
console.log(`[DEV EMAIL SAFEGUARD] Blocked real email dispatch to real user/client: ${email}`);
console.log(`[DEV EMAIL SAFEGUARD] Mock Invite URL: ${inviteUrl}`);
return { messageId: 'mock-dev-safeguard-id' };
}
const mailOptions = {
from: process.env.SMTP_FROM || (process.env.SMTP_USER ? `"Tech4Biz Portal" <${process.env.SMTP_USER}>` : '"Tech4Biz Portal" <noreply@tech4biz.com>'),
to: email,
subject: 'Tech4Biz Partner Portal Invitation',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 30px; border: 1px solid #e2e8f0; border-radius: 12px; background-color: #ffffff;">
<div style="text-align: left; margin-bottom: 24px;">
<h2 style="color: #1e293b; margin: 0; font-size: 20px; font-weight: bold;">Tech4Biz Partner Portal</h2>
<p style="color: #64748b; font-size: 13px; margin: 4px 0 0 0;">Authorized Collaborative Access Gateway</p>
</div>
<div style="border-top: 1px solid #e2e8f0; padding-top: 24px; margin-top: 16px;">
<p style="font-size: 14px; color: #334155; line-height: 1.6; margin: 0 0 16px 0;">Hello,</p>
<p style="font-size: 14px; color: #334155; line-height: 1.6; margin: 0 0 16px 0;">You have been invited to register for the <strong>Tech4Biz Partner Portal</strong> as a collaborative member. This portal allows your organization to view shared resources, asset files, and manage onboarding documentation.</p>
<p style="font-size: 14px; color: #334155; line-height: 1.6; margin: 0 0 24px 0;">To set up your account and access the platform, please proceed to the link below to configure your credentials and review the partner documentation:</p>
<div style="text-align: left; margin: 24px 0;">
<a href="${inviteUrl}" style="background-color: #0f172a; color: #ffffff; padding: 12px 24px; font-size: 14px; font-weight: bold; text-decoration: none; border-radius: 6px; display: inline-block;">Access Partner Portal</a>
</div>
<p style="font-size: 12px; color: #64748b; line-height: 1.6; margin: 24px 0 8px 0;">If you are unable to click the button above, please copy and paste the URL below into your web browser:</p>
<p style="font-size: 12px; color: #334155; font-family: monospace; background-color: #f8fafc; border: 1px solid #e2e8f0; padding: 12px; border-radius: 6px; word-break: break-all; margin: 0 0 24px 0;">${inviteUrl}</p>
</div>
<div style="border-top: 1px solid #e2e8f0; padding-top: 20px; margin-top: 30px; text-align: center;">
<p style="font-size: 11px; color: #94a3b8; margin: 0;">© 2026 Tech4Biz Solutions Inc. All rights reserved.</p>
</div>
</div>
`
};
try {
const info = await this.transporter.sendMail(mailOptions);
console.log(`[SMTP] Invitation email successfully sent to ${email}. Message ID: ${info.messageId}`);
return info;
} catch (err) {
console.error(`[SMTP ERROR] Failed to send invitation email to ${email}:`, err);
throw err;
}
}
public async sendCustomAnnouncement(options: { recipients: string[]; subject: string; messageBody: string }) {
if (!options.recipients || options.recipients.length === 0) return;
const isRealEmailAllowed = process.env.ENABLE_REAL_EMAILS === 'true';
if (!isRealEmailAllowed) {
console.log(`[DEV EMAIL SAFEGUARD] Blocked announcement email to ${options.recipients.length} recipients (Development mode safety guard). Subject: "${options.subject}"`);
return;
}
const mailOptions = {
from: process.env.SMTP_FROM || '"Tech4Biz Portal" <noreply@tech4biz.com>',
to: options.recipients.join(', '),
subject: options.subject,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; border: 1px solid #e2e8f0; border-radius: 8px; background-color: #ffffff;">
<h2 style="color: #0f172a; margin-top: 0;">${options.subject}</h2>
<div style="font-size: 14px; color: #334155; line-height: 1.6; white-space: pre-wrap; background: #f8fafc; padding: 16px; border-radius: 6px; border: 1px solid #e2e8f0;">${options.messageBody}</div>
<p style="font-size: 12px; color: #64748b; margin-top: 24px;">Sent via Tech4Biz Channel Partner Platform</p>
</div>
`
};
try {
await this.transporter.sendMail(mailOptions);
console.log(`[SMTP] Announcement email sent to ${options.recipients.length} recipients.`);
} catch (err) {
console.error('[SMTP ERROR] Failed to send announcement email:', err);
}
}
}

View File

@ -1,229 +0,0 @@
import * as cheerio from 'cheerio';
interface ScrapeResult {
title: string;
thumbnailUrl: string;
problemStatement: string;
solution: string;
}
interface PageElement {
type: 'heading' | 'content';
tagName: string;
text: string;
problemScore: number;
solutionScore: number;
}
export class ScraperService {
private problemKeywords: Record<string, number> = {
'problem statement': 1.0,
'challenges': 0.9,
'challenge': 0.9,
'problem': 0.9,
'pain point': 0.9,
'issue': 0.8,
'background': 0.6,
'context': 0.5,
'objective': 0.6,
};
private solutionKeywords: Record<string, number> = {
'suggested solution': 1.0,
'solution': 0.9,
'approach': 0.8,
'implementation': 0.7,
'architecture': 0.8,
'technology': 0.6,
};
private stopKeywords: string[] = [
'result',
'conclusion',
'roi',
'future',
'roadmap',
'benefit',
'collaboration',
'support'
];
private getCategoryScore(text: string, keywords: Record<string, number>): number {
const lowerText = text.toLowerCase();
let maxScore = 0;
for (const [kw, score] of Object.entries(keywords)) {
if (lowerText.includes(kw)) {
maxScore = Math.max(maxScore, score);
}
}
return maxScore;
}
public async scrapeCaseStudy(url: string): Promise<ScrapeResult> {
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch page. Status: ${response.status}`);
}
const html = await response.text();
const $ = cheerio.load(html);
// Extract Title
let title = $('meta[property="og:title"]').attr('content') ||
$('meta[name="twitter:title"]').attr('content') ||
$('title').text() ||
$('h1').first().text() ||
'';
title = title.replace(/\s*[-|]\s*Tech4Biz.*$/i, '').trim();
// Extract Banner Image/Thumbnail
const thumbnailUrl = $('meta[property="og:image"]').attr('content') ||
$('meta[name="twitter:image"]').attr('content') ||
'';
const elements = $('h1, h2, h3, h4, h5, h6, p, ul, ol');
const pageElements: PageElement[] = [];
elements.each((_, el) => {
const $el = $(el);
const tagName = el.name.toLowerCase();
const text = $el.text().trim();
if (!text) return;
// Skip copyright and common footer elements
if (text.toLowerCase().includes('copyright reserved')) return;
if (tagName.startsWith('h') && text.length < 65) {
const problemScore = this.getCategoryScore(text, this.problemKeywords);
const solutionScore = this.getCategoryScore(text, this.solutionKeywords);
pageElements.push({
type: 'heading',
tagName,
text,
problemScore,
solutionScore
});
} else {
let formattedText = '';
if (tagName === 'ul' || tagName === 'ol') {
const listItems: string[] = [];
$el.find('li').each((_, li) => {
const liText = $(li).text().trim();
if (liText) {
listItems.push(`${liText}`);
}
});
if (listItems.length > 0) {
formattedText = listItems.join('\n');
}
} else {
if (text.length > 10) {
formattedText = text;
}
}
if (formattedText) {
pageElements.push({
type: 'content',
tagName,
text: formattedText,
problemScore: 0,
solutionScore: 0
});
}
}
});
// Find the best indices
let bestProblemIdx = -1;
let maxProblemScore = 0.4;
let bestSolutionIdx = -1;
let maxSolutionScore = 0.4;
pageElements.forEach((el, idx) => {
if (el.type === 'heading') {
if (el.problemScore > maxProblemScore) {
maxProblemScore = el.problemScore;
bestProblemIdx = idx;
}
if (el.solutionScore > maxSolutionScore) {
maxSolutionScore = el.solutionScore;
bestSolutionIdx = idx;
}
}
});
// Fallback searches
if (bestProblemIdx === -1) {
bestProblemIdx = pageElements.findIndex(el =>
el.type === 'heading' &&
(el.text.toLowerCase().includes('background') || el.text.toLowerCase().includes('overview'))
);
}
if (bestSolutionIdx === -1) {
bestSolutionIdx = pageElements.findIndex(el =>
el.type === 'heading' &&
(el.text.toLowerCase().includes('implementation') || el.text.toLowerCase().includes('results') || el.text.toLowerCase().includes('benefits') || el.text.toLowerCase().includes('components'))
);
}
const gatherContent = (startIdx: number, otherIdx: number, isProblem: boolean): string => {
if (startIdx === -1) return '';
const collected: string[] = [];
let contentCount = 0;
for (let i = startIdx + 1; i < pageElements.length; i++) {
if (i === otherIdx) break;
const el = pageElements[i];
if (el.type === 'heading') {
const lowerText = el.text.toLowerCase();
// Stop if we hit the other major category heading
if (isProblem && el.solutionScore >= 0.8) break;
if (!isProblem && el.problemScore >= 0.8) break;
// Stop if we hit a stop keyword
const matchesStop = this.stopKeywords.some(kw => lowerText.includes(kw));
if (matchesStop) break;
// Include subheading formatting
collected.push(`**${el.text}**`);
} else {
// Simple de-duplication for consecutive identical elements
if (collected[collected.length - 1] !== el.text) {
collected.push(el.text);
contentCount++;
}
if (contentCount >= 5) break;
}
}
return collected.join('\n\n');
};
const problemStatement = gatherContent(bestProblemIdx, bestSolutionIdx, true);
const solution = gatherContent(bestSolutionIdx, bestProblemIdx, false);
return {
title,
thumbnailUrl,
problemStatement,
solution
};
} catch (error: any) {
console.error('[SCRAPE ERROR] Failed to scrape case study:', error.message);
throw new Error(`Scraping failed: ${error.message}`);
}
}
}

View File

@ -1,3 +0,0 @@
import { AsyncLocalStorage } from 'async_hooks';
export const originStorage = new AsyncLocalStorage<string>();

View File

@ -1,212 +0,0 @@
import dotenv from 'dotenv';
dotenv.config();
import prisma from './db';
export async function seedFourGroupTaxonomy() {
console.log('[Taxonomy-Seed] Starting taxonomy re-seeding with exact user specifications...');
await prisma.vertical.deleteMany();
await prisma.techStack.deleteMany();
await prisma.engagementType.deleteMany();
await prisma.complianceStandard.deleteMany();
console.log('[Taxonomy-Seed] Cleared existing taxonomy tables.');
// 1. Group 1: Industry Verticals (11 Entries)
const verticalsData = [
{ name: 'Cybersecurity & OT Security', slug: 'cybersecurity-ot-security', icon: 'Shield', description: 'ICS/SCADA protection, threat detection, and OT security architecture.', color: '#ef4444', orderIndex: 1 },
{ name: 'Healthcare & Pharma', slug: 'healthcare-pharma', icon: 'Heart', description: 'Patient monitoring, clinical workflows, and pharma tech.', color: '#10b981', orderIndex: 2 },
{ name: 'Finance & Banking', slug: 'finance-banking', icon: 'CreditCard', description: 'Core banking systems, fraud detection, and fintech platforms.', color: '#3b82f6', orderIndex: 3 },
{ name: 'Insurance', slug: 'insurance', icon: 'ShieldCheck', description: 'InsurTech systems, claim automation, and actuarial analytics.', color: '#0284c7', orderIndex: 4 },
{ name: 'Energy & Utilities', slug: 'energy-utilities', icon: 'Zap', description: 'Grid monitoring, renewable management, and infrastructure tech.', color: '#06b6d4', orderIndex: 5 },
{ name: 'Agriculture', slug: 'agriculture', icon: 'Sprout', description: 'AgriTech telemetry, precision farming, and supply analytics.', color: '#84cc16', orderIndex: 6 },
{ name: 'Education', slug: 'education', icon: 'GraduationCap', description: 'EdTech platforms, AI tutoring, and campus management.', color: '#f59e0b', orderIndex: 7 },
{ name: 'Manufacturing & IoT', slug: 'manufacturing-iot', icon: 'Cpu', description: 'Predictive maintenance, IIoT telemetry, and smart factory tech.', color: '#8b5cf6', orderIndex: 8 },
{ name: 'Automotive', slug: 'automotive', icon: 'Car', description: 'Connected vehicles, EV telemetry, and autonomous systems.', color: '#ec4899', orderIndex: 9 },
{ name: 'Retail & Supply Chain', slug: 'retail-supply-chain', icon: 'ShoppingBag', description: 'Smart basket automation, inventory AI, and logistics.', color: '#f97316', orderIndex: 10 },
{ name: 'Blockchain', slug: 'blockchain', icon: 'Link', description: 'Distributed ledgers, smart contracts, and Web3 security.', color: '#6366f1', orderIndex: 11 },
];
const createdVerticals: Record<string, string> = {};
for (const item of verticalsData) {
const v = await prisma.vertical.create({ data: item });
createdVerticals[item.name] = v.id;
}
// 2. Group 2: Technology Stack (21 Entries across 4 Categories)
const techStacksData = [
// Languages/Frameworks
{ name: 'Java / Spring Boot', slug: 'java-spring-boot', category: 'Languages & Frameworks', icon: 'Code', description: 'Enterprise backend services and Spring ecosystem.', color: '#3b82f6', orderIndex: 1 },
{ name: 'Node.js', slug: 'nodejs', category: 'Languages & Frameworks', icon: 'Server', description: 'Event-driven JavaScript/TypeScript backend runtimes.', color: '#10b981', orderIndex: 2 },
{ name: 'Python', slug: 'python', category: 'Languages & Frameworks', icon: 'FileCode', description: 'Data science, AI models, and microservices.', color: '#f59e0b', orderIndex: 3 },
{ name: 'React', slug: 'react', category: 'Languages & Frameworks', icon: 'Layout', description: 'Modern web component UIs and frontend state management.', color: '#06b6d4', orderIndex: 4 },
{ name: 'Go', slug: 'golang', category: 'Languages & Frameworks', icon: 'Cpu', description: 'High-performance cloud-native microservices.', color: '#0284c7', orderIndex: 5 },
{ name: '.NET', slug: 'dotnet', category: 'Languages & Frameworks', icon: 'Layers', description: 'C# enterprise applications and Microsoft ecosystem.', color: '#6366f1', orderIndex: 6 },
// AI/ML
{ name: 'AI & ML', slug: 'ai-ml', category: 'AI & ML', icon: 'Sparkles', description: 'Core artificial intelligence and machine learning models.', color: '#8b5cf6', orderIndex: 7 },
{ name: 'LLM / Agentic', slug: 'llm-agentic', category: 'AI & ML', icon: 'Bot', description: 'Large language models, multi-agent frameworks, and reasoning engines.', color: '#a855f7', orderIndex: 8 },
{ name: 'RAG', slug: 'rag', category: 'AI & ML', icon: 'Database', description: 'Retrieval-Augmented Generation and vector search systems.', color: '#ec4899', orderIndex: 9 },
{ name: 'Computer Vision', slug: 'computer-vision', category: 'AI & ML', icon: 'Camera', description: 'Real-time video analytics and optical recognition.', color: '#f43f5e', orderIndex: 10 },
{ name: 'ML Pipelines', slug: 'ml-pipelines', category: 'AI & ML', icon: 'GitBranch', description: 'MLOps, model retraining, and feature stores.', color: '#d946ef', orderIndex: 11 },
{ name: 'Deepfake / Detection', slug: 'deepfake-detection', category: 'AI & ML', icon: 'Eye', description: 'Synthetic media verification and anti-spoofing.', color: '#ef4444', orderIndex: 12 },
// Data/Backend
{ name: 'PostgreSQL', slug: 'postgresql', category: 'Data & Backend', icon: 'Database', description: 'Relational database with JSONB and vector capabilities.', color: '#3b82f6', orderIndex: 13 },
{ name: 'Temporal', slug: 'temporal', category: 'Data & Backend', icon: 'Clock', description: 'Durable workflow execution and saga orchestrations.', color: '#10b981', orderIndex: 14 },
{ name: 'Kafka', slug: 'kafka', category: 'Data & Backend', icon: 'Activity', description: 'Distributed event streaming and message pub/sub.', color: '#f59e0b', orderIndex: 15 },
{ name: 'Event-driven', slug: 'event-driven', category: 'Data & Backend', icon: 'Zap', description: 'Asynchronous event architecture and CQRS patterns.', color: '#06b6d4', orderIndex: 16 },
{ name: 'Microservices', slug: 'microservices', category: 'Data & Backend', icon: 'Grid', description: 'Decoupled service APIs and domain-driven design.', color: '#6366f1', orderIndex: 17 },
// Cloud/Infra
{ name: 'AWS', slug: 'aws', category: 'Cloud & Infra', icon: 'Cloud', description: 'Amazon Web Services cloud infrastructure.', color: '#f97316', orderIndex: 18 },
{ name: 'Sovereign / On-prem', slug: 'sovereign-onprem', category: 'Cloud & Infra', icon: 'Lock', description: 'Sovereign cloud hosting and air-gapped on-premise deployments.', color: '#64748b', orderIndex: 19 },
{ name: 'Kubernetes', slug: 'kubernetes', category: 'Cloud & Infra', icon: 'Box', description: 'K8s container orchestration and mesh networking.', color: '#0284c7', orderIndex: 20 },
{ name: 'IaaS', slug: 'iaas', category: 'Cloud & Infra', icon: 'Server', description: 'Infrastructure-as-a-Service and virtualized bare metal.', color: '#475569', orderIndex: 21 },
];
const createdTechStacks: Record<string, string> = {};
for (const item of techStacksData) {
const ts = await prisma.techStack.create({ data: item });
createdTechStacks[item.name] = ts.id;
}
// 3. Group 3: Engagement Type (4 Entries)
const engagementTypesData = [
{ name: 'Build', slug: 'build', icon: 'Wrench', description: 'Greenfield product engineering and 0-to-1 development.', color: '#3b82f6', orderIndex: 1 },
{ name: 'Rescue', slug: 'rescue', icon: 'LifeBuoy', description: 'Turnaround engineering, legacy modernization, and critical fixes.', color: '#ef4444', orderIndex: 2 },
{ name: 'Scale', slug: 'scale', icon: 'TrendingUp', description: 'Performance optimization, architecture scaling, and throughput expansion.', color: '#10b981', orderIndex: 3 },
{ name: 'Due Diligence', slug: 'due-diligence', icon: 'FileSearch', description: 'Technical audits, code reviews, and M&A architecture assessments.', color: '#f59e0b', orderIndex: 4 },
];
const createdEngagementTypes: Record<string, string> = {};
for (const item of engagementTypesData) {
const et = await prisma.engagementType.create({ data: item });
createdEngagementTypes[item.name] = et.id;
}
// 4. Group 4: Compliance / Regulatory (5 Entries)
const complianceStandardsData = [
{ name: 'HIPAA', slug: 'hipaa', icon: 'Activity', description: 'Health Insurance Portability and Accountability Act.', color: '#ec4899', orderIndex: 1 },
{ name: 'GxP', slug: 'gxp', icon: 'ShieldCheck', description: 'Good Practice quality guidelines for pharma and life sciences.', color: '#10b981', orderIndex: 2 },
{ name: 'APRA CPS 230', slug: 'apra-cps-230', icon: 'Building', description: 'APRA Operational Risk Management standard for banking.', color: '#3b82f6', orderIndex: 3 },
{ name: 'SOC 2', slug: 'soc-2', icon: 'FileCheck', description: 'SOC 2 security, availability, and confidentiality controls.', color: '#06b6d4', orderIndex: 4 },
{ name: 'GDPR / Sovereign', slug: 'gdpr-sovereign', icon: 'Lock', description: 'EU General Data Protection Regulation and data sovereignty.', color: '#8b5cf6', orderIndex: 5 },
];
const createdCompliance: Record<string, string> = {};
for (const item of complianceStandardsData) {
const cs = await prisma.complianceStandard.create({ data: item });
createdCompliance[item.name] = cs.id;
}
console.log('[Taxonomy-Seed] Successfully seeded 11 Verticals, 21 Tech Stacks, 4 Engagement Types, and 5 Compliance Standards.');
// 5. Re-map Catalog Assets to the Exact Taxonomy Entries
const assets = await prisma.asset.findMany();
console.log(`[Taxonomy-Seed] Mapping exact taxonomy relations for ${assets.length} catalog assets...`);
let updatedCount = 0;
for (const asset of assets) {
const text = (asset.title + ' ' + (asset.description || '') + ' ' + (asset.tags || []).join(' ')).toLowerCase();
const targetVerticals: string[] = [];
const targetTechs: string[] = [];
const targetEngagements: string[] = [];
const targetCompliance: string[] = [];
// Verticals mapping
if (text.includes('cyber') || text.includes('security') || text.includes('scada') || text.includes('ot security')) {
if (createdVerticals['Cybersecurity & OT Security']) targetVerticals.push(createdVerticals['Cybersecurity & OT Security']);
}
if (text.includes('health') || text.includes('patient') || text.includes('medical') || text.includes('pharma') || text.includes('diabetic')) {
if (createdVerticals['Healthcare & Pharma']) targetVerticals.push(createdVerticals['Healthcare & Pharma']);
}
if (text.includes('bank') || text.includes('finance') || text.includes('fintech') || text.includes('payment')) {
if (createdVerticals['Finance & Banking']) targetVerticals.push(createdVerticals['Finance & Banking']);
}
if (text.includes('insurance') || text.includes('claim')) {
if (createdVerticals['Insurance']) targetVerticals.push(createdVerticals['Insurance']);
}
if (text.includes('energy') || text.includes('grid') || text.includes('metering') || text.includes('utility') || text.includes('water')) {
if (createdVerticals['Energy & Utilities']) targetVerticals.push(createdVerticals['Energy & Utilities']);
}
if (text.includes('agri') || text.includes('farm') || text.includes('crop')) {
if (createdVerticals['Agriculture']) targetVerticals.push(createdVerticals['Agriculture']);
}
if (text.includes('student') || text.includes('education') || text.includes('textbook') || text.includes('school') || text.includes('plagiarism')) {
if (createdVerticals['Education']) targetVerticals.push(createdVerticals['Education']);
}
if (text.includes('manufactur') || text.includes('iot') || text.includes('sensor') || text.includes('factory')) {
if (createdVerticals['Manufacturing & IoT']) targetVerticals.push(createdVerticals['Manufacturing & IoT']);
}
if (text.includes('auto') || text.includes('vehicle') || text.includes('car') || text.includes('ev ')) {
if (createdVerticals['Automotive']) targetVerticals.push(createdVerticals['Automotive']);
}
if (text.includes('retail') || text.includes('basket') || text.includes('store') || text.includes('supply')) {
if (createdVerticals['Retail & Supply Chain']) targetVerticals.push(createdVerticals['Retail & Supply Chain']);
}
if (text.includes('blockchain') || text.includes('ledger') || text.includes('web3')) {
if (createdVerticals['Blockchain']) targetVerticals.push(createdVerticals['Blockchain']);
}
// Tech Stacks mapping
if (text.includes('ai') || text.includes('ml') || text.includes('model') || text.includes('predictive') || text.includes('chatbot')) {
if (createdTechStacks['AI & ML']) targetTechs.push(createdTechStacks['AI & ML']);
}
if (text.includes('llm') || text.includes('agent') || text.includes('gpt') || text.includes('deepseek')) {
if (createdTechStacks['LLM / Agentic']) targetTechs.push(createdTechStacks['LLM / Agentic']);
}
if (text.includes('rag') || text.includes('vector') || text.includes('retrieval')) {
if (createdTechStacks['RAG']) targetTechs.push(createdTechStacks['RAG']);
}
if (text.includes('vision') || text.includes('camera') || text.includes('image')) {
if (createdTechStacks['Computer Vision']) targetTechs.push(createdTechStacks['Computer Vision']);
}
if (text.includes('postgres') || text.includes('db') || text.includes('sql')) {
if (createdTechStacks['PostgreSQL']) targetTechs.push(createdTechStacks['PostgreSQL']);
}
if (text.includes('aws') || text.includes('cloud') || text.includes('server')) {
if (createdTechStacks['AWS']) targetTechs.push(createdTechStacks['AWS']);
}
// Default Fallbacks
if (targetVerticals.length === 0 && createdVerticals['Cybersecurity & OT Security']) {
targetVerticals.push(createdVerticals['Cybersecurity & OT Security']);
}
if (targetTechs.length === 0 && createdTechStacks['AI & ML']) {
targetTechs.push(createdTechStacks['AI & ML']);
}
if (createdEngagementTypes['Build']) {
targetEngagements.push(createdEngagementTypes['Build']);
}
if (createdCompliance['SOC 2']) {
targetCompliance.push(createdCompliance['SOC 2']);
}
await prisma.asset.update({
where: { id: asset.id },
data: {
verticals: { connect: targetVerticals.map(id => ({ id })) },
techStacks: { connect: targetTechs.map(id => ({ id })) },
engagementTypes: { connect: targetEngagements.map(id => ({ id })) },
complianceStandards: { connect: targetCompliance.map(id => ({ id })) },
}
});
updatedCount++;
}
console.log(`[Taxonomy-Seed] Successfully mapped exact taxonomy relations for ${updatedCount} assets.`);
}
if (require.main === module || (process.argv[1] && process.argv[1].includes('seed-taxonomy'))) {
seedFourGroupTaxonomy()
.then(() => process.exit(0))
.catch(err => {
console.error('[Taxonomy-Seed] Failed:', err);
process.exit(1);
});
}

View File

@ -2,12 +2,13 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2375BF46' stroke-width='2'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z' /%3E%3C/svg%3E" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tech4Biz Client & Admin Portal</title> <title>Tech4Biz Client & Admin Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <!-- Premium Fonts -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@ -10,19 +10,16 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"@tanstack/react-router": "^1.170.17", "@tanstack/react-router": "^1.170.17",
"axios": "^1.18.1", "axios": "^1.18.1",
"docx-preview": "^0.4.0",
"framer-motion": "^12.42.2", "framer-motion": "^12.42.2",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-hook-form": "^7.81.0", "react-hook-form": "^7.81.0",
"react-router-dom": "^7.18.1", "react-router-dom": "^7.18.1",
"xlsx": "^0.18.5",
"zod": "^4.4.3", "zod": "^4.4.3",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

View File

@ -5,8 +5,6 @@ import { useEffect } from 'react';
import { useThemeStore } from './hooks/use-theme'; import { useThemeStore } from './hooks/use-theme';
import { useAuthStore } from './hooks/use-auth'; import { useAuthStore } from './hooks/use-auth';
import { ToastProvider } from "./components/ui/Toast";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false } queries: { retry: 1, refetchOnWindowFocus: false }
@ -41,9 +39,7 @@ export const App = () => {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ToastProvider> <RouterProvider router={router} />
<RouterProvider router={router} />
</ToastProvider>
</QueryClientProvider> </QueryClientProvider>
); );
}; };

View File

@ -1,23 +1,9 @@
import React, { useState } from "react"; import React, { useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useThemeStore } from "../../hooks/use-theme"; import { useThemeStore } from '../../hooks/use-theme';
import { useAuthStore } from "../../hooks/use-auth"; import { useAuthStore } from '../../hooks/use-auth';
import { import { ShieldCheck, BarChart3, ClipboardCheck, FolderGit2, BookCopy, Users, LogOut, Menu, X, Sun, Moon, ChevronRight, ChevronLeft } from 'lucide-react';
ShieldCheck, import { motion, AnimatePresence } from 'framer-motion';
ClipboardCheck,
FolderGit2,
Users,
LogOut,
Menu,
X,
Sun,
Moon,
ChevronRight,
ChevronLeft,
Globe
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { ChatDrawer } from "../../components/ui/ChatDrawer";
export const AdminLayout: React.FC = () => { export const AdminLayout: React.FC = () => {
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
@ -29,62 +15,51 @@ export const AdminLayout: React.FC = () => {
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
navigate("/login"); navigate('/login');
}; };
const navItems = [ const navItems = [
{ name: "Partners", path: "/admin/partners", icon: Users }, { name: 'Partners', path: '/admin/partners', icon: Users },
{ name: "Approvals Queue", path: "/admin/approvals", icon: ClipboardCheck }, { name: 'Approvals Queue', path: '/admin/approvals', icon: ClipboardCheck },
{ name: "Legal Templates", path: "/admin/legal", icon: ShieldCheck }, { name: 'Legal Templates', path: '/admin/legal', icon: ShieldCheck },
{ name: "Manage Catalog", path: "/admin/assets", icon: FolderGit2 }, { name: 'Manage Catalog', path: '/admin/assets', icon: FolderGit2 },
{ name: "Ecosystem Manager", path: "/admin/ecosystem", icon: Globe }, { name: 'Analytics', path: '/admin/analytics', icon: BarChart3 },
{ name: 'Blog CMS', path: '/admin/blog', icon: BookCopy }
]; ];
return ( return (
<div className="min-h-screen flex flex-col md:flex-row bg-ink-50 text-ink-900 font-sans transition-colors duration-500 selection:bg-ink-900/10 overflow-hidden"> <div className="min-h-screen flex flex-col md:flex-row bg-ink-50 text-ink-900 font-sans transition-colors duration-500 selection:bg-ink-900/10 overflow-hidden">
{/* ── Desktop Sidebar ── */} {/* ── Desktop Sidebar ── */}
<aside <aside className={`hidden md:flex md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20 transition-all duration-300 relative ${isCollapsed ? 'md:w-[80px]' : 'md:w-[280px]'}`}>
className={`hidden md:flex md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20 transition-all duration-300 relative ${isCollapsed ? "md:w-[80px]" : "md:w-[280px]"}`}
>
{/* Toggle Button Floating on the Border */} {/* Toggle Button Floating on the Border */}
<button <button
onClick={() => setIsCollapsed(!isCollapsed)} onClick={() => setIsCollapsed(!isCollapsed)}
className="hidden md:flex absolute top-9 -right-3 w-6 h-6 rounded-full border border-ink-200 bg-ink-0 text-ink-500 hover:text-ink-900 hover:shadow-md items-center justify-center transition-all z-30 cursor-pointer shadow-sm" className="hidden md:flex absolute top-9 -right-3 w-6 h-6 rounded-full border border-ink-200 bg-ink-0 text-ink-500 hover:text-ink-900 hover:shadow-md items-center justify-center transition-all z-30 cursor-pointer shadow-sm"
title={isCollapsed ? "Expand Sidebar" : "Collapse Sidebar"} title={isCollapsed ? "Expand Sidebar" : "Collapse Sidebar"}
> >
{isCollapsed ? ( {isCollapsed ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
<ChevronRight className="w-3.5 h-3.5" />
) : (
<ChevronLeft className="w-3.5 h-3.5" />
)}
</button> </button>
{/* Branding */} {/* Branding */}
<div <div className={`h-24 flex items-center border-b border-ink-200 transition-all duration-300 ${isCollapsed ? 'justify-center px-4' : 'px-8'}`}>
className={`h-24 flex items-center border-b border-ink-200 transition-all duration-300 ${isCollapsed ? "justify-center px-4" : "px-8"}`}
>
<Link to="/admin" className="flex items-center gap-3 shrink-0 group"> <Link to="/admin" className="flex items-center gap-3 shrink-0 group">
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-ink-0 shadow-sm border border-ink-200 group-hover:scale-105 transition-all duration-300 p-1"> <div className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300">
<img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" /> <ShieldCheck className="w-5 h-5 text-ink-0" />
</div> </div>
{!isCollapsed && ( {!isCollapsed && (
<div className="flex flex-col animate-fade-in"> <div className="flex flex-col animate-fade-in">
<span className="text-lg font-extrabold tracking-tight leading-none text-ink-900"> <span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">Tech4Biz</span>
Tech4Biz <span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Admin Console</span>
</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">
Admin Console
</span>
</div> </div>
)} )}
</Link> </Link>
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav <nav className={`flex-1 py-8 space-y-2 overflow-y-auto transition-all duration-300 ${isCollapsed ? 'px-2' : 'px-4'}`}>
className={`flex-1 py-8 space-y-2 overflow-y-auto transition-all duration-300 ${isCollapsed ? "px-2" : "px-4"}`} {navItems.map(item => {
>
{navItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = location.pathname === item.path; const isActive = location.pathname === item.path;
return ( return (
@ -92,47 +67,31 @@ export const AdminLayout: React.FC = () => {
key={item.path} key={item.path}
to={item.path} to={item.path}
className={`flex items-center gap-3 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group relative ${ className={`flex items-center gap-3 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group relative ${
isCollapsed ? "justify-center px-0" : "px-4" isCollapsed ? 'justify-center px-0' : 'px-4'
} ${ } ${
isActive isActive
? "bg-ink-100 text-ink-900 border border-ink-300 shadow-sm" ? 'bg-ink-100 text-ink-900 border border-ink-300 shadow-sm'
: "text-ink-500 hover:text-ink-900 hover:bg-ink-100" : 'text-ink-500 hover:text-ink-900 hover:bg-ink-100'
}`} }`}
title={isCollapsed ? item.name : undefined} title={isCollapsed ? item.name : undefined}
> >
<Icon <Icon className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? 'text-ink-950' : 'text-ink-400 group-hover:text-ink-900'}`} />
className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? "text-ink-900" : "text-ink-400 group-hover:text-ink-900"}`}
/>
{!isCollapsed && <span>{item.name}</span>} {!isCollapsed && <span>{item.name}</span>}
{!isCollapsed && isActive && ( {!isCollapsed && isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
<ChevronRight className="w-4 h-4 ml-auto opacity-50" />
)}
</Link> </Link>
); );
})} })}
</nav> </nav>
{/* Footer */} {/* Footer */}
<div <div className={`border-t border-ink-200 bg-ink-50 transition-all duration-300 ${isCollapsed ? 'p-3' : 'p-5'}`}>
className={`border-t border-ink-200 bg-ink-50 transition-all duration-300 ${isCollapsed ? "p-3" : "p-5"}`} <div className={`flex items-center mb-4 transition-all duration-300 ${isCollapsed ? 'justify-center' : 'justify-between'}`}>
> {!isCollapsed && <p className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">Appearance</p>}
<div
className={`flex items-center mb-4 transition-all duration-300 ${isCollapsed ? "justify-center" : "justify-between"}`}
>
{!isCollapsed && (
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">
Appearance
</p>
)}
<button <button
onClick={toggleTheme} onClick={toggleTheme}
className="p-2 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group cursor-pointer" className="p-2 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group cursor-pointer"
> >
{theme === "dark" ? ( {theme === 'dark' ? <Sun className="w-4 h-4 group-hover:rotate-90 transition-transform" /> : <Moon className="w-4 h-4 group-hover:-rotate-12 transition-transform" />}
<Sun className="w-4 h-4 group-hover:rotate-90 transition-transform" />
) : (
<Moon className="w-4 h-4 group-hover:-rotate-12 transition-transform" />
)}
</button> </button>
</div> </div>
@ -142,26 +101,19 @@ export const AdminLayout: React.FC = () => {
{user?.email?.charAt(0).toUpperCase()} {user?.email?.charAt(0).toUpperCase()}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-bold text-ink-900 truncate"> <p className="text-sm font-bold text-ink-900 truncate">{user?.email}</p>
{user?.email} <p className="text-[10px] uppercase font-bold text-ink-500 tracking-wider truncate">Administrator</p>
</p>
<p className="text-[10px] uppercase font-bold text-ink-500 tracking-wider truncate">
Administrator
</p>
</div> </div>
</div> </div>
) : ( ) : (
<div <div className="w-9 h-9 rounded-full bg-ink-900 flex items-center justify-center text-ink-0 font-bold shadow-sm mx-auto mb-4 animate-fade-in" title={user?.email || ''}>
className="w-9 h-9 rounded-full bg-ink-900 flex items-center justify-center text-ink-0 font-bold shadow-sm mx-auto mb-4 animate-fade-in"
title={user?.email || ""}
>
{user?.email?.charAt(0).toUpperCase()} {user?.email?.charAt(0).toUpperCase()}
</div> </div>
)} )}
<button <button
onClick={handleLogout} onClick={handleLogout}
className={`w-full flex items-center justify-center gap-2 rounded-xl text-sm font-bold tracking-wide text-red-600 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200 cursor-pointer ${isCollapsed ? "py-2 px-0" : "px-4 py-2.5"}`} className={`w-full flex items-center justify-center gap-2 rounded-xl text-sm font-bold tracking-wide text-red-600 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200 cursor-pointer ${isCollapsed ? 'py-2 px-0' : 'px-4 py-2.5'}`}
title={isCollapsed ? "Sign Out" : undefined} title={isCollapsed ? "Sign Out" : undefined}
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
@ -171,24 +123,19 @@ export const AdminLayout: React.FC = () => {
</aside> </aside>
{/* ── Main Content Area ── */} {/* ── Main Content Area ── */}
<main className="flex-1 flex flex-col relative w-full h-screen overflow-hidden bg-ink-50"> <main className="flex-1 flex flex-col relative w-full overflow-y-auto bg-ink-50">
{/* Ambient Background Glows */} {/* Ambient Background Glows */}
<div className="fixed top-0 right-0 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none -z-10" /> <div className="fixed top-0 right-0 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none -z-10" />
{/* Mobile Header */} {/* Mobile Header */}
<header className="md:hidden sticky top-0 z-40 h-16 flex items-center justify-between px-4 bg-ink-0 border-b border-ink-200 shadow-sm shrink-0"> <header className="md:hidden sticky top-0 z-40 h-16 flex items-center justify-between px-4 bg-ink-0 border-b border-ink-200 shadow-sm">
<Link to="/admin" className="flex items-center gap-2"> <Link to="/admin" className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-ink-0 border border-ink-200 p-1 shadow-sm"> <div className="w-8 h-8 rounded-lg flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800">
<img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" /> <ShieldCheck className="w-4 h-4 text-ink-0" />
</div> </div>
<span className="text-sm font-extrabold tracking-tight text-ink-900"> <span className="text-sm font-extrabold tracking-tight text-ink-900">Admin Console</span>
Tech4Biz
</span>
</Link> </Link>
<button <button onClick={() => setMobileOpen(true)} className="p-2 rounded-lg border border-ink-200 text-ink-600">
onClick={() => setMobileOpen(true)}
className="p-2 rounded-lg border border-ink-200 text-ink-600"
>
<Menu className="w-5 h-5" /> <Menu className="w-5 h-5" />
</button> </button>
</header> </header>
@ -197,94 +144,48 @@ export const AdminLayout: React.FC = () => {
<AnimatePresence> <AnimatePresence>
{mobileOpen && ( {mobileOpen && (
<> <>
<motion.div <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setMobileOpen(false)} className="fixed inset-0 bg-ink-900/40 backdrop-blur-sm z-50 md:hidden" />
initial={{ opacity: 0 }} <motion.div initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }} transition={{ type: 'spring', damping: 25, stiffness: 200 }} className="fixed right-0 top-0 bottom-0 w-72 bg-ink-0 shadow-2xl z-50 border-l border-ink-200 flex flex-col md:hidden">
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setMobileOpen(false)}
className="fixed inset-0 bg-ink-900/40 backdrop-blur-sm z-50 md:hidden"
/>
<motion.div
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className="fixed right-0 top-0 bottom-0 w-72 bg-ink-0 shadow-2xl z-50 border-l border-ink-200 flex flex-col md:hidden"
>
<div className="p-4 border-b border-ink-200 flex items-center justify-between"> <div className="p-4 border-b border-ink-200 flex items-center justify-between">
<div className="flex items-center gap-2"> <span className="font-extrabold text-ink-900">Menu</span>
<div className="w-6 h-6 rounded-md flex items-center justify-center bg-ink-0 border border-ink-200 p-0.5 shadow-sm"> <button onClick={() => setMobileOpen(false)} className="p-2 rounded-lg bg-ink-100">
<img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" />
</div>
<span className="font-extrabold text-ink-900 text-sm">Tech4Biz</span>
</div>
<button
onClick={() => setMobileOpen(false)}
className="p-2 rounded-lg bg-ink-100"
>
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
<nav className="flex-1 overflow-y-auto p-4 space-y-2"> <nav className="flex-1 overflow-y-auto p-4 space-y-2">
{navItems.map((item) => ( {navItems.map(item => (
<Link <Link key={item.path} to={item.path} onClick={() => setMobileOpen(false)} className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold ${location.pathname === item.path ? 'bg-ink-100 text-ink-900 border border-ink-300' : 'text-ink-500 hover:text-ink-900'}`}>
key={item.path}
to={item.path}
onClick={() => setMobileOpen(false)}
className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold ${location.pathname === item.path ? "bg-ink-100 text-ink-900 border border-ink-300" : "text-ink-500 hover:text-ink-900"}`}
>
<item.icon className="w-5 h-5" /> <item.icon className="w-5 h-5" />
<span>{item.name}</span> <span>{item.name}</span>
</Link> </Link>
))} ))}
</nav> </nav>
<div className="p-4 border-t border-ink-200 space-y-4"> <div className="p-4 border-t border-ink-200 space-y-4">
<button <button onClick={toggleTheme} className="flex items-center justify-between w-full p-3 rounded-xl bg-ink-50 border border-ink-200 text-sm font-bold text-ink-500 hover:text-ink-900">
onClick={toggleTheme} Theme {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
className="flex items-center justify-between w-full p-3 rounded-xl bg-ink-50 border border-ink-200 text-sm font-bold text-ink-500 hover:text-ink-900"
>
Theme{" "}
{theme === "dark" ? (
<Sun className="w-4 h-4" />
) : (
<Moon className="w-4 h-4" />
)}
</button>
<button
onClick={() => {
setMobileOpen(false);
handleLogout();
}}
className="w-full py-3 rounded-xl bg-red-500/10 text-red-650 font-bold text-sm"
>
Sign Out
</button> </button>
<button onClick={() => { setMobileOpen(false); handleLogout(); }} className="w-full py-3 rounded-xl bg-red-500/10 text-red-650 font-bold text-sm">Sign Out</button>
</div> </div>
</motion.div> </motion.div>
</> </>
)} )}
</AnimatePresence> </AnimatePresence>
<div className="flex-1 w-full max-w-[1600px] px-4 py-4 md:px-8 mx-auto relative z-10 overflow-hidden flex flex-col min-h-0"> <div className="flex-1 w-full max-w-[1600px] px-4 py-6 md:px-8 mx-auto relative z-10">
<Outlet /> <Outlet />
</div> </div>
{/* Footer */} {/* Footer */}
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md shrink-0"> <footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md mt-auto">
<div className="max-w-[1600px] mx-auto px-4 md:px-8 py-3 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500"> <div className="max-w-[1600px] mx-auto px-4 md:px-8 py-4 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500">
<p>© 2026 Tech4Biz Solutions Inc. Admin Console.</p> <p>© 2026 Tech4Biz Solutions Inc. Admin Console.</p>
<div className="flex gap-6"> <div className="flex gap-6">
<span className="hover:text-ink-900 cursor-pointer transition-colors"> <span className="hover:text-ink-900 cursor-pointer transition-colors">Security Compliance</span>
Security Compliance <span className="hover:text-ink-900 cursor-pointer transition-colors">System Status</span>
</span>
<span className="hover:text-ink-900 cursor-pointer transition-colors">
System Status
</span>
</div> </div>
</div> </div>
</footer> </footer>
</main> </main>
<ChatDrawer />
</div> </div>
); );
}; };

View File

@ -2,9 +2,13 @@ import React, { useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useThemeStore } from '../../hooks/use-theme'; import { useThemeStore } from '../../hooks/use-theme';
import { useAuthStore } from '../../hooks/use-auth'; import { useAuthStore } from '../../hooks/use-auth';
import { Cpu, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft, Settings, Globe, Video } from 'lucide-react'; import { ShieldCheck, Cpu, BookOpen, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { ChatDrawer } from '../../components/ui/ChatDrawer';
const navItems = [
{ name: 'Assets', path: '/client', icon: Cpu, label: 'Asset Explorer' },
{ name: 'Blog', path: '/client/blog', icon: BookOpen, label: 'Insights Blog' },
];
export const ClientLayout: React.FC = () => { export const ClientLayout: React.FC = () => {
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
@ -14,72 +18,6 @@ export const ClientLayout: React.FC = () => {
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
const dynamicNavItems = [
{ name: 'Assets', path: '/client', icon: Cpu, label: 'Asset Explorer' },
{ name: 'Ecosystem', path: '/client/ecosystem', icon: Globe, label: 'Explore More' },
{ name: 'Showcase', path: '/client/showcase', icon: Video, label: 'Featured Content' }
];
// Settings Modal State
const [settingsOpen, setSettingsOpen] = useState(false);
const [defaultTheme, setDefaultTheme] = useState(user?.defaultTheme || 'dark');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [settingsError, setSettingsError] = useState('');
const [settingsSuccess, setSettingsSuccess] = useState('');
const openSettings = () => {
setDefaultTheme(user?.defaultTheme || 'dark');
setPassword('');
setConfirmPassword('');
setSettingsError('');
setSettingsSuccess('');
setSettingsOpen(true);
};
const handleSaveSettings = async (e: React.FormEvent) => {
e.preventDefault();
setSettingsError('');
setSettingsSuccess('');
if (password && password !== confirmPassword) {
setSettingsError("Passwords do not match");
return;
}
setIsSaving(true);
try {
const { updateProfile } = await import('../../services/auth-api');
const updatedUser = await updateProfile({
defaultTheme: defaultTheme || undefined,
password: password || undefined
});
// Update auth store
useAuthStore.getState().setAuth({
user: updatedUser,
accessToken: useAuthStore.getState().accessToken!
});
if (defaultTheme) {
useThemeStore.getState().setTheme(defaultTheme as any);
}
setSettingsSuccess("Profile settings updated successfully!");
setPassword('');
setConfirmPassword('');
setTimeout(() => {
setSettingsOpen(false);
}, 1500);
} catch (err: any) {
setSettingsError(err.response?.data?.error || err.message || "Failed to update profile settings");
} finally {
setIsSaving(false);
}
};
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
navigate('/login'); navigate('/login');
@ -104,8 +42,8 @@ export const ClientLayout: React.FC = () => {
{/* Branding */} {/* Branding */}
<div className={`h-24 flex items-center border-b border-ink-200 transition-all duration-300 ${isCollapsed ? 'justify-center px-4' : 'px-8'}`}> <div className={`h-24 flex items-center border-b border-ink-200 transition-all duration-300 ${isCollapsed ? 'justify-center px-4' : 'px-8'}`}>
<Link to="/client" className="flex items-center gap-3 shrink-0 group"> <Link to="/client" className="flex items-center gap-3 shrink-0 group">
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-ink-0 shadow-sm border border-ink-200 group-hover:scale-105 transition-all duration-300 p-1"> <div className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300">
<img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" /> <ShieldCheck className="w-5 h-5 text-ink-0" />
</div> </div>
{!isCollapsed && ( {!isCollapsed && (
<div className="flex flex-col animate-fade-in"> <div className="flex flex-col animate-fade-in">
@ -118,7 +56,7 @@ export const ClientLayout: React.FC = () => {
{/* Navigation */} {/* Navigation */}
<nav className={`flex-1 py-8 space-y-2 overflow-y-auto transition-all duration-300 ${isCollapsed ? 'px-2' : 'px-4'}`}> <nav className={`flex-1 py-8 space-y-2 overflow-y-auto transition-all duration-300 ${isCollapsed ? 'px-2' : 'px-4'}`}>
{dynamicNavItems.map(item => { {navItems.map(item => {
const Icon = item.icon; const Icon = item.icon;
const isActive = location.pathname === item.path; const isActive = location.pathname === item.path;
return ( return (
@ -134,23 +72,12 @@ export const ClientLayout: React.FC = () => {
}`} }`}
title={isCollapsed ? item.label : undefined} title={isCollapsed ? item.label : undefined}
> >
<Icon className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? 'text-ink-900' : 'text-ink-400 group-hover:text-ink-900'}`} /> <Icon className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? 'text-ink-950' : 'text-ink-400 group-hover:text-ink-900'}`} />
{!isCollapsed && <span>{item.label}</span>} {!isCollapsed && <span>{item.label}</span>}
{!isCollapsed && isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />} {!isCollapsed && isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
</Link> </Link>
); );
})} })}
<button
onClick={openSettings}
className={`w-full flex items-center gap-3 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group relative cursor-pointer ${
isCollapsed ? 'justify-center px-0' : 'px-4'
} text-ink-500 hover:text-ink-900 hover:bg-ink-100`}
title={isCollapsed ? 'Profile Settings' : undefined}
>
<Settings className={`w-5 h-5 transition-transform group-hover:scale-110 ${settingsOpen ? 'text-ink-900' : 'text-ink-400 group-hover:text-ink-900'}`} />
{!isCollapsed && <span>Profile Settings</span>}
</button>
</nav> </nav>
{/* Footer */} {/* Footer */}
@ -183,8 +110,6 @@ export const ClientLayout: React.FC = () => {
</div> </div>
)} )}
{/* Profile Settings button removed from here */}
<button <button
onClick={handleLogout} onClick={handleLogout}
className={`w-full flex items-center justify-center gap-2 rounded-xl text-sm font-bold tracking-wide text-red-650 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200 cursor-pointer ${isCollapsed ? 'py-2 px-0' : 'px-4 py-2.5'}`} className={`w-full flex items-center justify-center gap-2 rounded-xl text-sm font-bold tracking-wide text-red-650 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200 cursor-pointer ${isCollapsed ? 'py-2 px-0' : 'px-4 py-2.5'}`}
@ -194,19 +119,21 @@ export const ClientLayout: React.FC = () => {
{!isCollapsed && <span>Sign Out</span>} {!isCollapsed && <span>Sign Out</span>}
</button> </button>
</div> </div>
</aside> {/* ── Main Content Area ── */} </aside>
<main className="flex-1 flex flex-col relative w-full h-screen overflow-hidden bg-ink-50">
{/* ── Main Content Area ── */}
<main className="flex-1 flex flex-col relative w-full overflow-y-auto bg-ink-50">
{/* Ambient Glow */} {/* Ambient Glow */}
<div className="fixed top-0 right-0 w-[500px] h-[500px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none -z-10" /> <div className="fixed top-0 right-0 w-[500px] h-[500px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none -z-10" />
{/* Mobile Header */} {/* Mobile Header */}
<header className="md:hidden sticky top-0 z-40 h-16 flex items-center justify-between px-4 bg-ink-0 border-b border-ink-200 shadow-sm shrink-0"> <header className="md:hidden sticky top-0 z-40 h-16 flex items-center justify-between px-4 bg-ink-0 border-b border-ink-200 shadow-sm">
<Link to="/client" className="flex items-center gap-2"> <Link to="/client" className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-ink-0 border border-ink-200 p-1 shadow-sm"> <div className="w-8 h-8 rounded-lg flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800">
<img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" /> <ShieldCheck className="w-4 h-4 text-ink-0" />
</div> </div>
<span className="text-sm font-extrabold tracking-tight text-ink-900">Tech4Biz</span> <span className="text-sm font-extrabold tracking-tight text-ink-900">Client Portal</span>
</Link> </Link>
<button onClick={() => setMobileOpen(true)} className="p-2 rounded-lg border border-ink-200 text-ink-600"> <button onClick={() => setMobileOpen(true)} className="p-2 rounded-lg border border-ink-200 text-ink-600">
<Menu className="w-5 h-5" /> <Menu className="w-5 h-5" />
@ -220,33 +147,21 @@ export const ClientLayout: React.FC = () => {
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setMobileOpen(false)} className="fixed inset-0 bg-ink-900/40 backdrop-blur-sm z-50 md:hidden" /> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setMobileOpen(false)} className="fixed inset-0 bg-ink-900/40 backdrop-blur-sm z-50 md:hidden" />
<motion.div initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }} transition={{ type: 'spring', damping: 25, stiffness: 200 }} className="fixed right-0 top-0 bottom-0 w-72 bg-ink-0 shadow-2xl z-50 border-l border-ink-200 flex flex-col md:hidden"> <motion.div initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }} transition={{ type: 'spring', damping: 25, stiffness: 200 }} className="fixed right-0 top-0 bottom-0 w-72 bg-ink-0 shadow-2xl z-50 border-l border-ink-200 flex flex-col md:hidden">
<div className="p-4 border-b border-ink-200 flex items-center justify-between"> <div className="p-4 border-b border-ink-200 flex items-center justify-between">
<div className="flex items-center gap-2"> <span className="font-extrabold text-ink-900">Menu</span>
<div className="w-6 h-6 rounded-md flex items-center justify-center bg-ink-0 border border-ink-200 p-0.5 shadow-sm">
<img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" />
</div>
<span className="font-extrabold text-ink-900 text-sm">Tech4Biz</span>
</div>
<button onClick={() => setMobileOpen(false)} className="p-2 rounded-lg bg-ink-100"> <button onClick={() => setMobileOpen(false)} className="p-2 rounded-lg bg-ink-100">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
<nav className="flex-1 overflow-y-auto p-4 space-y-2"> <nav className="flex-1 overflow-y-auto p-4 space-y-2">
{dynamicNavItems.map(item => ( {navItems.map(item => (
<Link key={item.path} to={item.path} onClick={() => setMobileOpen(false)} className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold ${location.pathname === item.path ? 'bg-ink-100 text-ink-900 border border-ink-300' : 'text-ink-650'}`}> <Link key={item.path} to={item.path} onClick={() => setMobileOpen(false)} className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold ${location.pathname === item.path ? 'bg-ink-100 text-ink-900 border border-ink-300' : 'text-ink-650'}`}>
<item.icon className="w-5 h-5" /> <item.icon className="w-5 h-5" />
{item.label} {item.label}
</Link> </Link>
))} ))}
<button
onClick={() => { setMobileOpen(false); openSettings(); }}
className="flex items-center gap-3 w-full px-4 py-3 rounded-xl text-sm font-semibold text-ink-650 hover:bg-ink-100 cursor-pointer animate-fade-in"
>
<Settings className="w-5 h-5 text-ink-400" />
Profile Settings
</button>
</nav> </nav>
<div className="p-4 border-t border-ink-200 space-y-4"> <div className="p-4 border-t border-ink-200 space-y-4">
<button onClick={toggleTheme} className="flex items-center justify-between w-full p-3 rounded-xl bg-ink-50 border border-ink-200 text-sm font-bold text-ink-650"> <button onClick={toggleTheme} className="flex items-center justify-between w-full p-3 rounded-xl bg-ink-50 border border-ink-200 text-sm font-bold text-ink-600">
Theme {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />} Theme {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button> </button>
<button onClick={() => { setMobileOpen(false); handleLogout(); }} className="w-full py-3 rounded-xl bg-red-500/10 text-red-600 font-bold text-sm">Sign Out</button> <button onClick={() => { setMobileOpen(false); handleLogout(); }} className="w-full py-3 rounded-xl bg-red-500/10 text-red-600 font-bold text-sm">Sign Out</button>
@ -256,13 +171,13 @@ export const ClientLayout: React.FC = () => {
)} )}
</AnimatePresence> </AnimatePresence>
<div className="flex-1 w-full max-w-[1600px] px-4 py-4 md:px-8 mx-auto relative z-10 overflow-hidden flex flex-col min-h-0"> <div className="flex-1 w-full max-w-[1600px] px-4 py-6 md:px-8 mx-auto relative z-10">
<Outlet /> <Outlet />
</div> </div>
{/* Footer */} {/* Footer */}
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md shrink-0"> <footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md mt-auto">
<div className="max-w-[1600px] mx-auto px-4 md:px-8 py-3 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500"> <div className="max-w-[1600px] mx-auto px-4 md:px-8 py-4 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500">
<p>© 2026 Tech4Biz Solutions Inc. All rights reserved.</p> <p>© 2026 Tech4Biz Solutions Inc. All rights reserved.</p>
<div className="flex gap-6"> <div className="flex gap-6">
<span className="hover:text-ink-900 cursor-pointer transition-colors">Security</span> <span className="hover:text-ink-900 cursor-pointer transition-colors">Security</span>
@ -271,132 +186,6 @@ export const ClientLayout: React.FC = () => {
</div> </div>
</footer> </footer>
</main> </main>
{/* Settings Modal */}
<AnimatePresence>
{settingsOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => !isSaving && setSettingsOpen(false)}
className="fixed inset-0 bg-ink-900/60 backdrop-blur-md"
/>
{/* Modal Body */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="relative w-full max-w-xl bg-ink-0 border border-ink-200 rounded-3xl shadow-2xl p-6 md:p-8 z-50 overflow-y-auto max-h-[90vh] text-ink-900 transition-colors duration-300"
>
<div className="flex justify-between items-start mb-6">
<div>
<h3 className="text-xl font-black tracking-tight text-ink-900">Profile Settings</h3>
<p className="text-xs font-bold text-ink-500 mt-1">Refine your profile parameters and manage credentials.</p>
</div>
<button
onClick={() => setSettingsOpen(false)}
disabled={isSaving}
className="p-1.5 rounded-lg bg-ink-50 border border-ink-200 text-ink-500 hover:text-ink-900 disabled:opacity-50 cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{settingsError && (
<div className="mb-4 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-xs font-bold text-red-600 dark:text-red-400">
{settingsError}
</div>
)}
{settingsSuccess && (
<div className="mb-4 p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-xs font-bold text-emerald-600 dark:text-emerald-400">
{settingsSuccess}
</div>
)}
<form onSubmit={handleSaveSettings} className="space-y-4">
{/* Read-only Email Field */}
<div>
<label className="block text-xs font-extrabold uppercase tracking-widest text-ink-500 mb-1.5">Email Address</label>
<input
type="email"
value={user?.email || ''}
disabled
className="w-full px-4 py-2.5 rounded-xl bg-ink-100 border border-ink-200 text-ink-450 text-sm font-bold cursor-not-allowed"
/>
<p className="text-[10px] font-bold text-ink-400 mt-1">Email address cannot be changed.</p>
</div>
{/* Company Profile Fields Removed */}
{/* Theme settings */}
<div>
<label className="block text-xs font-extrabold uppercase tracking-widest text-ink-500 mb-1.5">Default Theme</label>
<select
value={defaultTheme}
onChange={(e) => setDefaultTheme(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-ink-50 border border-ink-200 focus:border-ink-400 focus:bg-ink-0 text-sm font-bold transition-all outline-none cursor-pointer text-ink-900"
>
<option value="dark">Dark Theme</option>
<option value="light">Light Theme</option>
</select>
</div>
{/* Security Fields */}
<div className="border-t border-ink-200 pt-4 mt-2">
<h4 className="text-sm font-bold text-ink-900 mb-3">Change Password</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-extrabold uppercase tracking-widest text-ink-500 mb-1.5">New Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-ink-50 border border-ink-200 focus:border-ink-400 focus:bg-ink-0 text-sm font-bold transition-all outline-none"
placeholder="••••••••"
/>
</div>
<div>
<label className="block text-xs font-extrabold uppercase tracking-widest text-ink-500 mb-1.5">Confirm New Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-ink-50 border border-ink-200 focus:border-ink-400 focus:bg-ink-0 text-sm font-bold transition-all outline-none"
placeholder="••••••••"
/>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3 border-t border-ink-200 pt-5 mt-4">
<button
type="button"
disabled={isSaving}
onClick={() => setSettingsOpen(false)}
className="px-5 py-2.5 rounded-xl text-sm font-bold bg-ink-50 hover:bg-ink-100 border border-ink-200 text-ink-700 disabled:opacity-50 cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={isSaving}
className="px-6 py-2.5 rounded-xl text-sm font-bold bg-ink-900 hover:bg-ink-900 text-ink-0 hover:shadow-lg transition-all disabled:opacity-50 cursor-pointer flex items-center justify-center gap-2"
>
{isSaving ? 'Saving Changes...' : 'Save Settings'}
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
{/* AI Assistant Chat Drawer */}
<ChatDrawer />
</div> </div>
); );
}; };

View File

@ -1,72 +1,20 @@
import React, { Suspense } from "react"; import React, { Suspense } from 'react';
import { createBrowserRouter, Navigate } from "react-router-dom"; import { createBrowserRouter, Navigate } from 'react-router-dom';
import { RequireAuth, RequireRole, RequireOnboardingComplete } from "./guards"; import { RequireAuth, RequireRole, RequireOnboardingComplete } from './guards';
// Layouts // Layouts
const ClientLayout = React.lazy(() => import("../layouts/ClientLayout")); const ClientLayout = React.lazy(() => import('../layouts/ClientLayout'));
const AdminLayout = React.lazy(() => import("../layouts/AdminLayout")); const AdminLayout = React.lazy(() => import('../layouts/AdminLayout'));
// Pages (Lazy Loaded) // Pages (Lazy Loaded)
const LoginPage = React.lazy(() => const LoginPage = React.lazy(() => import('../../pages/LoginPage').then(m => ({ default: m.LoginPage })));
import("../../pages/LoginPage").then((m) => ({ default: m.LoginPage })), const InvitePage = React.lazy(() => import('../../pages/InvitePage').then(m => ({ default: m.InvitePage })));
); const DashboardPage = React.lazy(() => import('../../pages/DashboardPage').then(m => ({ default: m.DashboardPage })));
const InvitePage = React.lazy(() => const AssetsPage = React.lazy(() => import('../../pages/AssetsPage').then(m => ({ default: m.AssetsPage })));
import("../../pages/InvitePage").then((m) => ({ default: m.InvitePage })), const ApprovalsPage = React.lazy(() => import('../../pages/admin/ApprovalsPage').then(m => ({ default: m.ApprovalsPage })));
); const DirectoryPage = React.lazy(() => import('../../pages/admin/DirectoryPage').then(m => ({ default: m.DirectoryPage })));
const DashboardPage = React.lazy(() => const OnboardingPage = React.lazy(() => import('../../pages/OnboardingPage').then(m => ({ default: m.OnboardingPage })));
import("../../pages/DashboardPage").then((m) => ({ const LegalTemplatesPage = React.lazy(() => import('../../pages/admin/LegalTemplatesPage').then(m => ({ default: m.LegalTemplatesPage })));
default: m.DashboardPage,
})),
);
const AssetsPage = React.lazy(() =>
import("../../pages/AssetsPage").then((m) => ({ default: m.AssetsPage })),
);
const ApprovalsPage = React.lazy(() =>
import("../../pages/admin/ApprovalsPage").then((m) => ({
default: m.ApprovalsPage,
})),
);
const DirectoryPage = React.lazy(() =>
import("../../pages/admin/DirectoryPage").then((m) => ({
default: m.DirectoryPage,
})),
);
const OnboardingPage = React.lazy(() =>
import("../../pages/OnboardingPage").then((m) => ({
default: m.OnboardingPage,
})),
);
const LegalTemplatesPage = React.lazy(() =>
import("../../pages/admin/LegalTemplatesPage").then((m) => ({
default: m.LegalTemplatesPage,
})),
);
const ClientAgreementsPage = React.lazy(() =>
import("../../pages/ClientAgreementsPage").then((m) => ({
default: m.ClientAgreementsPage,
})),
);
const EcosystemPage = React.lazy(() =>
import("../../pages/EcosystemPage").then((m) => ({
default: m.EcosystemPage,
})),
);
const ShowcasePage = React.lazy(() =>
import("../../pages/ShowcasePage").then((m) => ({
default: m.ShowcasePage,
})),
);
const GroupDetailsPage = React.lazy(() =>
import("../../pages/admin/GroupDetailsPage").then((m) => ({
default: m.GroupDetailsPage,
})),
);
const EcosystemManagerPage = React.lazy(() =>
import("../../pages/admin/EcosystemManagerPage").then((m) => ({
default: m.EcosystemManagerPage,
})),
);
// Dummy Components for routing // Dummy Components for routing
const LoadingFallback = () => ( const LoadingFallback = () => (
@ -75,13 +23,17 @@ const LoadingFallback = () => (
</div> </div>
); );
const LegalPage = () => <div className="max-w-[1400px] mx-auto w-full p-8"><h1 className="text-4xl font-extrabold text-ink-800 tracking-tight">Legal Engine</h1></div>;
const AnalyticsPage = () => <div className="max-w-[1400px] mx-auto w-full p-8"><h1 className="text-4xl font-extrabold text-ink-800 tracking-tight">Analytics Dashboard</h1></div>;
const BlogPage = () => <div className="max-w-[1400px] mx-auto w-full p-8"><h1 className="text-4xl font-extrabold text-ink-800 tracking-tight">Blog CMS</h1></div>;
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
path: "/", path: '/',
element: <Navigate to="/login" replace />, element: <Navigate to="/login" replace />,
}, },
{ {
path: "/login", path: '/login',
element: ( element: (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<LoginPage /> <LoginPage />
@ -89,7 +41,7 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: "/invite", path: '/invite',
element: ( element: (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<InvitePage /> <InvitePage />
@ -97,7 +49,7 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: "/onboarding", path: '/onboarding',
element: ( element: (
<RequireAuth> <RequireAuth>
<RequireRole role="PARTNER_USER"> <RequireRole role="PARTNER_USER">
@ -109,7 +61,7 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: "/client", path: '/client',
element: ( element: (
<RequireAuth> <RequireAuth>
<RequireRole role="PARTNER_USER"> <RequireRole role="PARTNER_USER">
@ -131,7 +83,7 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: "assets", path: 'assets',
element: ( element: (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<AssetsPage /> <AssetsPage />
@ -139,33 +91,17 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: "agreements", path: 'agreements',
element: ( element: <LegalPage />,
<Suspense fallback={<LoadingFallback />}>
<ClientAgreementsPage />
</Suspense>
),
}, },
{ {
path: "ecosystem", path: 'blog',
element: ( element: <BlogPage />,
<Suspense fallback={<LoadingFallback />}> }
<EcosystemPage />
</Suspense>
),
},
{
path: "showcase",
element: (
<Suspense fallback={<LoadingFallback />}>
<ShowcasePage />
</Suspense>
),
},
], ],
}, },
{ {
path: "/admin", path: '/admin',
element: ( element: (
<RequireAuth> <RequireAuth>
<RequireRole role="ADMIN"> <RequireRole role="ADMIN">
@ -181,7 +117,7 @@ export const router = createBrowserRouter([
element: <DirectoryPage />, element: <DirectoryPage />,
}, },
{ {
path: "assets", path: 'assets',
element: ( element: (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<AssetsPage /> <AssetsPage />
@ -189,7 +125,7 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: "legal", path: 'legal',
element: ( element: (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<LegalTemplatesPage /> <LegalTemplatesPage />
@ -197,37 +133,29 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: "approvals", path: 'analytics',
element: <AnalyticsPage />,
},
{
path: 'blog',
element: <div>Blog Management Coming Soon</div>
},
{
path: 'approvals',
element: ( element: (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<ApprovalsPage /> <ApprovalsPage />
</Suspense> </Suspense>
), )
}, },
{ {
path: "partners", path: 'partners',
element: ( element: (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<DirectoryPage /> <DirectoryPage />
</Suspense> </Suspense>
), )
}, }
{ ]
path: "groups/:groupId", }
element: (
<Suspense fallback={<LoadingFallback />}>
<GroupDetailsPage />
</Suspense>
),
},
{
path: "ecosystem",
element: (
<Suspense fallback={<LoadingFallback />}>
<EcosystemManagerPage />
</Suspense>
),
},
],
},
]); ]);

View File

@ -2,7 +2,7 @@ import { Outlet, Link } from '@tanstack/react-router';
import { useAuthStore } from '../../hooks/use-auth'; import { useAuthStore } from '../../hooks/use-auth';
import { useThemeStore } from '../../hooks/use-theme'; import { useThemeStore } from '../../hooks/use-theme';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { LayoutDashboard, FolderKanban, FileSignature, Users, LogOut, Sun, Moon } from 'lucide-react'; import { LayoutDashboard, FolderKanban, FileSignature, Users, LogOut, Hexagon, Sun, Moon } from 'lucide-react';
export const MainLayout = () => { export const MainLayout = () => {
const { isAuthenticated, logout, user } = useAuthStore(); const { isAuthenticated, logout, user } = useAuthStore();
@ -29,8 +29,8 @@ export const MainLayout = () => {
> >
<div className="h-24 flex items-center px-8 border-b border-ink-200"> <div className="h-24 flex items-center px-8 border-b border-ink-200">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="relative flex items-center justify-center w-11 h-11 rounded-2xl bg-ink-0 border border-ink-200 shadow-sm p-1"> <div className="relative flex items-center justify-center w-11 h-11 rounded-2xl bg-gradient-to-tr from-ink-900 to-ink-800 shadow-lg">
<img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" /> <Hexagon className="text-ink-0 w-6 h-6 absolute" />
</div> </div>
<span className="font-extrabold text-xl tracking-tight text-ink-900">Tech4Biz</span> <span className="font-extrabold text-xl tracking-tight text-ink-900">Tech4Biz</span>
</div> </div>

View File

@ -1,47 +0,0 @@
import React from 'react';
interface PageLayoutProps {
header: React.ReactNode;
toolbar?: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
className?: string;
}
export const PageLayout: React.FC<PageLayoutProps> = ({
header,
toolbar,
children,
footer,
className = '',
}) => {
return (
<div className={`flex-1 flex flex-col min-h-0 h-full w-full overflow-hidden ${className}`}>
{/* Page Header (Fixed) */}
<div className="shrink-0 mb-3 sm:mb-4">
{header}
</div>
{/* Toolbar (Fixed) */}
{toolbar && (
<div className="shrink-0 mb-3 sm:mb-4">
{toolbar}
</div>
)}
{/* Scrollable Content Area */}
<div className="flex-1 min-h-0 overflow-y-auto bg-ink-0 border border-ink-200 rounded-2xl shadow-sm relative flex flex-col scrollbar-thin">
{children}
</div>
{/* Page-level Footer/Pagination (Fixed) */}
{footer && (
<div className="shrink-0 mt-3 sm:mt-4">
{footer}
</div>
)}
</div>
);
};
export default PageLayout;

View File

@ -1,45 +0,0 @@
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'xs' | 'sm' | 'md';
icon?: React.ReactNode;
}
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'sm',
icon,
children,
className = '',
...props
}) => {
// Base classes for the button
const baseClasses = 'inline-flex items-center justify-center font-semibold tracking-wider transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed select-none font-sans shrink-0 border';
// Variant classes
const variantClasses = {
primary: 'bg-ink-900 hover:bg-ink-800 text-ink-0 border-transparent shadow-sm hover-lift',
secondary: 'bg-ink-100 hover:bg-ink-200 text-ink-800 border-ink-200',
ghost: 'bg-transparent hover:bg-ink-100 text-ink-700 border-ink-200',
danger: 'bg-red-500 hover:bg-red-600 text-white border-transparent shadow-sm',
};
// Size classes
const sizeClasses = {
xs: 'text-[10px] uppercase tracking-wider py-1.5 px-3 rounded-lg gap-1.5',
sm: 'text-xs uppercase tracking-wider py-2 px-3.5 rounded-lg gap-2',
md: 'text-xs uppercase tracking-wider py-2.5 px-4.5 rounded-xl gap-2.5',
};
const combinedClassName = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
return (
<button className={combinedClassName} {...props}>
{icon && <span className="inline-flex items-center justify-center shrink-0">{icon}</span>}
<span>{children}</span>
</button>
);
};
export default Button;

View File

@ -1,687 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Sparkles, X, Send, Bot, User as UserIcon, FileText, Minimize2, Maximize2, RefreshCw, Compass, Eye, Plus, History, MessageSquare } from 'lucide-react';
import { axiosInstance } from '../../services/axios';
import { useAuthStore } from '../../hooks/use-auth';
import { AssetViewerModal } from '../../features/assets/components/AssetViewerModal';
import MarkdownViewer from './MarkdownViewer';
import type { Asset } from '../../types/assets';
export interface CitationItem {
assetId: string;
title: string;
location: string;
type: string;
isRecommended?: boolean;
}
export interface ChatMessage {
id?: string;
sender: 'USER' | 'ASSISTANT';
content: string;
citations?: CitationItem[];
createdAt?: string;
}
export interface ChatSessionItem {
id: string;
createdAt: string;
updatedAt: string;
messages?: ChatMessage[];
}
export interface AttachedEntity {
id: string;
title: string;
type: string;
entityKind: 'ASSET' | 'SHOWCASE' | 'ECOSYSTEM' | 'LEGAL';
url?: string;
description?: string;
problemStatement?: string;
solution?: string;
thumbnailUrl?: string;
tags?: string[];
}
export const ChatDrawer: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuthStore();
const [isOpen, setIsOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [drawerSize, setDrawerSize] = useState<'standard' | 'wide' | 'maximized'>('standard');
const [showHistory, setShowHistory] = useState(false);
const [prompt, setPrompt] = useState('');
const [loading, setLoading] = useState(false);
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [pastSessions, setPastSessions] = useState<ChatSessionItem[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const [attachedEntities, setAttachedEntities] = useState<AttachedEntity[]>([]);
const [isDraggingOver, setIsDraggingOver] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([
{
sender: 'ASSISTANT',
content: 'Hello! I am your **Tech4Biz AI Advisor Workbench**. **Drag and drop any asset, case study reel, ecosystem offering** here to inspect, summarize, and receive instant role-tailored explanations!',
}
]);
const [activePreviewAsset, setActivePreviewAsset] = useState<Asset | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [hasActiveOverlay, setHasActiveOverlay] = useState(false);
useEffect(() => {
const checkOverlays = () => {
const overlays = document.querySelectorAll('.fixed.inset-0.z-50, .fixed.inset-y-0.right-0');
setHasActiveOverlay(overlays.length > 0);
};
checkOverlays();
const interval = setInterval(checkOverlays, 300);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (isOpen) {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, isOpen]);
// Listen for custom attach event from card button clicks
useEffect(() => {
const handleAttachEvent = (e: Event) => {
const customEvent = e as CustomEvent;
if (customEvent.detail) {
const entity = customEvent.detail as AttachedEntity;
setIsOpen(true);
setIsMinimized(false);
setAttachedEntities(prev => {
if (prev.some(item => item.id === entity.id)) return prev;
return [...prev, entity];
});
}
};
window.addEventListener('attach-ai-entity', handleAttachEvent);
return () => window.removeEventListener('attach-ai-entity', handleAttachEvent);
}, []);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDraggingOver(false);
const jsonStr = e.dataTransfer.getData('application/json');
if (jsonStr) {
try {
const entity = JSON.parse(jsonStr) as AttachedEntity;
if (entity.id && entity.title) {
setIsOpen(true);
setIsMinimized(false);
setAttachedEntities(prev => {
if (prev.some(item => item.id === entity.id)) return prev;
return [...prev, entity];
});
}
} catch (err) {
console.error('Failed to parse dropped entity payload', err);
}
}
};
// Load user session history when history drawer is opened
const fetchSessionHistory = async () => {
setLoadingHistory(true);
try {
const res = await axiosInstance.get('/chat/history');
setPastSessions(res.data || []);
} catch (err) {
console.error('Failed to fetch chat history:', err);
} finally {
setLoadingHistory(false);
}
};
const handleToggleHistory = () => {
if (!showHistory) {
fetchSessionHistory();
}
setShowHistory(!showHistory);
};
const handleStartNewChat = async () => {
setLoading(true);
try {
const res = await axiosInstance.post('/chat/sessions');
setSessionId(res.data.sessionId);
setMessages([
{
sender: 'ASSISTANT',
content: 'Started a new session! **Drag & Drop** any catalog asset, case study, or ecosystem offering below for instant AI analysis.',
}
]);
setShowHistory(false);
setAttachedEntities([]);
} catch (err) {
console.error('Failed to create new session', err);
} finally {
setLoading(false);
}
};
const handleLoadSession = async (sessId: string) => {
setLoading(true);
try {
const res = await axiosInstance.get(`/chat/sessions/${sessId}`);
setSessionId(sessId);
const loadedMessages = (res.data.messages || []).map((m: any) => ({
id: m.id,
sender: m.sender,
content: m.content,
citations: m.citations || [],
createdAt: m.createdAt,
}));
setMessages(loadedMessages.length > 0 ? loadedMessages : [
{
sender: 'ASSISTANT',
content: 'Loaded past session. Ask your follow up questions below!',
}
]);
setShowHistory(false);
} catch (err) {
console.error('Failed to load session', err);
} finally {
setLoading(false);
}
};
const handleSend = async (customPrompt?: string) => {
const textToSend = customPrompt || prompt;
if ((!textToSend.trim() && attachedEntities.length === 0) || loading) return;
const effectiveText = textToSend.trim() || `Explain and analyze the attached ${attachedEntities.length} dropped entity/entities in detail.`;
const userMsg: ChatMessage = {
sender: 'USER',
content: effectiveText + (attachedEntities.length > 0 ? `\n\n📌 *Attached Items:* ${attachedEntities.map(e => e.title).join(', ')}` : ''),
createdAt: new Date().toISOString(),
};
setMessages(prev => [...prev, userMsg]);
if (!customPrompt) setPrompt('');
const currentAttached = [...attachedEntities];
setAttachedEntities([]);
setLoading(true);
try {
const response = await axiosInstance.post('/chat/query', {
prompt: effectiveText,
sessionId,
attachedEntities: currentAttached,
});
const { sessionId: newSessionId, message } = response.data;
setSessionId(newSessionId);
setMessages(prev => [...prev, {
id: message.id,
sender: 'ASSISTANT',
content: message.content,
citations: message.citations || [],
createdAt: message.createdAt,
}]);
} catch (err: any) {
console.error('Chat API Error:', err);
setMessages(prev => [...prev, {
sender: 'ASSISTANT',
content: 'Sorry, I encountered an issue connecting to the AI Gateway. Please try again.',
}]);
} finally {
setLoading(false);
}
};
const openAssetPreview = async (cite: CitationItem) => {
try {
if (cite.location === 'Featured Content Showcase') {
const showcaseRes = await axiosInstance.get('/showcase');
const showcaseItem = showcaseRes.data.find((item: any) => item.id === cite.assetId || item.title === cite.title);
if (showcaseItem) {
setActivePreviewAsset({
id: showcaseItem.id,
title: showcaseItem.title,
type: 'case_study',
url: showcaseItem.youtubeUrl || showcaseItem.mediaUrl || 'https://www.youtube.com/watch?v=svJDGYlQLYw',
description: showcaseItem.description || `Verified Showcase Reel: ${showcaseItem.title}`,
isDownloadable: false,
createdAt: showcaseItem.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
categoryId: null,
organizationId: null,
} as any);
return;
}
}
const res = await axiosInstance.get(`/assets/${cite.assetId}`);
setActivePreviewAsset(res.data);
} catch (err) {
console.error('Failed to fetch asset from API, constructing fallback preview:', err);
let videoUrl = '';
if (cite.title.toLowerCase().includes('digital twin') || cite.title.toLowerCase().includes('digitaltwin')) {
videoUrl = 'https://www.youtube.com/watch?v=svJDGYlQLYw';
} else if (cite.title.toLowerCase().includes('surveillance')) {
videoUrl = 'https://www.youtube.com/watch?v=svJDGYlQLYw';
}
setActivePreviewAsset({
id: cite.assetId,
title: cite.title,
type: cite.type || 'case_study',
url: videoUrl,
description: `Verified Document Source: ${cite.title} (${cite.location})`,
isDownloadable: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
categoryId: null,
organizationId: null,
} as any);
}
};
const handleGoToAsset = (cite: CitationItem) => {
setIsMinimized(true);
const typeLower = (cite.type || '').toLowerCase();
const titleLower = (cite.title || '').toLowerCase();
const isCurrentAdmin = location.pathname.startsWith('/admin') || user?.role === 'ADMIN';
if (typeLower.includes('case_study') || titleLower.includes('case study') || typeLower.includes('video')) {
navigate(isCurrentAdmin ? '/admin/showcase' : '/client/showcase', { state: { highlightAssetId: cite.assetId } });
} else if (titleLower.includes('nda') || titleLower.includes('msa') || titleLower.includes('agreement') || typeLower.includes('legal')) {
navigate(isCurrentAdmin ? '/admin/legal' : '/client/agreements', { state: { highlightAssetId: cite.assetId } });
} else if (typeLower.includes('ecosystem') || titleLower.includes('offering')) {
navigate(isCurrentAdmin ? '/admin/ecosystem' : '/client/ecosystem', { state: { highlightAssetId: cite.assetId } });
} else {
navigate(isCurrentAdmin ? '/admin/assets' : '/client/assets', { state: { highlightAssetId: cite.assetId } });
}
};
const cycleSize = () => {
if (drawerSize === 'standard') setDrawerSize('wide');
else if (drawerSize === 'wide') setDrawerSize('maximized');
else setDrawerSize('standard');
};
const getDimensions = () => {
if (isMinimized) return { width: 'min(380px, 94vw)', height: '56px' };
if (drawerSize === 'maximized') return { width: 'min(880px, 96vw)', height: 'min(84vh, 900px)' };
if (drawerSize === 'wide') return { width: 'min(660px, 95vw)', height: 'min(700px, 82vh)' };
return { width: 'min(440px, 95vw)', height: 'min(600px, 80vh)' };
};
const dimensions = getDimensions();
return (
<>
{/* Draggable Floating Trigger Pill */}
{!isOpen && !hasActiveOverlay && (
<motion.button
drag
dragConstraints={{ left: -1200, right: 20, top: -800, bottom: 20 }}
dragElastic={0.1}
dragMomentum={false}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => { setIsOpen(true); setIsMinimized(false); }}
className="fixed bottom-14 right-6 sm:bottom-16 sm:right-8 z-30 flex items-center gap-2.5 px-4 py-3 bg-slate-900 text-white rounded-full shadow-2xl border border-slate-700/80 hover:border-emerald-500/80 transition-all cursor-grab active:cursor-grabbing group select-none"
>
<div className="p-1.5 rounded-full bg-emerald-500/20 text-emerald-400 border border-emerald-500/40 group-hover:scale-110 transition-transform">
<Bot className="w-4 h-4" />
</div>
<span className="text-xs font-extrabold tracking-wide font-sans">
AI Advisor
</span>
<span className="flex h-2 w-2 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
</span>
</motion.button>
)}
{/* Floating Glassmorphic Drawer Window */}
<AnimatePresence>
{isOpen && (
<motion.div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
initial={{ opacity: 0, y: 40, scale: 0.95 }}
animate={{
opacity: 1,
y: 0,
scale: 1,
height: dimensions.height,
width: dimensions.width
}}
exit={{ opacity: 0, y: 40, scale: 0.95 }}
transition={{ type: 'spring', damping: 25, stiffness: 220 }}
className="fixed sm:bottom-6 sm:right-6 bottom-0 right-0 left-0 sm:left-auto z-50 bg-slate-950/95 backdrop-blur-2xl border border-slate-800 sm:rounded-3xl rounded-t-2xl shadow-2xl flex flex-col overflow-hidden text-slate-100 font-sans max-w-full"
>
{/* Window Header (Clean Layout) */}
<div className="px-4 py-3 bg-slate-900/90 border-b border-slate-800 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-xl bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
<Bot className="w-4 h-4" />
</div>
<div>
<h3 className="font-bold text-xs text-white">Tech4Biz AI Advisor</h3>
<p className="text-[10px] text-slate-400">Enterprise Intelligent Assistant</p>
</div>
</div>
<div className="flex items-center gap-1.5">
{/* + New Chat Button */}
{!isMinimized && (
<button
type="button"
onClick={handleStartNewChat}
title="Start New Chat Session"
className="p-1.5 rounded-lg bg-emerald-500/20 text-emerald-300 hover:bg-emerald-500/30 border border-emerald-500/40 text-[10px] font-bold flex items-center gap-1 transition-all cursor-pointer mr-1"
>
<Plus className="w-3.5 h-3.5" />
<span className="hidden sm:inline">New Chat</span>
</button>
)}
{/* Session History Toggle Button */}
{!isMinimized && (
<button
type="button"
onClick={handleToggleHistory}
title="Past Chat Sessions"
className={`p-1.5 rounded-lg border transition-colors cursor-pointer ${showHistory
? 'bg-emerald-500/30 text-emerald-300 border-emerald-500/50'
: 'text-slate-400 hover:text-white hover:bg-slate-800 border-slate-800'
}`}
>
<History className="w-3.5 h-3.5" />
</button>
)}
{/* Resize Drawer Cycle Button */}
{!isMinimized && (
<button
onClick={cycleSize}
title={`Current size: ${drawerSize.toUpperCase()}. Click to toggle window width.`}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
>
{drawerSize === 'maximized' ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
</button>
)}
{/* Minimize Button */}
<button
onClick={() => setIsMinimized(!isMinimized)}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
>
{isMinimized ? <Maximize2 className="w-3.5 h-3.5" /> : <Minimize2 className="w-3.5 h-3.5" />}
</button>
{/* Close Button */}
<button
onClick={() => setIsOpen(false)}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Past Session History Drawer Side Panel */}
{showHistory && !isMinimized && (
<div className="bg-slate-900 border-b border-slate-800 p-3 max-h-48 overflow-y-auto space-y-2 shrink-0">
<div className="flex items-center justify-between text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">
<span>Saved Past Sessions</span>
<span>{pastSessions.length} sessions</span>
</div>
{loadingHistory ? (
<div className="text-center p-3 text-xs text-slate-400 italic">
Loading chat history...
</div>
) : pastSessions.length === 0 ? (
<div className="text-center p-3 text-xs text-slate-500 italic">
No past sessions found. Start a conversation!
</div>
) : (
pastSessions.map((sess) => {
const firstUserMsg = sess.messages?.find(m => m.sender === 'USER')?.content || 'Chat Session';
const isActive = sess.id === sessionId;
return (
<div
key={sess.id}
onClick={() => handleLoadSession(sess.id)}
className={`p-2 rounded-xl border text-xs cursor-pointer flex items-center justify-between transition-all ${isActive
? 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40'
: 'bg-slate-950/70 hover:bg-slate-800 text-slate-300 border-slate-800'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<MessageSquare className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
<span className="truncate text-[11px] font-medium">{firstUserMsg}</span>
</div>
<span className="text-[9px] font-mono text-slate-500 shrink-0 ml-2">
{new Date(sess.updatedAt || sess.createdAt).toLocaleDateString()}
</span>
</div>
);
})
)}
</div>
)}
{/* Content Body (Visible when not minimized) */}
{!isMinimized && (
<>
{/* Messages Stream */}
<div className="flex-1 p-4 overflow-y-auto space-y-4 text-xs">
{messages.map((msg, i) => (
<div
key={i}
className={`flex gap-2.5 ${msg.sender === 'USER' ? 'flex-row-reverse' : 'flex-row'}`}
>
<div className={`p-1.5 rounded-xl shrink-0 h-fit ${msg.sender === 'USER'
? 'bg-emerald-600 text-white'
: 'bg-slate-900 text-emerald-400 border border-slate-700'
}`}>
{msg.sender === 'USER' ? <UserIcon className="w-3.5 h-3.5" /> : <Bot className="w-3.5 h-3.5" />}
</div>
<div className={`space-y-2 max-w-[85%] ${msg.sender === 'USER' ? 'text-right' : 'text-left'}`}>
<div className={`p-3.5 rounded-2xl leading-relaxed ${msg.sender === 'USER'
? 'bg-emerald-600 text-white font-medium rounded-tr-none whitespace-pre-wrap'
: 'bg-slate-900 text-slate-100 border border-slate-800 rounded-tl-none shadow-md'
}`}>
{msg.sender === 'USER' ? (
msg.content
) : (
<MarkdownViewer markdown={msg.content} variant="dark" />
)}
</div>
{/* Citation Cards with Direct Preview & Location Redirection */}
{msg.citations && msg.citations.length > 0 && (
<div className="space-y-1.5 pt-1">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider text-left">
Verified Knowledge Sources ({msg.citations.length}):
</p>
<div className="space-y-2">
{msg.citations.map((cite) => (
<div
key={cite.assetId}
className="group p-2.5 rounded-xl bg-slate-900/90 border border-slate-800 hover:border-emerald-500/50 transition-all text-left flex flex-col gap-2"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-emerald-400 shrink-0 mt-0.5" />
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-bold text-[11px] text-white group-hover:text-emerald-300 truncate">
{cite.title}
</span>
{cite.isRecommended && (
<span className="px-1.5 py-0.2 rounded bg-amber-500/20 text-amber-300 border border-amber-500/30 text-[8px] font-extrabold uppercase shrink-0">
Recommended
</span>
)}
</div>
<span className="text-[9px] text-slate-400 block font-mono">
{cite.location}
</span>
</div>
</div>
</div>
{/* Action Buttons: Preview & Go to Location */}
<div className="flex items-center gap-2 pt-1 border-t border-slate-800">
<button
type="button"
onClick={() => openAssetPreview(cite)}
className="flex-1 py-1 px-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-[10px] font-bold flex items-center justify-center gap-1 transition-colors cursor-pointer"
>
<Eye className="w-3 h-3 text-slate-400" />
<span>Quick Preview</span>
</button>
<button
type="button"
onClick={() => handleGoToAsset(cite)}
className="flex-1 py-1 px-2 bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 border border-emerald-500/40 rounded-lg text-[10px] font-bold flex items-center justify-center gap-1 transition-colors cursor-pointer"
>
<Compass className="w-3 h-3 text-emerald-400" />
<span>Go to Location</span>
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
))}
{loading && (
<div className="flex items-center gap-2.5 text-slate-400 text-xs italic p-2">
<RefreshCw className="w-3.5 h-3.5 animate-spin text-emerald-400" />
Analyzing catalog knowledge...
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Attached Entities Chip Bar */}
{attachedEntities.length > 0 && (
<div className="px-3 py-2 bg-slate-950 border-t border-slate-800 flex flex-wrap gap-1.5 shrink-0 max-h-28 overflow-y-auto">
<div className="w-full flex items-center justify-between text-[10px] font-extrabold uppercase tracking-wider text-amber-400">
<span className="flex items-center gap-1">
<Sparkles className="w-3 h-3 text-amber-400 fill-amber-400" />
Attached Workbench Entities ({attachedEntities.length}):
</span>
<button
onClick={() => setAttachedEntities([])}
className="text-slate-400 hover:text-slate-200 transition-colors text-[9px] cursor-pointer"
>
Clear All
</button>
</div>
{attachedEntities.map(ent => (
<span
key={ent.id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-bold bg-slate-800 text-slate-100 border border-slate-700 shadow-sm"
>
<span className="text-[9px] font-black uppercase px-1.5 py-0.5 rounded bg-amber-500 text-slate-950">
{ent.entityKind}
</span>
<span className="truncate max-w-[160px] text-white">{ent.title}</span>
<button
onClick={() => setAttachedEntities(prev => prev.filter(x => x.id !== ent.id))}
className="hover:text-red-400 transition-colors text-slate-400 cursor-pointer ml-1"
title="Remove attached item"
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
</div>
)}
{/* Input Controls */}
<div className="p-3 bg-slate-900 border-t border-slate-800 flex gap-2 items-center shrink-0">
<input
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder={attachedEntities.length > 0 ? "Ask AI to analyze or summarize attached items..." : "Ask AI or drag and drop items here..."}
disabled={loading}
className="flex-1 bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500/60 transition-all font-sans"
/>
<button
onClick={() => handleSend()}
disabled={loading || (!prompt.trim() && attachedEntities.length === 0)}
className="p-2 bg-emerald-500 hover:bg-emerald-400 disabled:opacity-40 text-slate-950 rounded-xl transition-all font-bold cursor-pointer disabled:cursor-not-allowed"
>
<Send className="w-4 h-4" />
</button>
</div>
{/* Drag and Drop Target Zone Overlay */}
{isDraggingOver && (
<div className="absolute inset-0 z-50 bg-slate-950/90 backdrop-blur-md border-4 border-dashed border-amber-500 rounded-3xl flex flex-col items-center justify-center p-6 text-center animate-pulse">
<Sparkles className="w-12 h-12 text-amber-400 fill-amber-400 mb-3 animate-bounce" />
<h3 className="text-lg font-black text-slate-950 bg-amber-400 px-4 py-1 rounded-full shadow-lg">
Drop Entity Here for Instant AI Workbench Inspection
</h3>
<p className="text-xs font-bold text-slate-300 mt-2 max-w-xs leading-relaxed">
Attach catalog assets, case studies, partner offerings, or legal agreements to analyze and summarize.
</p>
</div>
)}
</>
)}
</motion.div>
)}
</AnimatePresence>
{/* Asset Preview Modal Triggered from Citation Click */}
{activePreviewAsset && (
<AssetViewerModal
asset={activePreviewAsset}
isOpen={!!activePreviewAsset}
user={user}
onDownload={() => { }}
onClose={() => setActivePreviewAsset(null)}
/>
)}
</>
);
};

View File

@ -1,836 +0,0 @@
import React, { useState, useEffect, useRef } from "react";
import ReactDOM from "react-dom";
import { motion } from "framer-motion";
import {
X,
ZoomIn,
ZoomOut,
RotateCcw,
Download,
Printer,
ShieldCheck,
AlertTriangle,
CheckCircle,
ChevronLeft,
ChevronRight,
FileText,
Maximize,
Minimize,
} from "lucide-react";
import { Button } from "./Button";
interface Acceptance {
id: string;
signatureHash: string | null;
documentUrl: string | null;
signatureBase64: string | null;
ipAddress: string;
acceptedAt: string;
document: {
id: string;
type: string;
version: string;
content: string;
};
}
interface DocumentPreviewModalProps {
isOpen: boolean;
onClose: () => void;
partnerId: string;
partnerEmail: string;
partnerCreatedAt: string;
acceptances: Acceptance[];
verifiedDocs: { nda: boolean; msa: boolean };
onVerify: (docType: "NDA" | "MSA") => void;
onApprovePartner: () => void;
isApproving: boolean;
assignedNdaId?: string | null;
assignedMsaId?: string | null;
}
export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
isOpen,
onClose,
partnerId: _partnerId,
partnerEmail,
partnerCreatedAt: _partnerCreatedAt,
acceptances,
verifiedDocs,
onVerify,
onApprovePartner,
isApproving,
assignedNdaId,
assignedMsaId,
}) => {
const [currentTab, setCurrentTab] = useState<"NDA" | "MSA">("NDA");
const [zoom, setZoom] = useState(100);
const [fitWidth, setFitWidth] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [isFullScreen, setIsFullScreen] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const ndaAcceptance = acceptances.find((a) => a.document.type === "NDA");
const msaAcceptance = acceptances.find((a) => a.document.type === "MSA");
const activeAcceptance = currentTab === "NDA" ? ndaAcceptance : msaAcceptance;
const totalPages = activeAcceptance?.documentUrl ? 1 : 2;
// Prevent background page scrolling when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
// Handle auto-opening on the signature page (Page 2) for digitally signed documents
useEffect(() => {
if (isOpen && activeAcceptance) {
if (!activeAcceptance.documentUrl && activeAcceptance.signatureHash) {
setCurrentPage(2);
} else {
setCurrentPage(1);
}
}
}, [currentTab, isOpen, activeAcceptance]);
// Full Screen logic
const toggleFullScreen = () => {
if (!modalRef.current) return;
if (!isFullScreen) {
if (modalRef.current.requestFullscreen) {
modalRef.current.requestFullscreen().catch(() => {
setIsFullScreen(true);
});
} else {
setIsFullScreen(true);
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen().catch(() => {});
}
setIsFullScreen(false);
}
};
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullScreen(!!document.fullscreenElement);
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () =>
document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, []);
if (!isOpen) return null;
const handleZoomIn = () => {
setFitWidth(false);
setZoom((prev) => Math.min(200, prev + 25));
};
const handleZoomOut = () => {
setFitWidth(false);
setZoom((prev) => Math.max(50, prev - 25));
};
const handleZoomReset = () => {
setFitWidth(false);
setZoom(100);
};
const toggleFitWidth = () => {
setFitWidth(!fitWidth);
};
const handlePrint = () => {
const printContent = document.getElementById("printable-doc-content");
if (!printContent) return;
const windowUrl = "about:blank";
const uniqueName = new Date().getTime();
const printWindow = window.open(
windowUrl,
uniqueName.toString(),
"left=50000,top=50000,width=0,height=0",
);
if (!printWindow) return;
printWindow.document.write(`
<html>
<head>
<title>Print Document - ${currentTab}</title>
<style>
body { font-family: sans-serif; padding: 40px; color: #1b1b1b; }
h1 { font-size: 24px; font-weight: bold; margin-bottom: 20px; text-align: center; }
p { font-size: 14px; line-height: 1.6; white-space: pre-line; }
.sig-box { margin-top: 40px; padding: 20px; border: 2px dashed #ccc; text-align: center; max-width: 400px; margin-left: auto; margin-right: auto; }
.sig-title { font-weight: bold; color: #10b981; margin-bottom: 10px; }
.hash { font-family: monospace; font-size: 11px; word-break: break-all; }
</style>
</head>
<body>
<h1>${currentTab === "NDA" ? "Non-Disclosure Agreement (NDA)" : "Master Services Agreement (MSA)"}</h1>
<p>${activeAcceptance?.document.content || ""}</p>
<div class="sig-box">
<div class="sig-title">Digitally Signed & Verified</div>
<div>Signed By: ${partnerEmail}</div>
<div class="hash">Verification Hash: ${activeAcceptance?.signatureHash || "N/A"}</div>
<div>IP Address: ${activeAcceptance?.ipAddress || "N/A"}</div>
<div>Date Signed: ${activeAcceptance ? new Date(activeAcceptance.acceptedAt).toLocaleString() : ""}</div>
</div>
</body>
</html>
`);
printWindow.document.close();
printWindow.focus();
printWindow.print();
printWindow.close();
};
const handleDownloadText = () => {
if (!activeAcceptance) return;
const element = document.createElement("a");
const file = new Blob(
[
`${currentTab === "NDA" ? "Non-Disclosure Agreement (NDA)" : "Master Services Agreement (MSA)"}\n\n`,
activeAcceptance.document.content,
`\n\n=== DIGITAL SIGNATURE ===\n`,
`Signed By: ${partnerEmail}\n`,
`Verification Hash: ${activeAcceptance.signatureHash || "N/A"}\n`,
`IP Address: ${activeAcceptance.ipAddress}\n`,
`Signed On: ${new Date(activeAcceptance.acceptedAt).toLocaleString()}\n`,
],
{ type: "text/plain" },
);
element.href = URL.createObjectURL(file);
element.download = `${currentTab}_Agreement_${partnerEmail.split("@")[0]}.txt`;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
};
const fileHost = (
import.meta.env.VITE_API_URL || "/api/v1"
).replace("/api/v1", "");
const isNdaVerified = !assignedNdaId || verifiedDocs.nda;
const isMsaVerified = !assignedMsaId || verifiedDocs.msa;
const isBothVerified = isNdaVerified && isMsaVerified;
const isCurrentVerified =
currentTab === "NDA" ? verifiedDocs.nda : verifiedDocs.msa;
// Responsive classes based on screen size
const modalContainerClasses = isFullScreen
? "fixed inset-0 w-screen h-screen bg-ink-0 z-[9999] flex flex-col overflow-hidden"
: "relative w-full h-full sm:w-[90vw] sm:h-[88vh] lg:w-[78vw] lg:h-[86vh] bg-ink-0 border border-ink-200 sm:rounded-2xl shadow-2xl flex flex-col overflow-hidden z-[9999]";
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">
{/* Full-screen Backdrop overlay */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-ink-900/60 backdrop-blur-md z-[9998]"
/>
{/* Modal Box */}
<motion.div
ref={modalRef}
initial={{ opacity: 0, scale: 0.97, y: 15 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 15 }}
transition={{ type: "spring", damping: 26, stiffness: 340 }}
className={modalContainerClasses}
>
{/* Sticky Header */}
<div className="px-5 py-4 border-b border-ink-200 bg-ink-0 flex flex-col md:flex-row md:items-center justify-between gap-3 shrink-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 shrink-0">
<FileText className="w-5 h-5" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-extrabold text-ink-900 tracking-tight truncate">
{currentTab === "NDA"
? "Mutual Non-Disclosure Agreement (NDA)"
: "Master Services Agreement (MSA)"}
</h3>
<p className="text-xs text-ink-500 font-semibold truncate">
Partner: <span className="text-ink-900">{partnerEmail}</span>
</p>
</div>
</div>
{/* Switcher tabs */}
<div className="flex items-center gap-2 bg-ink-50 p-1 rounded-xl border border-ink-200 self-start md:self-auto">
<button
onClick={() => {
setCurrentTab("NDA");
setCurrentPage(1);
}}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
currentTab === "NDA"
? "bg-ink-0 text-ink-900 shadow-sm border border-ink-200"
: "text-ink-500 hover:text-ink-900"
}`}
>
NDA Agreement {verifiedDocs.nda && "✓"}
</button>
<button
onClick={() => {
setCurrentTab("MSA");
setCurrentPage(1);
}}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
currentTab === "MSA"
? "bg-ink-0 text-ink-900 shadow-sm border border-ink-200"
: "text-ink-500 hover:text-ink-900"
}`}
>
MSA Agreement {verifiedDocs.msa && "✓"}
</button>
</div>
<div className="flex items-center gap-1.5 self-end md:self-auto">
<button
onClick={toggleFullScreen}
className="p-2 rounded-lg text-ink-500 hover:text-ink-900 hover:bg-ink-50 transition-all cursor-pointer"
title={isFullScreen ? "Exit Fullscreen" : "Fullscreen"}
>
{isFullScreen ? (
<Minimize className="w-4 h-4" />
) : (
<Maximize className="w-4 h-4" />
)}
</button>
<button
onClick={onClose}
className="p-2 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-all cursor-pointer"
title="Close Modal"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Warning Banner if Unsigned */}
{!activeAcceptance && (currentTab === "NDA" ? assignedNdaId : assignedMsaId) && (
<div className="bg-amber-50 border-b border-amber-200 px-5 py-2.5 flex items-center gap-3 shrink-0">
<AlertTriangle className="w-4.5 h-4.5 text-amber-600 shrink-0" />
<p className="text-xs font-bold text-amber-800">
Attention: This document has not been submitted or signed by the
partner.
</p>
</div>
)}
{/* Professional Document Viewer Toolbar */}
<div className="px-5 py-2.5 bg-ink-50 border-b border-ink-200 flex flex-wrap items-center justify-between gap-3 shrink-0">
{/* Page navigation controls */}
<div className="flex items-center gap-1.5">
<button
disabled={currentPage <= 1 || !activeAcceptance}
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
title="Previous Page"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-xs font-bold text-ink-600 min-w-[70px] text-center">
Page {activeAcceptance ? currentPage : 0} of{" "}
{activeAcceptance ? totalPages : 0}
</span>
<button
disabled={currentPage >= totalPages || !activeAcceptance}
onClick={() =>
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
title="Next Page"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
{/* Zoom controls */}
<div className="flex items-center gap-1.5">
<button
onClick={handleZoomOut}
disabled={
!activeAcceptance || activeAcceptance.documentUrl !== null
}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
title="Zoom Out"
>
<ZoomOut className="w-4 h-4" />
</button>
<span className="text-xs font-bold text-ink-600 min-w-[45px] text-center">
{zoom}%
</span>
<button
onClick={handleZoomIn}
disabled={
!activeAcceptance || activeAcceptance.documentUrl !== null
}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
title="Zoom In"
>
<ZoomIn className="w-4 h-4" />
</button>
<button
onClick={handleZoomReset}
disabled={
!activeAcceptance || activeAcceptance.documentUrl !== null
}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
title="Reset Zoom"
>
<RotateCcw className="w-4 h-4" />
</button>
<button
onClick={toggleFitWidth}
disabled={
!activeAcceptance || activeAcceptance.documentUrl !== null
}
className={`px-2.5 py-1.5 rounded-lg border text-xs font-bold cursor-pointer shadow-sm transition-all ${
fitWidth
? "bg-ink-900 text-ink-0 border-transparent"
: "bg-ink-0 border-ink-200 text-ink-700 hover:bg-ink-50"
}`}
title="Fit Width"
>
Fit Width
</button>
</div>
{/* Download & Print Actions */}
<div className="flex items-center gap-2">
<button
onClick={handlePrint}
disabled={!activeAcceptance}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer flex items-center gap-1.5 text-xs font-bold shadow-sm"
title="Print"
>
<Printer className="w-4 h-4" />
<span className="hidden sm:inline">Print</span>
</button>
<button
onClick={
activeAcceptance?.documentUrl ? undefined : handleDownloadText
}
disabled={!activeAcceptance}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer flex items-center gap-1.5 text-xs font-bold shadow-sm"
title="Download File"
>
{activeAcceptance?.documentUrl ? (
<a
href={`${fileHost}${activeAcceptance.documentUrl}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5"
>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">Download</span>
</a>
) : (
<>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">Download</span>
</>
)}
</button>
</div>
</div>
{/* Modal Main Body (Split Pane) */}
<div className="flex-1 flex flex-col md:flex-row overflow-hidden min-h-0">
{/* Left Pane: Centered Document Viewer */}
<div className="flex-1 flex flex-col bg-ink-100 overflow-hidden relative p-4 sm:p-6 justify-center items-center">
<div
ref={containerRef}
className="w-full h-full overflow-y-auto flex justify-center items-start scrollbar-thin rounded-lg"
>
{activeAcceptance ? (
activeAcceptance.documentUrl ? (
// PDF / Uploaded Document Viewer centered
<div className="w-full h-full max-w-4xl bg-ink-0 shadow-lg rounded-xl overflow-hidden border border-ink-200 flex justify-center items-center">
{activeAcceptance.documentUrl
.toLowerCase()
.endsWith(".pdf") ? (
<iframe
src={`${fileHost}${activeAcceptance.documentUrl}#toolbar=0`}
title="Document PDF"
className="w-full h-full border-0 bg-white"
/>
) : (
<div className="w-full h-full flex items-center justify-center p-4 bg-ink-50 overflow-auto">
<img
src={`${fileHost}${activeAcceptance.documentUrl}`}
alt="Signed legal document upload"
className="max-w-full max-h-full object-contain shadow-md rounded border border-ink-200"
/>
</div>
)}
</div>
) : (
// Digitally Signed text template (Standard A4 letter styled)
<div
id="printable-doc-content"
className="w-full bg-ink-0 p-8 sm:p-12 my-2 rounded-xl shadow-lg border border-ink-200 font-serif leading-relaxed text-ink-800 transition-all duration-150 select-text flex flex-col"
style={
fitWidth
? { width: "100%", maxWidth: "100%", fontSize: "15px" }
: {
width: "100%",
maxWidth: "720px",
fontSize: `${14 * (zoom / 100)}px`,
}
}
>
{currentPage === 1 ? (
// Page 1: Agreement text
<div className="flex-1">
<div className="text-center mb-8 not-italic font-sans">
<h4 className="text-base sm:text-lg font-extrabold text-ink-900 tracking-tight">
{currentTab === "NDA"
? "MUTUAL NON-DISCLOSURE AGREEMENT"
: "MASTER SERVICES AGREEMENT"}
</h4>
<div className="w-16 h-1 bg-ink-900 mx-auto my-3" />
<p className="text-[10px] text-ink-400 font-bold uppercase tracking-wider">
Version {activeAcceptance.document.version}
</p>
</div>
<div className="whitespace-pre-line prose max-w-none text-xs sm:text-sm">
{activeAcceptance.document.content}
</div>
<div className="mt-8 pt-4 border-t border-ink-150 text-center not-italic font-sans text-xs text-ink-400 font-semibold">
--- Page 1 of 2 (Standard Terms) ---
</div>
</div>
) : (
// Page 2: Actual signature rendering + metadata
<div className="flex-1 flex flex-col justify-between">
<div>
<div className="text-center mb-6 not-italic font-sans">
<h4 className="text-base sm:text-lg font-extrabold text-ink-900 tracking-tight">
SIGNATURE &amp; ACCORD SIGNING PAGE
</h4>
<p className="text-[10px] text-ink-400 font-bold uppercase tracking-wider mt-1">
Agreement Version{" "}
{activeAcceptance.document.version}
</p>
</div>
<p className="text-xs sm:text-sm mb-6 text-ink-600 font-sans italic">
IN WITNESS WHEREOF, the parties hereto have caused
this Agreement to be executed by their digital
signatures as of the Acceptance Date specified
below.
</p>
</div>
{/* ── Actual Signature Rendering ── */}
<div className="my-auto max-w-lg mx-auto w-full space-y-4">
{/* Signature image box */}
<div className="border-2 border-ink-200 rounded-xl overflow-hidden bg-white shadow-sm">
<div className="px-4 pt-3 pb-1 border-b border-ink-100 flex items-center justify-between">
<span className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">
Client Signature
</span>
<div className="flex items-center gap-1 text-emerald-600">
<ShieldCheck className="w-3.5 h-3.5" />
<span className="text-[10px] font-bold uppercase tracking-wider">
Verified
</span>
</div>
</div>
<div className="p-4 min-h-[100px] flex items-center justify-center bg-white">
{activeAcceptance.signatureBase64 ? (
// Render the exact drawn signature as-is — no modification
<img
src={activeAcceptance.signatureBase64}
alt="Client drawn signature"
className="max-w-full max-h-[160px] object-contain"
style={{ imageRendering: "crisp-edges", filter: "brightness(0)" }}
/>
) : (
// Upload-based signing — no drawn image stored
<div className="text-center py-4">
<ShieldCheck className="w-8 h-8 text-emerald-500 mx-auto mb-2" />
<p className="text-xs text-ink-500 font-semibold">
Signed via document upload
</p>
<p className="text-[10px] text-ink-400 mt-0.5">
See uploaded file in viewer
</p>
</div>
)}
</div>
{/* Signature baseline line */}
<div className="px-6 pb-3">
<div className="border-b-2 border-ink-300 w-full" />
<p className="text-[9px] text-ink-400 font-bold uppercase tracking-widest mt-1 text-center">
Authorized Digital Signature
</p>
</div>
</div>
{/* Verification metadata strip */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-[10px] sm:text-xs font-sans bg-ink-50 border border-ink-200 rounded-xl p-4">
<div>
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">
Signed By
</p>
<p className="font-bold text-ink-900 mt-0.5 truncate">
{partnerEmail}
</p>
</div>
<div>
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">
Accepted On
</p>
<p className="font-semibold text-ink-900 mt-0.5">
{new Date(
activeAcceptance.acceptedAt,
).toLocaleString()}
</p>
</div>
<div>
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">
IP Address
</p>
<p className="font-semibold font-mono text-ink-900 mt-0.5">
{activeAcceptance.ipAddress}
</p>
</div>
<div>
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">
Document Version
</p>
<p className="font-semibold text-ink-900 mt-0.5">
v{activeAcceptance.document.version}
</p>
</div>
{activeAcceptance.signatureHash && (
<div className="col-span-2">
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">
Verification Hash (SHA-256)
</p>
<p className="font-mono text-[9px] text-ink-500 bg-ink-100 p-1.5 rounded border border-ink-200 break-all mt-0.5">
{activeAcceptance.signatureHash}
</p>
</div>
)}
</div>
</div>
<div className="mt-8 pt-4 border-t border-ink-150 text-center not-italic font-sans text-xs text-ink-400 font-semibold">
--- Page 2 of 2 (Signature &amp; Seals) ---
</div>
</div>
)}
</div>
)
) : (
<div className="w-full max-w-md my-auto flex flex-col items-center justify-center text-center p-8 bg-ink-0 rounded-2xl border border-ink-200 shadow-lg">
<AlertTriangle className="w-12 h-12 text-ink-400 mb-3" />
<h4 className="text-base font-extrabold text-ink-900">
{!(currentTab === "NDA" ? assignedNdaId : assignedMsaId) ? "Agreement Not Required" : "Document Unavailable"}
</h4>
<p className="text-xs text-ink-500 mt-1.5 max-w-xs leading-relaxed font-semibold">
{!(currentTab === "NDA" ? assignedNdaId : assignedMsaId)
? "This partner onboarding configuration does not require signing this agreement."
: `The partner has not yet submitted or signed the ${currentTab} document.`}
</p>
</div>
)}
</div>
</div>
{/* Right Pane: Document Details & Metadata */}
<div className="w-full md:w-80 border-t md:border-t-0 md:border-l border-ink-200 bg-ink-0 flex flex-col overflow-y-auto shrink-0 p-5 space-y-5">
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-ink-400 mb-3">
Onboarding Details
</h4>
<div className="space-y-3">
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">
Document Category
</span>
<span className="font-bold text-ink-900 bg-ink-100 px-2 py-0.5 rounded border border-ink-200">
{currentTab}
</span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">
Submission State
</span>
{activeAcceptance ? (
<span className="font-bold text-emerald-700 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">
Signed
</span>
) : (
<span className="font-bold text-amber-700 bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">
Pending
</span>
)}
</div>
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">
Digital Signature
</span>
{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-red-650 bg-red-500/10 px-2 py-0.5 rounded border border-red-500/20">
Not Verified
</span>
)}
</div>
</div>
</div>
<div className="border-t border-ink-100 pt-4">
<h4 className="text-xs font-bold uppercase tracking-wider text-ink-400 mb-3">
Legal Verification
</h4>
{activeAcceptance ? (
<div className="space-y-3 text-xs">
<div>
<p className="font-semibold text-ink-400 text-[10px] uppercase">
Signed By
</p>
<p className="font-bold text-ink-900 truncate mt-0.5">
{partnerEmail}
</p>
</div>
<div>
<p className="font-semibold text-ink-400 text-[10px] uppercase">
Signed On
</p>
<p className="font-bold text-ink-900 mt-0.5">
{new Date(activeAcceptance.acceptedAt).toLocaleString()}
</p>
</div>
<div>
<p className="font-semibold text-ink-400 text-[10px] uppercase">
IP Address
</p>
<p className="font-bold text-ink-900 mt-0.5 font-mono">
{activeAcceptance.ipAddress}
</p>
</div>
<div>
<p className="font-semibold text-ink-400 text-[10px] uppercase">
Version
</p>
<p className="font-bold text-ink-900 mt-0.5">
v{activeAcceptance.document.version}
</p>
</div>
{activeAcceptance.signatureHash && (
<div>
<p className="font-semibold text-ink-400 text-[10px] uppercase">
Verification Hash
</p>
<p className="font-mono text-[10px] text-ink-650 bg-ink-50 p-1.5 rounded border border-ink-150 break-all mt-0.5">
{activeAcceptance.signatureHash}
</p>
</div>
)}
</div>
) : (
<p className="text-xs text-ink-400 italic">
No verification metadata available.
</p>
)}
</div>
{activeAcceptance?.signatureBase64 && (
<div className="border-t border-ink-100 pt-4">
<h4 className="text-xs font-bold uppercase tracking-wider text-ink-400 mb-2">
Captured Signature
</h4>
<div className="border border-ink-200 rounded-lg p-2 bg-white flex items-center justify-center min-h-[60px] shadow-sm">
<img
src={activeAcceptance.signatureBase64}
alt="Signature"
className="max-h-12 object-contain"
style={{ filter: 'brightness(0)' }}
/>
</div>
</div>
)}
<div className="mt-auto pt-4 border-t border-ink-100 space-y-2">
<Button
onClick={() => onVerify(currentTab)}
disabled={!activeAcceptance || !(currentTab === "NDA" ? assignedNdaId : assignedMsaId)}
variant={!(currentTab === "NDA" ? assignedNdaId : assignedMsaId) || isCurrentVerified ? "secondary" : "primary"}
className="w-full flex justify-center items-center gap-1.5"
>
{!(currentTab === "NDA" ? assignedNdaId : assignedMsaId) ? (
<span>No Verification Needed</span>
) : isCurrentVerified ? (
<>
<div className="flex gap-2 items-center">
<CheckCircle className="w-4 h-4 text-emerald-600" />
<span>Document Verified</span>
</div>
</>
) : (
<span>Verify {currentTab} Signature</span>
)}
</Button>
<p className="text-[10px] text-ink-400 text-center font-medium leading-normal">
{!(currentTab === "NDA" ? assignedNdaId : assignedMsaId)
? "This agreement is not required for this partner."
: "Marking this verified unlocks approval options for the administrator."}
</p>
</div>
</div>
</div>
{/* Sticky Footer */}
<div className="px-6 py-4 border-t border-ink-200 bg-ink-50 flex flex-col sm:flex-row justify-between items-center gap-4 shrink-0">
<div className="text-xs font-semibold text-ink-500">
{isBothVerified ? (
<span className="text-emerald-700 font-bold flex items-center gap-1.5">
<CheckCircle className="w-4.5 h-4.5" /> Required agreements
verified. Access approval unlocked.
</span>
) : (
<span className="flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4 text-amber-500" /> Please
review and verify the pending required documents.
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto justify-end">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
onClick={onApprovePartner}
disabled={isApproving}
variant="primary"
className="font-bold shadow-md hover:shadow-lg transition-shadow"
>
{isApproving ? "Approving Partner..." : "Approve Partner Access"}
</Button>
</div>
</div>
</motion.div>
</div>,
document.body,
);
};

View File

@ -1,549 +0,0 @@
import React from "react";
interface MarkdownBlock {
type: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "code" | "blockquote" | "ul" | "ol" | "hr" | "p" | "table";
content: string;
language?: string;
items?: string[];
headers?: string[];
rows?: string[][];
}
interface ParseState {
blocks: MarkdownBlock[];
currentCodeBlock: { language: string; lines: string[] } | null;
currentList: { type: "ul" | "ol"; items: string[] } | null;
currentTableLines: string[];
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 flushTable = (state: ParseState): void => {
if (state.currentTableLines.length > 0) {
const rawLines = state.currentTableLines;
state.currentTableLines = [];
// Filter out separator lines like |---|---|
const parsedRows = rawLines
.filter(line => !/^[|\s-:]+$/.test(line.trim()))
.map(line => {
const cells = line.split('|').map(c => c.trim());
// Remove empty lead/trail cells from leading/trailing pipes
if (cells.length > 0 && cells[0] === '') cells.shift();
if (cells.length > 0 && cells[cells.length - 1] === '') cells.pop();
return cells;
})
.filter(row => row.length > 0);
if (parsedRows.length > 0) {
const headers = parsedRows[0];
const rows = parsedRows.slice(1);
state.blocks.push({
type: "table",
content: "",
headers,
rows,
});
}
}
};
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);
flushTable(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);
flushTable(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);
flushTable(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);
flushTable(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);
flushTable(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 handleTableLine = (trimmed: string, state: ParseState): boolean => {
// Check if line contains markdown table pipes
if (trimmed.includes("|") && (trimmed.startsWith("|") || trimmed.includes(" | ") || /^[-|\s:]+$/.test(trimmed))) {
flushParagraph(state);
flushList(state);
state.currentTableLines.push(trimmed);
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);
flushTable(state);
state.blocks.push({ type: "hr", content: "" });
return;
}
if (handleHeading(line, state) || handleBlockquote(trimmed, state)) {
return;
}
if (handleLists(line, state)) {
return;
}
if (handleTableLine(trimmed, state)) {
return;
}
if (trimmed === "") {
flushParagraph(state);
flushList(state);
flushTable(state);
return;
}
flushList(state);
flushTable(state);
state.currentParagraphLines.push(line);
};
/**
* Preprocesses raw markdown text to split single-line concatenated markdown table rows and merge split table rows across lines.
*/
const sanitizeMarkdownText = (rawText: string): string => {
if (!rawText) return "";
const lines = rawText.split("\n");
const processedLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// If the line starts with a pipe but doesn't end with one, it is likely split across linebreaks
if (trimmed.startsWith("|") && !trimmed.endsWith("|")) {
let merged = line;
while (i + 1 < lines.length) {
const nextLine = lines[i + 1];
const nextTrimmed = nextLine.trim();
merged += " " + nextTrimmed;
i++;
if (nextTrimmed.endsWith("|")) {
break;
}
}
processedLines.push(merged);
} else {
processedLines.push(line);
}
}
let formatted = processedLines.join("\n");
// Split inline concatenated table rows like "| Col 1 | Col 2 | | :--- | :--- | | Val 1 | Val 2 |"
formatted = formatted.replace(/\|\s*\|\s*:-/g, "|\n| :-");
formatted = formatted.replace(/\|\s*\|\s*([A-Za-z0-9_*`])/g, "|\n| $1");
formatted = formatted.replace(/([^\n|])\s*(\|[\s\S]+?\|)\s*([^\n|])/g, "$1\n\n$2\n\n$3");
// Clean up repeated linebreaks
formatted = formatted.replace(/\n{3,}/g, "\n\n");
return formatted;
};
export const parseMarkdown = (text: string): MarkdownBlock[] => {
const sanitized = sanitizeMarkdownText(text);
const lines = sanitized.split("\n");
const state: ParseState = {
blocks: [],
currentCodeBlock: null,
currentList: null,
currentTableLines: [],
currentParagraphLines: [],
};
for (let i = 0; i < lines.length; i++) {
handleLine(lines[i], state);
}
flushParagraph(state);
flushList(state);
flushTable(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, isDark: boolean = false): 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={isDark ? "font-extrabold text-white" : "font-extrabold text-ink-900"}>{part.text}</strong>;
case "italic":
return <em key={idx} className={isDark ? "italic text-slate-200" : "italic text-ink-800"}>{part.text}</em>;
case "code":
return (
<code key={idx} className={isDark ? "bg-slate-900 border border-slate-700 text-emerald-300 rounded px-1.5 py-0.5 text-xs font-mono" : "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={isDark ? "text-emerald-400 hover:text-emerald-300 font-semibold underline break-all inline-flex items-center gap-0.5" : "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, isDark: boolean): React.ReactNode => {
const Component = block.type === "ul" ? "ul" : "ol";
const listClass = block.type === "ul"
? `list-disc pl-5 space-y-1.5 my-2 text-xs ${isDark ? 'text-slate-100' : 'text-ink-800'}`
: `list-decimal pl-5 space-y-1.5 my-2 text-xs ${isDark ? 'text-slate-100' : 'text-ink-800'}`;
return (
<Component key={key} className={listClass}>
{block.items?.map((item, idx) => (
<li key={idx}>{renderInlineText(item, isDark)}</li>
))}
</Component>
);
};
const renderHeadingBlock = (block: MarkdownBlock, key: string, isDark: boolean): React.ReactNode => {
const level = block.type.slice(1);
const classes: Record<string, string> = {
"1": `text-lg sm:text-xl font-extrabold ${isDark ? 'text-white' : 'text-ink-900'} mt-4 mb-2 border-b border-slate-700/50 pb-1`,
"2": `text-base sm:text-lg font-extrabold ${isDark ? 'text-white' : 'text-ink-900'} mt-3 mb-1.5`,
"3": `text-sm sm:text-base font-bold ${isDark ? 'text-emerald-400' : 'text-ink-900'} mt-2.5 mb-1`,
"4": `text-xs sm:text-sm font-bold ${isDark ? 'text-emerald-300' : 'text-ink-800'} mt-2 mb-1`,
"5": `text-xs font-bold ${isDark ? 'text-slate-200' : 'text-ink-800'} mt-1.5 mb-1`,
"6": `text-xs font-bold ${isDark ? 'text-slate-300' : 'text-ink-700'} mt-1 mb-0.5`,
};
const Component = block.type as any;
return (
<Component key={key} className={classes[level] || ""}>
{renderInlineText(block.content, isDark)}
</Component>
);
};
const renderTableBlock = (block: MarkdownBlock, key: string, isDark: boolean): React.ReactNode => {
if (!block.headers || block.headers.length === 0) return null;
return (
<div key={key} className="my-3 w-full overflow-x-auto rounded-xl border border-slate-800 bg-slate-950/90 shadow-xl max-w-full">
<table className="w-full text-left border-collapse min-w-[320px]">
<thead>
<tr className="bg-slate-900/90 border-b border-slate-800 text-[11px] font-extrabold text-emerald-400 uppercase tracking-wider">
{block.headers.map((h, i) => (
<th key={i} className="py-2.5 px-3 border-r last:border-r-0 border-slate-800/80 font-bold whitespace-nowrap">
{renderInlineText(h, isDark)}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 text-xs">
{(block.rows || []).map((row, rIdx) => (
<tr key={rIdx} className="hover:bg-slate-900/60 transition-colors odd:bg-slate-950/40 even:bg-slate-900/30">
{row.map((cell, cIdx) => (
<td key={cIdx} className="py-2.5 px-3 border-r last:border-r-0 border-slate-800/60 text-slate-200 leading-relaxed font-sans">
{renderInlineText(cell, isDark)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};
const renderBlock = (block: MarkdownBlock, index: number, isDark: boolean): React.ReactNode => {
const key = `${block.type}-${index}`;
if (block.type.startsWith("h") && block.type.length === 2 && block.type !== "hr") {
return renderHeadingBlock(block, key, isDark);
}
switch (block.type) {
case "table":
return renderTableBlock(block, key, isDark);
case "blockquote":
return (
<blockquote key={key} className={`border-l-4 border-emerald-500 pl-3 py-1 italic ${isDark ? 'bg-slate-900/60 text-slate-200' : 'bg-primary-50/20 text-ink-700'} my-2 rounded-r-lg`}>
{renderInlineText(block.content, isDark)}
</blockquote>
);
case "ul":
case "ol":
return renderListBlock(block, key, isDark);
case "code":
return (
<div key={key} className="my-3 rounded-xl border border-slate-800 bg-slate-950 text-slate-100 p-3 overflow-x-auto shadow-inner relative group select-text max-w-full">
{block.language && (
<div className="absolute right-3 top-2 text-[9px] uppercase font-bold text-slate-400 select-none">
{block.language}
</div>
)}
<pre className="font-mono text-xs leading-relaxed overflow-x-auto">
{block.content}
</pre>
</div>
);
case "hr":
return <hr key={key} className={`my-4 ${isDark ? 'border-slate-800' : 'border-ink-200'}`} />;
default:
return (
<p key={key} className={`text-xs ${isDark ? 'text-slate-100' : 'text-ink-800'} leading-relaxed my-1.5`}>
{renderInlineText(block.content, isDark)}
</p>
);
}
};
export interface MarkdownViewerProps {
markdown: string;
variant?: 'light' | 'dark' | 'auto';
}
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ markdown, variant = 'auto' }) => {
const blocks = parseMarkdown(markdown);
const isDark = variant === 'dark';
return (
<div className={`w-full text-left select-text font-sans leading-relaxed break-words overflow-hidden ${isDark ? 'text-slate-100' : 'text-ink-800'}`}>
{blocks.map((block, idx) => renderBlock(block, idx, isDark))}
</div>
);
};
export default MarkdownViewer;

View File

@ -1,97 +0,0 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { X } from 'lucide-react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: React.ReactNode;
subtitle?: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
className?: string;
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
subtitle,
children,
footer,
size = 'md',
className = ''
}) => {
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-xl',
xl: 'max-w-2xl',
'2xl': 'max-w-4xl',
full: 'max-w-[95vw]'
};
const sizeClass = sizeClasses[size] || sizeClasses.md;
if (typeof document === 'undefined') return null;
return createPortal(
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[9999] flex justify-center items-start pt-[76px] sm:pt-[80px] px-4 pb-4 sm:pb-6 overflow-hidden">
{/* Backdrop overlay */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-ink-900/40 backdrop-blur-sm z-[9999]"
/>
{/* Modal Container */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 15 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 15 }}
transition={{ type: 'spring', damping: 25, stiffness: 350 }}
className={`relative w-full ${sizeClass} bg-ink-0 border border-ink-200 rounded-2xl shadow-xl flex flex-col overflow-hidden focus:outline-none z-[10000] ${className}`}
style={{
maxHeight: '100%'
}}
>
{/* Header */}
<div className="flex justify-between items-center px-6 py-4 border-b border-ink-100 flex-shrink-0">
<div>
<h3 className="text-base font-bold text-ink-900 leading-snug font-sans">{title}</h3>
{subtitle && <p className="text-xs text-ink-500 mt-1 font-semibold font-sans">{subtitle}</p>}
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors flex-shrink-0 ml-4 cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Scrollable Content */}
<div className="flex-1 min-h-0 overflow-y-auto p-6 text-ink-900 scrollbar-thin">
{children}
</div>
{/* Footer */}
{footer && (
<div className="px-6 py-4 border-t border-ink-100 bg-ink-50 flex justify-end gap-3 flex-shrink-0">
{footer}
</div>
)}
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
);
};
export default Modal;

View File

@ -12,13 +12,13 @@ export const PageHeader: React.FC<PageHeaderProps> = ({ title, subtitle, badge,
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-ink-200"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-ink-200">
<div className="flex flex-col gap-1 min-w-0"> <div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap"> <div className="flex items-center gap-2.5 flex-wrap">
<h1 className="text-xl page-title font-bold tracking-tight text-ink-900 truncate"> <h1 className="text-xl font-bold tracking-tight text-ink-900 truncate">
{title} {title}
</h1> </h1>
{badge && <div className="shrink-0">{badge}</div>} {badge && <div className="shrink-0">{badge}</div>}
</div> </div>
{subtitle && ( {subtitle && (
<p className="text-xs page-subtitle font-medium text-ink-500 max-w-3xl leading-relaxed"> <p className="text-xs font-medium text-ink-500 truncate max-w-3xl">
{subtitle} {subtitle}
</p> </p>
)} )}

View File

@ -1,122 +0,0 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { CheckCircle2, AlertCircle, AlertTriangle, Info, X } from 'lucide-react';
export interface Toast {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
message: string;
description?: string;
duration?: number;
}
interface ToastContextType {
toast: (options: {
type: 'success' | 'error' | 'warning' | 'info';
message: string;
description?: string;
duration?: number;
}) => void;
success: (message: string, description?: string, duration?: number) => void;
error: (message: string, description?: string, duration?: number) => void;
warning: (message: string, description?: string, duration?: number) => void;
info: (message: string, description?: string, duration?: number) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const addToast = useCallback(
({
type,
message,
description,
duration = 4000,
}: Omit<Toast, 'id'>) => {
const id = Math.random().toString(36).substring(2, 9);
setToasts((prev) => [...prev, { id, type, message, description, duration }]);
setTimeout(() => removeToast(id), duration);
},
[removeToast]
);
const success = useCallback((message: string, description?: string, duration?: number) => {
addToast({ type: 'success', message, description, duration });
}, [addToast]);
const error = useCallback((message: string, description?: string, duration?: number) => {
addToast({ type: 'error', message, description, duration });
}, [addToast]);
const warning = useCallback((message: string, description?: string, duration?: number) => {
addToast({ type: 'warning', message, description, duration });
}, [addToast]);
const info = useCallback((message: string, description?: string, duration?: number) => {
addToast({ type: 'info', message, description, duration });
}, [addToast]);
const icons = {
success: <CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0 mt-0.5" />,
error: <AlertCircle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />,
warning: <AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />,
info: <Info className="w-5 h-5 text-blue-500 shrink-0 mt-0.5" />,
};
const borders = {
success: 'border-l-4 border-l-emerald-500',
error: 'border-l-4 border-l-red-500',
warning: 'border-l-4 border-l-amber-500',
info: 'border-l-4 border-l-blue-500',
};
return (
<ToastContext.Provider value={{ toast: addToast, success, error, warning, info }}>
{children}
{/* Toast container viewport */}
<div className="fixed top-6 right-6 z-[200] flex flex-col gap-4 w-[calc(100%-3rem)] max-w-md pointer-events-none">
<AnimatePresence>
{toasts.map((t) => (
<motion.div
key={t.id}
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.9, y: -10 }}
className={`pointer-events-auto flex items-start gap-4 p-5 bg-ink-0 border border-ink-200 rounded-xl shadow-premium ${borders[t.type]} overflow-hidden`}
>
{icons[t.type]}
<div className="flex-1 min-w-0">
<h4 className="text-sm md:text-base font-bold text-ink-900 leading-snug">{t.message}</h4>
{t.description && (
<p className="text-xs md:text-sm text-ink-500 font-semibold leading-normal mt-1.5">{t.description}</p>
)}
</div>
<button
onClick={() => removeToast(t.id)}
className="p-1.5 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors shrink-0 cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
</ToastContext.Provider>
);
};
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
export default ToastProvider;

View File

@ -0,0 +1,313 @@
import React, { useState, useEffect } from "react";
import type { User, Asset } from "../../../types";
import { apiClient } from "../../../lib/api-client";
import {
Users,
FileStack,
DownloadCloud,
AlertTriangle,
Cpu,
Terminal,
Brain,
Server,
ShieldCheck,
} from "lucide-react";
export const AnalyticsDashboard: React.FC = () => {
const [clients, setClients] = useState<User[]>([]);
const [assets, setAssets] = useState<Asset[]>([]);
const [loading, setLoading] = useState(true);
const fetchStats = async () => {
setLoading(true);
try {
const clientRes = await apiClient.get<User[]>("/admin/clients");
const assetRes = await apiClient.get<Asset[]>("/assets");
setClients(clientRes.data);
setAssets(assetRes.data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStats();
}, []);
// Compute analytics data
const totalClients = clients.length;
const totalAssets = assets.length;
const totalDownloads = assets.reduce((sum, a) => sum + a.downloadsCount, 0);
const pendingApprovals = clients.filter(
(c) => c.onboardingStatus === "PENDING_APPROVAL",
).length;
// Category counts
const categoryCounts = assets.reduce(
(acc, a) => {
acc[a.categoryId] = (acc[a.categoryId] || 0) + 1;
return acc;
},
{ silicon: 0, software: 0, ai: 0, cloud: 0 } as Record<string, number>,
);
const categoryMax = Math.max(...Object.values(categoryCounts), 1);
// Onboarding Status distributions
const onboardingCounts = clients.reduce(
(acc, c) => {
acc[c.onboardingStatus] = (acc[c.onboardingStatus] || 0) + 1;
return acc;
},
{
NOT_STARTED: 0,
FORM_COMPLETED: 0,
NDA_SIGNED: 0,
PENDING_APPROVAL: 0,
APPROVED: 0,
REJECTED: 0,
} as Record<string, number>,
);
const onboardingMax = Math.max(...Object.values(onboardingCounts), 1);
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-bold text-ink-800">
Operational Analytics
</h2>
<p className="text-sm text-ink-600">
Overview of client onboarding metrics, asset repository distribution,
and core library downloads.
</p>
</div>
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((n) => (
<div
key={n}
className="h-28 animate-pulse rounded-xl bg-white border border-ink-100"
/>
))}
</div>
) : (
/* Metric Cards */
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-xl border border-ink-100 bg-white p-5 shadow-sm flex items-center gap-4 hover:shadow-premium transition-all duration-300">
<div className="rounded-lg bg-primary-50 p-3 text-primary-700">
<Users className="h-6 w-6" />
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-600">
Total Clients
</p>
<p className="text-2xl font-bold text-ink-800">{totalClients}</p>
</div>
</div>
<div className="rounded-xl border border-ink-100 bg-white p-5 shadow-sm flex items-center gap-4 hover:shadow-premium transition-all duration-300">
<div className="rounded-lg bg-primary-50 p-3 text-primary-700">
<FileStack className="h-6 w-6" />
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-600">
Active Assets
</p>
<p className="text-2xl font-bold text-ink-800">{totalAssets}</p>
</div>
</div>
<div className="rounded-xl border border-ink-100 bg-white p-5 shadow-sm flex items-center gap-4 hover:shadow-premium transition-all duration-300">
<div className="rounded-lg bg-primary-50 p-3 text-primary-700">
<DownloadCloud className="h-6 w-6" />
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-600">
Total Downloads
</p>
<p className="text-2xl font-bold text-ink-800">
{totalDownloads}
</p>
</div>
</div>
<div className="rounded-xl border border-ink-100 bg-white p-5 shadow-sm flex items-center gap-4 hover:shadow-premium transition-all duration-300">
<div
className={`rounded-lg p-3 ${pendingApprovals > 0 ? "bg-warning/10 text-warning" : "bg-success/10 text-success"}`}
>
<AlertTriangle className="h-6 w-6" />
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-600">
Pending Review
</p>
<p className="text-2xl font-bold text-ink-800">
{pendingApprovals}
</p>
</div>
</div>
</div>
)}
{/* Charts Grid */}
<div className="grid gap-6 md:grid-cols-2">
{/* Category Breakdown */}
<div className="rounded-xl border border-ink-100 bg-white p-5 shadow-sm space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-ink-800">
Assets by Domain Category
</h3>
<span className="text-[10px] uppercase font-bold tracking-wider text-ink-600">
Count
</span>
</div>
{loading ? (
<div className="space-y-4 py-6">
{[1, 2, 3].map((n) => (
<div key={n} className="h-8 animate-pulse rounded bg-ink-50" />
))}
</div>
) : (
<div className="space-y-4 py-2">
{[
{ key: "silicon", name: "Silicon Core IP", icon: Cpu },
{
key: "software",
name: "Software & Libraries",
icon: Terminal,
},
{ key: "ai", name: "AI & Agent Workflows", icon: Brain },
{ key: "cloud", name: "Cloud Configs & IaC", icon: Server },
].map((item) => {
const count = categoryCounts[item.key] || 0;
const percentage = (count / categoryMax) * 100;
const Icon = item.icon;
return (
<div key={item.key} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5 font-semibold text-ink-700">
<Icon className="h-4 w-4 text-primary-700" />
{item.name}
</span>
<span className="font-mono font-bold text-ink-800">
{count}
</span>
</div>
<div className="h-3.5 w-full rounded-full bg-ink-50 overflow-hidden border border-ink-100/50">
<div
className="h-full rounded-full bg-gradient-to-r from-primary-200 to-primary-400 transition-all duration-500"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Client Onboarding Funnel */}
<div className="rounded-xl border border-ink-100 bg-white p-5 shadow-sm space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-ink-800">
Client Onboarding Funnel
</h3>
<span className="text-[10px] uppercase font-bold tracking-wider text-ink-600">
Accounts
</span>
</div>
{loading ? (
<div className="space-y-4 py-6">
{[1, 2, 3].map((n) => (
<div key={n} className="h-8 animate-pulse rounded bg-ink-50" />
))}
</div>
) : (
<div className="space-y-4 py-2">
{[
{ key: "NOT_STARTED", name: "Registration Initiated" },
{ key: "FORM_COMPLETED", name: "Profile Form Saved" },
{ key: "NDA_SIGNED", name: "NDA Document Signed" },
{ key: "PENDING_APPROVAL", name: "Awaiting Admin Approval" },
{ key: "APPROVED", name: "Approved & Active Portal" },
].map((item) => {
const count = onboardingCounts[item.key] || 0;
const percentage = (count / onboardingMax) * 100;
return (
<div key={item.key} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="font-semibold text-ink-700">
{item.name}
</span>
<span className="font-mono font-bold text-ink-800">
{count}
</span>
</div>
<div className="h-3.5 w-full rounded-full bg-ink-50 overflow-hidden border border-ink-100/50">
<div
className={`h-full rounded-full transition-all duration-500 ${
item.key === "APPROVED"
? "bg-success/70"
: item.key === "PENDING_APPROVAL"
? "bg-warning/70"
: "bg-primary-300"
}`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Bottom list showing Top Downloaded Assets */}
<div className="rounded-xl border border-ink-100 bg-white p-5 shadow-sm space-y-4">
<h3 className="text-sm font-bold text-ink-800">
Most Downloaded Catalog Resources
</h3>
<div className="divide-y divide-ink-50">
{assets
.slice()
.sort((a, b) => b.downloadsCount - a.downloadsCount)
.slice(0, 3)
.map((asset, index) => (
<div
key={asset.id}
className="flex items-center justify-between py-2.5 first:pt-0 last:pb-0"
>
<div className="flex items-center gap-3">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary-50 text-xs font-bold text-primary-800 border border-primary-100">
{index + 1}
</span>
<div>
<p className="text-xs font-bold text-ink-800">
{asset.title}
</p>
<p className="text-[10px] text-ink-600 font-semibold">
{asset.subcategory}
</p>
</div>
</div>
<div className="flex items-center gap-1.5 text-xs text-ink-700 font-mono">
<ShieldCheck className="h-4 w-4 text-success" />
<span className="font-bold">{asset.downloadsCount}</span>{" "}
downloads
</div>
</div>
))}
</div>
</div>
</div>
);
};

View File

@ -105,7 +105,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
}} }}
> >
{/* Document Header / Letterhead */} {/* Document Header / Letterhead */}
<div className="text-center mb-10 pb-6 border-b-2 border-ink-900"> <div className="text-center mb-10 pb-6 border-b-2 border-ink-950">
<h1 className="text-xl md:text-2xl font-bold uppercase tracking-wider text-ink-900 font-sans mb-2"> <h1 className="text-xl md:text-2xl font-bold uppercase tracking-wider text-ink-900 font-sans mb-2">
{documentType === "NDA" {documentType === "NDA"
? "Mutual Non-Disclosure Agreement" ? "Mutual Non-Disclosure Agreement"
@ -347,7 +347,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
For: Tech4Biz Solutions Inc. For: Tech4Biz Solutions Inc.
</p> </p>
<div className="h-16 flex items-end pb-1 border-b border-ink-300 relative"> <div className="h-16 flex items-end pb-1 border-b border-ink-300 relative">
<span className="font-serif italic text-base text-ink-900 select-none pb-1"> <span className="font-serif italic text-base text-ink-950 select-none pb-1">
Yasha Khandelwal{" "} Yasha Khandelwal{" "}
</span> </span>
</div> </div>

View File

@ -5,14 +5,12 @@ 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);
@ -26,7 +24,7 @@ export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return; if (!ctx) return;
// Detect theme for stroke color (visible on black/dark or white screens) // Detect theme for stroke color
const isDark = document.documentElement.classList.contains('dark'); const isDark = document.documentElement.classList.contains('dark');
ctx.strokeStyle = isDark ? '#fafafa' : '#09090b'; ctx.strokeStyle = isDark ? '#fafafa' : '#09090b';
ctx.lineWidth = 3; ctx.lineWidth = 3;
@ -40,19 +38,7 @@ 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(() => {
@ -128,7 +114,6 @@ export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
setHasSignature(false); setHasSignature(false);
onSignatureComplete('');
}; };
const handleSave = () => { const handleSave = () => {

View File

@ -1,909 +0,0 @@
import React, { useState } from 'react';
import { X, Shield, Plus, Trash2, Tag, CheckCircle2, AlertCircle, Bell, Layers, Cpu, ShieldCheck, Send } from 'lucide-react';
import type { TaxonomyMeta, Organization, Asset } from '../../../types/assets';
import {
createVertical, deleteVertical,
createTechStack, deleteTechStack,
createEngagementType, deleteEngagementType,
createComplianceStandard, deleteComplianceStandard,
sendAssetAnnouncement
} from '../../../services/assets-api';
interface AssetAdminManagerModalProps {
isOpen: boolean;
onClose: () => void;
meta: TaxonomyMeta | null;
organizations: Organization[];
allAssets?: Asset[];
onRefreshMeta: () => void;
}
export const AssetAdminManagerModal: React.FC<AssetAdminManagerModalProps> = ({
isOpen,
onClose,
meta,
organizations,
allAssets = [],
onRefreshMeta,
}) => {
const [activeTab, setActiveTab] = useState<'verticals' | 'techStacks' | 'engagements' | 'compliance' | 'taxonomy' | 'announcements'>('verticals');
const [loading, setLoading] = useState(false);
const [statusMsg, setStatusMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Inspect filter state for Taxonomy Stats interactive list
const [inspectFilter, setInspectFilter] = useState<{ type: 'Subcategory' | 'Vertical'; name: string; id?: string } | null>(null);
// Form State: Verticals
const [newVerticalName, setNewVerticalName] = useState('');
const [newVerticalColor, setNewVerticalColor] = useState('#3b82f6');
const [newVerticalDesc, setNewVerticalDesc] = useState('');
// Form State: Tech Stacks
const [newTechName, setNewTechName] = useState('');
const [newTechCategory, setNewTechCategory] = useState('Languages & Frameworks');
const [newTechColor, setNewTechColor] = useState('#64748b');
// Form State: Engagements
const [newEngagementName, setNewEngagementName] = useState('');
const [newEngagementColor, setNewEngagementColor] = useState('#0284c7');
// Form State: Compliance
const [newComplianceName, setNewComplianceName] = useState('');
const [newComplianceColor, setNewComplianceColor] = useState('#10b981');
// Announcement Form State
const [announcementTitle, setAnnouncementTitle] = useState('');
const [announcementMsg, setAnnouncementMsg] = useState('');
const [selectedOrgId, setSelectedOrgId] = useState<string>('ALL');
if (!isOpen) return null;
const matchingAssets = (allAssets || []).filter(asset => {
if (!inspectFilter) return false;
if (inspectFilter.type === 'Subcategory') {
return (asset.subcategory || asset.categoryId || '').toLowerCase() === inspectFilter.name.toLowerCase();
}
if (inspectFilter.type === 'Vertical') {
return asset.verticals?.some(v => v.id === inspectFilter.id || v.name.toLowerCase() === inspectFilter.name.toLowerCase());
}
return false;
});
// Vertical CRUD
const handleCreateVertical = async (e: React.FormEvent) => {
e.preventDefault();
if (!newVerticalName.trim()) return;
setLoading(true);
try {
await createVertical({
name: newVerticalName.trim(),
color: newVerticalColor,
description: newVerticalDesc.trim() || undefined,
icon: 'Shield',
});
setStatusMsg({ type: 'success', text: `Vertical "${newVerticalName}" created successfully!` });
setNewVerticalName('');
setNewVerticalDesc('');
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: err.response?.data?.error || 'Failed to create vertical' });
} finally {
setLoading(false);
}
};
const handleDeleteVertical = async (id: string, name: string) => {
if (!window.confirm(`Are you sure you want to delete vertical "${name}"?`)) return;
setLoading(true);
try {
await deleteVertical(id);
setStatusMsg({ type: 'success', text: `Vertical "${name}" deleted` });
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: 'Failed to delete vertical' });
} finally {
setLoading(false);
}
};
// Tech Stack CRUD
const handleCreateTechStack = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTechName.trim()) return;
setLoading(true);
try {
await createTechStack({
name: newTechName.trim(),
category: newTechCategory,
color: newTechColor,
});
setStatusMsg({ type: 'success', text: `Tech Stack item "${newTechName}" created successfully!` });
setNewTechName('');
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: err.response?.data?.error || 'Failed to create tech stack' });
} finally {
setLoading(false);
}
};
const handleDeleteTechStack = async (id: string, name: string) => {
if (!window.confirm(`Are you sure you want to delete tech stack item "${name}"?`)) return;
setLoading(true);
try {
await deleteTechStack(id);
setStatusMsg({ type: 'success', text: `Tech Stack item "${name}" deleted` });
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: 'Failed to delete tech stack item' });
} finally {
setLoading(false);
}
};
// Engagement Type CRUD
const handleCreateEngagement = async (e: React.FormEvent) => {
e.preventDefault();
if (!newEngagementName.trim()) return;
setLoading(true);
try {
await createEngagementType({
name: newEngagementName.trim(),
color: newEngagementColor,
});
setStatusMsg({ type: 'success', text: `Engagement Type "${newEngagementName}" created!` });
setNewEngagementName('');
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: err.response?.data?.error || 'Failed to create engagement type' });
} finally {
setLoading(false);
}
};
const handleDeleteEngagement = async (id: string, name: string) => {
if (!window.confirm(`Are you sure you want to delete engagement type "${name}"?`)) return;
setLoading(true);
try {
await deleteEngagementType(id);
setStatusMsg({ type: 'success', text: `Engagement Type "${name}" deleted` });
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: 'Failed to delete engagement type' });
} finally {
setLoading(false);
}
};
// Compliance Standard CRUD
const handleCreateCompliance = async (e: React.FormEvent) => {
e.preventDefault();
if (!newComplianceName.trim()) return;
setLoading(true);
try {
await createComplianceStandard({
name: newComplianceName.trim(),
color: newComplianceColor,
});
setStatusMsg({ type: 'success', text: `Compliance Standard "${newComplianceName}" created!` });
setNewComplianceName('');
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: err.response?.data?.error || 'Failed to create compliance standard' });
} finally {
setLoading(false);
}
};
const handleDeleteCompliance = async (id: string, name: string) => {
if (!window.confirm(`Are you sure you want to delete compliance standard "${name}"?`)) return;
setLoading(true);
try {
await deleteComplianceStandard(id);
setStatusMsg({ type: 'success', text: `Compliance Standard "${name}" deleted` });
onRefreshMeta();
} catch (err: any) {
setStatusMsg({ type: 'error', text: 'Failed to delete compliance standard' });
} finally {
setLoading(false);
}
};
const handleSendAnnouncement = async (e: React.FormEvent) => {
e.preventDefault();
if (!announcementTitle.trim() || !announcementMsg.trim()) return;
setLoading(true);
try {
await sendAssetAnnouncement({
title: announcementTitle.trim(),
message: announcementMsg.trim(),
targetOrgIds: selectedOrgId === 'ALL' ? ['ALL'] : [selectedOrgId],
});
setStatusMsg({ type: 'success', text: 'Announcement dispatched to partner organizations!' });
setAnnouncementTitle('');
setAnnouncementMsg('');
} catch (err: any) {
setStatusMsg({ type: 'error', text: err.response?.data?.error || 'Failed to send announcement' });
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-2xl w-full max-w-4xl overflow-hidden flex flex-col max-h-[85vh]">
{/* Modal Header */}
<div className="px-6 py-5 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between bg-slate-50/50 dark:bg-slate-900/50">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 shadow-md">
<Shield className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
Taxonomy & Announcements Control Panel
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400">
Manage 4-Group Taxonomy items, metadata stats, and partner notifications
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Tab Navigation */}
<div className="flex items-center overflow-x-auto whitespace-nowrap custom-scrollbar border-b border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-800/50 px-4 scroll-smooth shrink-0">
<button
onClick={() => { setActiveTab('verticals'); setStatusMsg(null); }}
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'verticals'
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<Shield className="w-3.5 h-3.5" />
1. Verticals ({meta?.verticals.length || 0})
</button>
<button
onClick={() => { setActiveTab('techStacks'); setStatusMsg(null); }}
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'techStacks'
? 'border-purple-600 text-purple-600 dark:border-purple-400 dark:text-purple-400'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<Cpu className="w-3.5 h-3.5" />
2. Tech Stack ({meta?.techStacks?.length || 0})
</button>
<button
onClick={() => { setActiveTab('engagements'); setStatusMsg(null); }}
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'engagements'
? 'border-sky-600 text-sky-600 dark:border-sky-400 dark:text-sky-400'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<Layers className="w-3.5 h-3.5" />
3. Engagements ({meta?.engagementTypes?.length || 0})
</button>
<button
onClick={() => { setActiveTab('compliance'); setStatusMsg(null); }}
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'compliance'
? 'border-emerald-600 text-emerald-600 dark:border-emerald-400 dark:text-emerald-400'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<ShieldCheck className="w-3.5 h-3.5" />
4. Compliance ({meta?.complianceStandards?.length || 0})
</button>
<button
onClick={() => { setActiveTab('taxonomy'); setStatusMsg(null); }}
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'taxonomy'
? 'border-slate-900 text-slate-900 dark:border-slate-100 dark:text-slate-100'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<Tag className="w-3.5 h-3.5" />
Stats ({meta?.totalAssets || 0})
</button>
<button
onClick={() => { setActiveTab('announcements'); setStatusMsg(null); }}
className={`px-3 py-3 text-xs font-bold border-b-2 flex items-center gap-1.5 transition-colors shrink-0 ${
activeTab === 'announcements'
? 'border-amber-500 text-amber-500 dark:border-amber-400 dark:text-amber-400'
: 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
<Bell className="w-3.5 h-3.5" />
Announcements
</button>
</div>
{/* Status Message */}
{statusMsg && (
<div
className={`px-6 py-3 text-xs font-medium flex items-center gap-2 ${
statusMsg.type === 'success'
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300'
: 'bg-rose-50 text-rose-700 dark:bg-rose-950/50 dark:text-rose-300'
}`}
>
{statusMsg.type === 'success' ? <CheckCircle2 className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
{statusMsg.text}
</div>
)}
{/* Modal Body */}
<div className="p-6 overflow-y-auto flex-1 custom-scrollbar">
{/* TAB 1: Verticals */}
{activeTab === 'verticals' && (
<div className="space-y-6">
{/* Create Vertical Form */}
<form onSubmit={handleCreateVertical} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 space-y-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-2">
<Plus className="w-4 h-4 text-slate-700 dark:text-slate-300" />
Add New Industry Vertical
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="sm:col-span-2">
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Vertical Name
</label>
<input
type="text"
placeholder="e.g. CleanTech & Renewables"
value={newVerticalName}
onChange={e => setNewVerticalName(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-slate-400"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Badge Color
</label>
<div className="flex items-center gap-2">
<input
type="color"
value={newVerticalColor}
onChange={e => setNewVerticalColor(e.target.value)}
className="w-9 h-9 p-0.5 rounded border border-slate-200 dark:border-slate-700 cursor-pointer"
/>
<input
type="text"
value={newVerticalColor}
onChange={e => setNewVerticalColor(e.target.value)}
className="w-full px-2 py-1.5 text-xs font-mono rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
/>
</div>
</div>
</div>
<div>
<input
type="text"
placeholder="Short description of this vertical domain..."
value={newVerticalDesc}
onChange={e => setNewVerticalDesc(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
/>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={loading || !newVerticalName.trim()}
className="px-4 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold disabled:opacity-50 transition-all flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
Create Vertical
</button>
</div>
</form>
{/* Verticals Table */}
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400">
Active Verticals ({meta?.verticals.length || 0})
</h3>
<div className="divide-y divide-slate-100 dark:divide-slate-800 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
{(meta?.verticals || []).map(v => (
<div key={v.id} className="p-3 bg-white dark:bg-slate-900 flex items-center justify-between text-xs">
<div className="flex items-center gap-3">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: v.color || '#6366f1' }} />
<div>
<div className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
{v.name}
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500">
{v._count?.assets ?? 0} assets
</span>
</div>
{v.description && <div className="text-[11px] text-slate-400">{v.description}</div>}
</div>
</div>
<button
onClick={() => handleDeleteVertical(v.id, v.name)}
className="p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded-lg transition-colors"
title="Delete Vertical"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</div>
)}
{/* TAB 2: Tech Stacks */}
{activeTab === 'techStacks' && (
<div className="space-y-6">
{/* Create Tech Stack Form */}
<form onSubmit={handleCreateTechStack} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 space-y-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-2">
<Plus className="w-4 h-4 text-slate-700 dark:text-slate-300" />
Add New Tech Stack / Capability Item
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Tech Item Name
</label>
<input
type="text"
placeholder="e.g. Rust, PyTorch, GraphQL"
value={newTechName}
onChange={e => setNewTechName(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Tech Category / Group
</label>
<select
value={newTechCategory}
onChange={e => setNewTechCategory(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
>
<option value="Languages & Frameworks">Languages & Frameworks</option>
<option value="AI & ML">AI & ML</option>
<option value="Data & Backend">Data & Backend</option>
<option value="Cloud & Infra">Cloud & Infra</option>
</select>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Color
</label>
<input
type="color"
value={newTechColor}
onChange={e => setNewTechColor(e.target.value)}
className="w-full h-9 p-0.5 rounded border border-slate-200 dark:border-slate-700 cursor-pointer"
/>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={loading || !newTechName.trim()}
className="px-4 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold disabled:opacity-50 transition-all flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
Create Tech Item
</button>
</div>
</form>
{/* Tech Stack List */}
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400">
Active Tech Stack Items ({meta?.techStacks?.length || 0})
</h3>
<div className="divide-y divide-slate-100 dark:divide-slate-800 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
{(meta?.techStacks || []).map(t => (
<div key={t.id} className="p-3 bg-white dark:bg-slate-900 flex items-center justify-between text-xs">
<div className="flex items-center gap-3">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: t.color || '#64748b' }} />
<div>
<div className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
{t.name}
<span className="text-[10px] font-extrabold px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-500">
{t.category}
</span>
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500">
{t._count?.assets ?? 0} assets
</span>
</div>
</div>
</div>
<button
onClick={() => handleDeleteTechStack(t.id, t.name)}
className="p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded-lg transition-colors"
title="Delete Tech Item"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</div>
)}
{/* TAB 3: Engagement Types */}
{activeTab === 'engagements' && (
<div className="space-y-6">
{/* Create Engagement Form */}
<form onSubmit={handleCreateEngagement} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 space-y-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-2">
<Plus className="w-4 h-4 text-slate-700 dark:text-slate-300" />
Add New Engagement Type
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Engagement Name
</label>
<input
type="text"
placeholder="e.g. Audit & Advisory, Advisory"
value={newEngagementName}
onChange={e => setNewEngagementName(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Badge Color
</label>
<input
type="color"
value={newEngagementColor}
onChange={e => setNewEngagementColor(e.target.value)}
className="w-full h-9 p-0.5 rounded border border-slate-200 dark:border-slate-700 cursor-pointer"
/>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={loading || !newEngagementName.trim()}
className="px-4 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold disabled:opacity-50 transition-all flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
Create Engagement Type
</button>
</div>
</form>
{/* Engagements List */}
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400">
Active Engagement Types ({meta?.engagementTypes?.length || 0})
</h3>
<div className="divide-y divide-slate-100 dark:divide-slate-800 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
{(meta?.engagementTypes || []).map(e => (
<div key={e.id} className="p-3 bg-white dark:bg-slate-900 flex items-center justify-between text-xs">
<div className="flex items-center gap-3">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: e.color || '#0284c7' }} />
<span className="font-semibold text-slate-900 dark:text-slate-100">{e.name}</span>
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500">
{e._count?.assets ?? 0} assets
</span>
</div>
<button
onClick={() => handleDeleteEngagement(e.id, e.name)}
className="p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded-lg transition-colors"
title="Delete Engagement Type"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</div>
)}
{/* TAB 4: Compliance Standards */}
{activeTab === 'compliance' && (
<div className="space-y-6">
{/* Create Compliance Form */}
<form onSubmit={handleCreateCompliance} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 space-y-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300 flex items-center gap-2">
<Plus className="w-4 h-4 text-slate-700 dark:text-slate-300" />
Add New Compliance Standard / Regulatory Certification
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Compliance Name
</label>
<input
type="text"
placeholder="e.g. ISO 27001, PCI-DSS, FedRAMP"
value={newComplianceName}
onChange={e => setNewComplianceName(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-slate-600 dark:text-slate-400 mb-1">
Badge Color
</label>
<input
type="color"
value={newComplianceColor}
onChange={e => setNewComplianceColor(e.target.value)}
className="w-full h-9 p-0.5 rounded border border-slate-200 dark:border-slate-700 cursor-pointer"
/>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={loading || !newComplianceName.trim()}
className="px-4 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold disabled:opacity-50 transition-all flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
Create Compliance Standard
</button>
</div>
</form>
{/* Compliance List */}
<div className="space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400">
Active Compliance Standards ({meta?.complianceStandards?.length || 0})
</h3>
<div className="divide-y divide-slate-100 dark:divide-slate-800 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
{(meta?.complianceStandards || []).map(c => (
<div key={c.id} className="p-3 bg-white dark:bg-slate-900 flex items-center justify-between text-xs">
<div className="flex items-center gap-3">
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: c.color || '#10b981' }} />
<span className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-emerald-500" />
{c.name}
</span>
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500">
{c._count?.assets ?? 0} assets
</span>
</div>
<button
onClick={() => handleDeleteCompliance(c.id, c.name)}
className="p-1.5 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded-lg transition-colors"
title="Delete Compliance Standard"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</div>
)}
{/* TAB 2: Taxonomy Stats */}
{activeTab === 'taxonomy' && (
<div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2.5">
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
<div className="text-xl font-bold text-slate-900 dark:text-slate-100">{meta?.totalAssets || 0}</div>
<div className="text-[11px] text-slate-500 font-medium">Total Assets</div>
</div>
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
<div className="text-xl font-bold text-blue-600">{meta?.verticals.length || 0}</div>
<div className="text-[11px] text-slate-500 font-medium">1. Verticals</div>
</div>
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
<div className="text-xl font-bold text-purple-600">{meta?.techStacks?.length || 0}</div>
<div className="text-[11px] text-slate-500 font-medium">2. Tech Stacks</div>
</div>
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
<div className="text-xl font-bold text-sky-600">{meta?.engagementTypes?.length || 0}</div>
<div className="text-[11px] text-slate-500 font-medium">3. Engagements</div>
</div>
<div className="p-3.5 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800">
<div className="text-xl font-bold text-emerald-600">{meta?.complianceStandards?.length || 0}</div>
<div className="text-[11px] text-slate-500 font-medium">4. Compliance</div>
</div>
</div>
{/* 1. Industry Verticals Breakdown */}
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2.5">
1. Industry Verticals Domain Distribution
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{(meta?.verticals || []).map(v => (
<button
key={v.id}
onClick={() => setInspectFilter({ type: 'Vertical', name: v.name, id: v.id })}
className={`p-2.5 rounded-xl border text-left transition-all cursor-pointer flex items-center justify-between ${
inspectFilter?.name === v.name
? 'bg-slate-900 text-white border-slate-900 dark:bg-slate-100 dark:text-slate-900'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-200 hover:border-slate-400'
}`}
>
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: v.color || '#3b82f6' }} />
<span className="font-bold text-xs">{v.name}</span>
</div>
<span className="text-[10px] font-mono font-bold px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-700">
{v._count?.assets ?? 0} assets
</span>
</button>
))}
</div>
</div>
{/* 2. Tech Stack Breakdown */}
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2.5">
2. Technology Stack & Capabilities
</h4>
<div className="flex flex-wrap gap-1.5">
{(meta?.techStacks || []).map(t => (
<div
key={t.id}
className="px-2.5 py-1 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-xs font-medium flex items-center gap-1.5"
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: t.color || '#64748b' }} />
<span className="font-semibold">{t.name}</span>
<span className="text-[10px] font-mono opacity-70">({t._count?.assets ?? 0})</span>
</div>
))}
</div>
</div>
{/* 3 & 4. Engagement & Compliance Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">
3. Engagement Types
</h4>
<div className="space-y-1.5">
{(meta?.engagementTypes || []).map(e => (
<div key={e.id} className="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 flex items-center justify-between text-xs font-semibold">
<span>{e.name}</span>
<span className="font-mono text-[10px] opacity-70">({e._count?.assets ?? 0} assets)</span>
</div>
))}
</div>
</div>
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">
4. Compliance & Regulatory
</h4>
<div className="space-y-1.5">
{(meta?.complianceStandards || []).map(c => (
<div key={c.id} className="p-2 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 flex items-center justify-between text-xs font-semibold">
<span className="flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-emerald-500" />
{c.name}
</span>
<span className="font-mono text-[10px] opacity-70">({c._count?.assets ?? 0} assets)</span>
</div>
))}
</div>
</div>
</div>
{/* Inspect Filter Asset List Details */}
{inspectFilter && (
<div className="p-4 rounded-xl bg-slate-100 dark:bg-slate-800/80 border border-slate-300 dark:border-slate-700 space-y-3">
<div className="flex items-center justify-between">
<h5 className="text-xs font-extrabold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<Tag className="w-4 h-4 text-amber-500" />
Inspecting {inspectFilter.type}: <span className="underline">{inspectFilter.name}</span>
</h5>
<button
onClick={() => setInspectFilter(null)}
className="text-[11px] font-bold text-slate-500 hover:text-slate-900 dark:hover:text-slate-100"
>
Clear Selection
</button>
</div>
<div className="divide-y divide-slate-200 dark:divide-slate-700/60 max-h-48 overflow-y-auto custom-scrollbar bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700">
{matchingAssets.length === 0 ? (
<div className="p-4 text-xs text-center text-slate-500">No assets tagged with this {inspectFilter.type.toLowerCase()} yet.</div>
) : (
matchingAssets.map(asset => (
<div key={asset.id} className="p-2.5 flex items-center justify-between text-xs hover:bg-slate-50 dark:hover:bg-slate-800/50">
<div className="min-w-0 flex-1 pr-3">
<div className="font-bold text-slate-900 dark:text-slate-100 truncate">{asset.title}</div>
<div className="text-[10px] text-slate-500 font-mono truncate">{asset.subcategory || asset.type} {asset.url}</div>
</div>
<a
href={asset.url}
target="_blank"
rel="noreferrer"
className="px-2.5 py-1 text-[10px] font-bold bg-slate-100 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-slate-800 dark:text-slate-200 hover:bg-slate-200"
>
View Asset
</a>
</div>
))
)}
</div>
</div>
)}
</div>
)}
{/* TAB 3: Announcements */}
{activeTab === 'announcements' && (
<form onSubmit={handleSendAnnouncement} className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1">
Target Partner Organization
</label>
<select
value={selectedOrgId}
onChange={e => setSelectedOrgId(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
>
<option value="ALL">All Partner Organizations (Broadcast)</option>
{organizations.map(org => (
<option key={org.id} value={org.id}>{org.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1">
Announcement Title
</label>
<input
type="text"
placeholder="e.g. New Cybersecurity Case Studies & MVPs Released"
value={announcementTitle}
onChange={e => setAnnouncementTitle(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-700 dark:text-slate-300 mb-1">
Message Body
</label>
<textarea
rows={5}
placeholder="Write announcement details for partners..."
value={announcementMsg}
onChange={e => setAnnouncementMsg(e.target.value)}
className="w-full px-3 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 custom-scrollbar"
/>
</div>
<div className="flex justify-end pt-2">
<button
type="submit"
disabled={loading || !announcementTitle.trim() || !announcementMsg.trim()}
className="px-5 py-2.5 rounded-xl bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold shadow-md disabled:opacity-50 transition-all flex items-center gap-2"
>
<Send className="w-4 h-4" />
Dispatch Announcement
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
};

View File

@ -1,7 +1,8 @@
import React, { useState } from 'react'; import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { import {
Globe,
FileText, FileText,
Image as ImageIcon,
File, File,
Eye, Eye,
MoreVertical, MoreVertical,
@ -12,25 +13,10 @@ import {
ExternalLink, ExternalLink,
Download, Download,
Clock, Clock,
AlertCircle, AlertCircle
Globe,
Sparkles
} from 'lucide-react'; } from 'lucide-react';
import type { Asset } from '../../../types/assets'; import type { Asset } from '../../../types/assets';
import type { User } from '../../../types/auth'; import type { User } from '../../../types/auth';
import { axiosInstance } from '../../../services/axios';
import { PdfThumbnail } from './PdfThumbnail';
import { DocxThumbnail } from './DocxThumbnail';
import { SpreadsheetThumbnail } from './SpreadsheetThumbnail';
const getFriendlyHostname = (urlStr: string) => {
try {
const urlObj = new URL(urlStr);
return urlObj.hostname.replace('www.', '');
} catch {
return 'website.com';
}
};
interface AssetCardProps { interface AssetCardProps {
asset: Asset; asset: Asset;
@ -44,11 +30,6 @@ interface AssetCardProps {
onOpenViewer: (asset: Asset) => void; onOpenViewer: (asset: Asset) => void;
onDownload: (asset: Asset) => void; onDownload: (asset: Asset) => void;
onRequestDownload: (asset: Asset) => void; onRequestDownload: (asset: Asset) => void;
isSelected?: boolean;
onToggleSelect?: (assetId: string, event?: React.MouseEvent) => void;
isExpanded?: boolean;
onToggleExpand?: () => void;
isRecommended?: boolean;
} }
export const AssetCard: React.FC<AssetCardProps> = ({ export const AssetCard: React.FC<AssetCardProps> = ({
@ -62,12 +43,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
onDelete, onDelete,
onOpenViewer, onOpenViewer,
onDownload, onDownload,
onRequestDownload, onRequestDownload
isSelected = false,
onToggleSelect,
isExpanded = false,
onToggleExpand,
isRecommended = false
}) => { }) => {
const isMenuOpen = activeMenuId === asset.id; const isMenuOpen = activeMenuId === asset.id;
const canDirectDownload = user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED'; const canDirectDownload = user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED';
@ -82,640 +58,138 @@ export const AssetCard: React.FC<AssetCardProps> = ({
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}; };
const getAssetIcon = (type: string) => {
if (type === 'url') return Globe;
const isRenderable = (type: string, url: string) => { if (type.includes('pdf')) return FileText;
const isOffice = type.includes('word') || type.includes('presentation') || type.includes('sheet') || if (type.includes('image') || type.includes('png') || type.includes('jpg')) return ImageIcon;
url.toLowerCase().endsWith('.docx') || url.toLowerCase().endsWith('.doc') || return File;
url.toLowerCase().endsWith('.pptx') || url.toLowerCase().endsWith('.ppt') ||
url.toLowerCase().endsWith('.xlsx') || url.toLowerCase().endsWith('.xls') ||
url.toLowerCase().endsWith('.csv') || type.includes('csv');
const isTextOrCode = url.toLowerCase().endsWith('.md') ||
url.toLowerCase().endsWith('.txt') ||
url.toLowerCase().endsWith('.json') ||
url.toLowerCase().endsWith('.js') ||
url.toLowerCase().endsWith('.ts') ||
url.toLowerCase().endsWith('.tsx') ||
url.toLowerCase().endsWith('.jsx') ||
url.toLowerCase().endsWith('.py') ||
url.toLowerCase().endsWith('.yaml') ||
url.toLowerCase().endsWith('.yml') ||
url.toLowerCase().endsWith('.css') ||
url.toLowerCase().endsWith('.html') ||
url.toLowerCase().endsWith('.sh') ||
type.includes('text') ||
type.includes('markdown') ||
type.includes('json') ||
type.includes('javascript');
return type === 'url' || type.includes('pdf') || type.includes('image') || type.includes('png') || type.includes('jpg') || isOffice || isTextOrCode;
}; };
const getFullAssetUrl = (url: string) => { const isRenderable = (type: string) => {
let resolvedUrl = url; return type === 'url' || type.includes('pdf') || type.includes('image') || type.includes('png') || type.includes('jpg');
if (!url.startsWith('http')) {
const backendBase = axiosInstance.defaults.baseURL || '/api/v1';
const relativeHost = backendBase.replace('/api/v1', '');
resolvedUrl = relativeHost.startsWith('/') || relativeHost === ''
? `${window.location.origin}${relativeHost}${url}`
: `${relativeHost}${url}`;
}
const currentOrigin = window.location.origin;
const isCurrentOriginLocal = currentOrigin.includes('localhost') || currentOrigin.includes('127.0.0.1');
if (!isCurrentOriginLocal && (resolvedUrl.includes('localhost') || resolvedUrl.includes('127.0.0.1'))) {
resolvedUrl = resolvedUrl.replace(/https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/, currentOrigin);
}
return resolvedUrl;
}; };
const Icon = getAssetIcon(asset.type);
const isImage = asset.type.includes('image') || asset.type.includes('png') || asset.type.includes('jpg') || asset.url.match(/\.(png|jpe?g|gif|svg|webp)$/i);
const isPdf = asset.type.includes('pdf') || asset.url.toLowerCase().endsWith('.pdf');
const isWord = asset.type.includes('word') || asset.url.toLowerCase().endsWith('.docx') || asset.url.toLowerCase().endsWith('.doc');
const isPresentation = asset.type.includes('presentation') || asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt');
const isSpreadsheet = asset.type.includes('sheet') || asset.url.toLowerCase().endsWith('.xlsx') || asset.url.toLowerCase().endsWith('.xls') || asset.url.toLowerCase().endsWith('.csv');
const [imgError, setImgError] = useState(false);
const hasBanner = (isImage || !!asset.thumbnailUrl) && !imgError;
const bannerSrc = asset.thumbnailUrl ? asset.thumbnailUrl : asset.url;
const handleInspectWithAI = (e: React.MouseEvent) => {
e.stopPropagation();
const payload = {
id: asset.id,
title: asset.title,
type: asset.type,
entityKind: 'ASSET',
url: asset.url,
description: asset.description,
problemStatement: asset.problemStatement,
solution: asset.solution,
thumbnailUrl: asset.thumbnailUrl,
tags: asset.tags,
};
window.dispatchEvent(new CustomEvent('attach-ai-entity', { detail: payload }));
};
return ( return (
<motion.div <div className="group relative bg-ink-0 border border-ink-200 rounded-xl p-4 hover:border-ink-300 transition-all duration-300 shadow-sm hover:shadow-md hover:-translate-y-0.5 flex flex-col justify-between">
id={`asset-card-${asset.id}`} <div>
layout <div className="flex justify-between items-start mb-3 relative">
draggable={true} <div className="w-10 h-10 rounded-lg flex items-center justify-center text-ink-900 bg-ink-100 border border-ink-200">
onDragStart={(e: any) => { <Icon className="w-5 h-5" />
const payload = {
id: asset.id,
title: asset.title,
type: asset.type,
entityKind: 'ASSET',
url: asset.url,
description: asset.description,
problemStatement: asset.problemStatement,
solution: asset.solution,
thumbnailUrl: asset.thumbnailUrl,
tags: asset.tags,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.setData('text/plain', asset.title);
}}
transition={{ type: "spring", stiffness: 320, damping: 28 }}
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.closest('button') || target.closest('a') || target.closest('input') || target.closest('.toggle-expand-btn')) {
return;
}
onViewDetails(asset);
}}
className={`group bg-ink-0 border rounded-xl p-4 transition-[border-color,box-shadow,background-color] duration-300 flex flex-col justify-between cursor-pointer h-full ${isExpanded
? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-300 bg-ink-0'
: isRecommended
? 'relative w-full border-amber-500/35 bg-gradient-to-br from-amber-500/[0.02] via-ink-0 to-ink-0 hover:border-amber-500 hover:shadow-lg hover:shadow-amber-500/10 hover:-translate-y-0.5'
: 'relative w-full border-ink-200 hover:border-ink-300 hover:shadow-md hover:-translate-y-0.5'
} ${isSelected ? 'border-ink-900 ring-1 ring-ink-900 bg-ink-50/30' : ''}`}
>
<div className="flex-grow flex flex-col">
{/* Visual Thumbnail Area */}
<div className="w-full h-36 bg-ink-50 border border-ink-200 rounded-lg mb-3 flex items-center justify-center overflow-hidden relative select-none group-hover:border-ink-300 transition-colors bg-gradient-to-br from-ink-50 to-ink-100/50">
{/* Absolute overlays for controls */}
<div className="absolute top-2 left-2 z-10 flex items-center gap-1.5">
{user?.role === 'ADMIN' && onToggleSelect && (
<input
type="checkbox"
checked={isSelected}
onClick={(e) => e.stopPropagation()}
onChange={(e) => onToggleSelect(asset.id, e as unknown as React.MouseEvent)}
className={`w-3.5 h-3.5 rounded border-ink-300 bg-ink-0 text-ink-900 focus:ring-ink-900/10 cursor-pointer shadow-sm transition-opacity duration-200 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
/>
)}
{isRecommended && (
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-500 text-ink-900 text-[9px] font-extrabold uppercase tracking-wider shadow-sm border border-amber-400">
<Sparkles className="w-2.5 h-2.5 fill-ink-900" />
<span>Recommended for You</span>
</div>
)}
</div> </div>
<div className="absolute top-2 right-2 z-10 flex items-center gap-1"> <div className="relative flex items-center gap-1.5">
<button {isRenderable(asset.type) && (
onClick={handleInspectWithAI}
className="px-2 py-1 rounded-md bg-ink-900 text-ink-0 hover:bg-ink-800 text-[10px] font-bold shadow-sm transition-all hover:scale-105 flex items-center gap-1 border border-ink-700 cursor-pointer"
title="Inspect with AI Advisor Workbench"
>
<Sparkles className="w-3 h-3 text-amber-400 fill-amber-400" />
<span>ask AI</span>
</button>
{isRenderable(asset.type, asset.url) && (
<button <button
onClick={() => onOpenViewer(asset)} onClick={() => onOpenViewer(asset)}
className="p-1 rounded-md bg-ink-0/90 backdrop-blur text-ink-600 hover:text-ink-900 border border-ink-200 shadow-sm transition-all hover:scale-105 cursor-pointer" className="p-1 rounded-lg text-ink-500 hover:text-ink-900 hover:bg-ink-100 transition-colors"
title="Preview Online" title="Preview Online"
> >
<Eye className="w-3.5 h-3.5" /> <Eye className="w-4 h-4" />
</button> </button>
)} )}
<div className="relative"> <button
<button onClick={() => setActiveMenuId(isMenuOpen ? null : asset.id)}
onClick={() => setActiveMenuId(isMenuOpen ? null : asset.id)} className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-100 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
className="p-1 rounded-md bg-ink-0/90 backdrop-blur text-ink-600 hover:text-ink-900 border border-ink-200 shadow-sm transition-all hover:scale-105 cursor-pointer"
>
<MoreVertical className="w-3.5 h-3.5" />
</button>
{isMenuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setActiveMenuId(null)} />
<div className="absolute right-0 mt-1.5 w-48 bg-ink-0 border border-ink-200 rounded-lg shadow-lg z-20 overflow-hidden py-1">
<button
onClick={(e) => {
handleInspectWithAI(e);
setActiveMenuId(null);
}}
className="w-full text-left px-4 py-2 text-xs font-bold text-amber-650 hover:bg-amber-50 flex items-center gap-2"
>
<Sparkles className="w-3.5 h-3.5 text-amber-500 fill-amber-500" />
Inspect with AI Advisor
</button>
<button
onClick={() => {
onViewDetails(asset);
setActiveMenuId(null);
}}
className="w-full text-left px-4 py-2 text-xs font-semibold text-ink-700 hover:bg-ink-50 hover:text-ink-900 flex items-center gap-2"
>
<File className="w-3.5 h-3.5" />
View Details
</button>
{user?.role === 'ADMIN' && (
<>
<button
onClick={() => {
onEdit(asset);
setActiveMenuId(null);
}}
className="w-full text-left px-4 py-2 text-xs font-semibold text-ink-700 hover:bg-ink-50 hover:text-ink-900 flex items-center gap-2"
>
<Edit3 className="w-3.5 h-3.5" />
Edit Asset
</button>
<button
onClick={() => {
onShare(asset);
setActiveMenuId(null);
}}
className="w-full text-left px-4 py-2 text-xs font-semibold text-ink-700 hover:bg-ink-50 hover:text-ink-900 flex items-center gap-2"
>
<Share2 className="w-3.5 h-3.5" />
Share Settings
</button>
<button
onClick={() => {
onDelete(asset.id);
setActiveMenuId(null);
}}
className="w-full text-left px-4 py-2 text-xs font-semibold text-red-650 hover:bg-red-500/10 flex items-center gap-2"
>
<Trash2 className="w-3.5 h-3.5" />
Delete Asset
</button>
</>
)}
</div>
</>
)}
</div>
</div>
{/* Thumbnail Preview Area Content */}
{hasBanner ? (
<img
src={getFullAssetUrl(bannerSrc)}
alt={asset.title}
loading="lazy"
decoding="async"
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
onError={() => setImgError(true)}
/>
) : isPdf ? (
<PdfThumbnail
url={getFullAssetUrl(asset.url)}
title={asset.title}
fallback={
<div className="flex flex-col items-center justify-center w-full h-full p-4 relative bg-red-500/[0.03] hover:bg-red-500/[0.06] transition-colors">
<div className="w-[105px] h-[135px] bg-ink-0 border border-red-500/20 shadow-md rounded-lg flex flex-col justify-between p-3 relative overflow-hidden transition-all duration-300 group-hover:scale-105">
<div className="absolute top-0 right-0 left-0 bg-red-600 text-ink-0 py-1 text-[8px] font-extrabold uppercase text-center tracking-wider font-sans">
PDF Document
</div>
<div className="space-y-1.5 mt-6 flex-grow pt-1">
<div className="h-1 bg-ink-200 rounded w-11/12" />
<div className="h-1 bg-ink-200 rounded w-full" />
<div className="h-1 bg-ink-250 rounded w-10/12" />
<div className="h-1 bg-ink-200 rounded w-full" />
<div className="h-1 bg-ink-200 rounded w-3/4" />
</div>
<div className="flex items-center justify-between text-[7px] text-ink-400 font-bold border-t border-ink-100 pt-1 mt-1 font-sans">
<span>PDF RESOURCE</span>
<FileText className="w-3 h-3 text-red-650" />
</div>
</div>
</div>
}
/>
) : isWord ? (
<DocxThumbnail
url={getFullAssetUrl(asset.url)}
fallback={
<div className="w-full h-full bg-white border border-slate-200 text-left select-none relative overflow-hidden p-3.5 flex flex-col justify-between">
<div className="absolute top-0 right-0 left-0 h-1 bg-slate-800" />
<div className="flex justify-between items-center text-[7px] text-slate-400 font-bold tracking-wide border-b border-slate-100 pb-1.5">
<span>PARTNER ASSET LIBRARY</span>
<span className="font-extrabold text-slate-800">DOCX</span>
</div>
<div className="space-y-2 flex-grow mt-3">
<h4 className="text-[11px] font-extrabold text-slate-900 leading-snug font-sans line-clamp-2">
{asset.title}
</h4>
<div className="space-y-1.5">
<div className="h-1.5 bg-slate-100 rounded w-full" />
<div className="h-1.5 bg-slate-100 rounded w-11/12" />
<div className="h-1.5 bg-slate-55 rounded w-3/4" />
</div>
</div>
<div className="flex items-center justify-between text-[7px] text-slate-400 font-bold border-t border-slate-100 pt-1.5 mt-auto font-mono">
<span>PAGE 1 OF 1</span>
<span className="text-slate-350">CONFIDENTIAL</span>
</div>
</div>
}
/>
) : isSpreadsheet ? (
<SpreadsheetThumbnail
url={getFullAssetUrl(asset.url)}
fallback={
<div className="w-full h-full bg-white border border-slate-200 text-left select-none relative overflow-hidden flex flex-col justify-between">
<div className="h-5 bg-emerald-700 border-b border-emerald-800 flex items-center px-2 shrink-0 justify-between select-none">
<span className="text-[8px] font-extrabold text-white tracking-wider">Spreadsheet Editor</span>
<span className="text-[6px] font-extrabold text-emerald-100 bg-emerald-800/80 px-1 py-0.5 rounded uppercase">XLSX</span>
</div>
<div className="flex-1 flex flex-col min-h-0 bg-white font-mono text-[6px]">
<div className="h-4 bg-slate-100 border-b border-slate-200 flex shrink-0">
<div className="w-6 border-r border-slate-200 bg-slate-150 flex items-center justify-center text-slate-500 font-bold shrink-0"></div>
<div className="flex-1 border-r border-slate-200 flex items-center justify-center text-slate-500 font-bold">A</div>
<div className="flex-1 border-r border-slate-200 flex items-center justify-center text-slate-500 font-bold">B</div>
<div className="flex-1 border-r border-slate-200 flex items-center justify-center text-slate-500 font-bold">C</div>
<div className="flex-1 flex items-center justify-center text-slate-500 font-bold">D</div>
</div>
<div className="flex-1 flex flex-col divide-y divide-slate-100">
<div className="h-4 flex items-center bg-emerald-500/5 divide-x divide-slate-100">
<div className="w-6 bg-slate-100 flex items-center justify-center text-slate-500 font-bold shrink-0">1</div>
<div className="flex-1 px-1 font-bold text-emerald-900 truncate">
{asset.title}
</div>
</div>
<div className="h-4 flex items-center bg-slate-55 divide-x divide-slate-100">
<div className="w-6 bg-slate-100 flex items-center justify-center text-slate-500 font-bold shrink-0">2</div>
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Category</div>
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Date</div>
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Size</div>
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Status</div>
</div>
</div>
</div>
<div className="h-4 bg-slate-50 border-t border-slate-200 flex items-center px-2 shrink-0 select-none justify-between">
<div className="flex gap-1">
<span className="text-[6px] font-extrabold text-emerald-700 bg-white border-x border-t border-slate-200 px-1.5 py-0.5 rounded-t select-none leading-none">Sheet1</span>
<span className="text-[6px] text-slate-400 px-1 py-0.5 select-none leading-none">Sheet2</span>
</div>
<span className="text-[5px] text-slate-350 font-bold">READY</span>
</div>
</div>
}
/>
) : asset.type === 'url' ? (
<div className="w-full h-full flex flex-col bg-slate-900 border border-slate-800 relative overflow-hidden group/browser">
{/* Browser Header Bar */}
<div className="h-6 bg-slate-800/80 border-b border-slate-700/50 flex items-center px-3 gap-2 shrink-0 select-none">
{/* Windows Dots */}
<div className="flex gap-1.5 shrink-0">
<span className="w-2.5 h-2.5 rounded-full bg-red-500/80" />
<span className="w-2.5 h-2.5 rounded-full bg-yellow-500/80" />
<span className="w-2.5 h-2.5 rounded-full bg-green-500/80" />
</div>
{/* URL Bar */}
<div className="flex-1 max-w-[150px] mx-auto bg-slate-900/60 border border-slate-700/30 rounded px-2 py-0.5 flex items-center gap-1 text-[9px] text-slate-400 font-mono select-none truncate">
<Globe className="w-2.5 h-2.5 text-slate-500 shrink-0" />
<span className="truncate">{getFriendlyHostname(asset.url)}</span>
</div>
</div>
{/* Browser Body Web Mockup */}
<div className="flex-1 bg-slate-950 p-3 flex flex-col justify-between relative overflow-hidden">
{/* Decorative Grid Pattern */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:10px_10px]" />
{/* Mock Web Page Hero Abstract Layout */}
<div className="space-y-2 mt-1.5 relative z-10">
{/* Mock Navbar */}
<div className="flex justify-between items-center bg-white/[0.03] border border-white/5 rounded px-2 py-0.5">
<div className="w-8 h-1.5 bg-slate-700 rounded" />
<div className="flex gap-1">
<div className="w-4 h-1 bg-white/20 rounded" />
<div className="w-4 h-1 bg-white/20 rounded" />
</div>
</div>
{/* Mock Page Content */}
<div className="space-y-1 pt-1">
<div className="h-2.5 bg-slate-700 rounded w-3/4" />
<div className="h-1.5 bg-white/20 rounded w-11/12" />
<div className="h-1.5 bg-white/10 rounded w-5/6" />
</div>
</div>
{/* Mock Card Domain Display */}
<div className="relative z-10 bg-slate-900/80 border border-slate-800/80 rounded-lg p-2 flex items-center gap-2 select-none">
<div className="w-6 h-6 rounded bg-slate-800 flex items-center justify-center text-[10px] font-black text-white shrink-0">
{getFriendlyHostname(asset.url).charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0 text-left">
<div className="text-[10px] font-bold text-white font-mono truncate leading-none">
{getFriendlyHostname(asset.url)}
</div>
<div className="text-[7px] text-slate-500 font-mono mt-0.5 leading-none">
EXTERNAL PORTAL
</div>
</div>
</div>
</div>
</div>
) : (
<div
id={`fallback-${asset.id}`}
className="w-full h-full flex items-center justify-center bg-ink-50 relative overflow-hidden"
> >
{isPresentation ? ( <MoreVertical className="w-4 h-4" />
<div className="w-full h-full flex bg-slate-900 border border-slate-800 text-left select-none relative overflow-hidden"> </button>
{/* Left slide thumbnails rail */}
<div className="w-10 bg-slate-950 border-r border-slate-850 flex flex-col items-center py-2 gap-1.5 shrink-0"> {isMenuOpen && (
<div className="w-7 h-5 rounded bg-amber-600/30 border border-amber-500/50 flex items-center justify-center text-[7px] text-amber-500 font-bold">1</div> <>
<div className="w-7 h-5 rounded bg-slate-850 border border-slate-750 flex items-center justify-center text-[7px] text-slate-500">2</div> <div className="fixed inset-0 z-10" onClick={() => setActiveMenuId(null)} />
<div className="w-7 h-5 rounded bg-slate-850 border border-slate-750 flex items-center justify-center text-[7px] text-slate-500">3</div> <div className="absolute right-0 mt-8 w-44 bg-ink-0 border border-ink-200 rounded-lg shadow-lg z-20 overflow-hidden py-1">
</div> <button
{/* Main slide content area */} onClick={() => {
<div className="flex-1 bg-gradient-to-tr from-slate-950 via-slate-900 to-amber-950/60 p-3.5 flex flex-col justify-between relative"> onViewDetails(asset);
<div className="space-y-1.5 mt-2 relative z-10"> setActiveMenuId(null);
<span className="inline-flex px-1.5 py-0.5 rounded text-[7px] font-extrabold uppercase bg-amber-500 text-slate-950 tracking-wider"> }}
Slide Show className="w-full text-left px-4 py-2 text-xs font-semibold text-ink-700 hover:bg-ink-50 hover:text-ink-900 flex items-center gap-2"
</span> >
<h4 className="text-[11px] font-extrabold text-white leading-tight font-sans line-clamp-2 mt-1"> <File className="w-3.5 h-3.5" />
{asset.title} View Details
</h4> </button>
<div className="space-y-1 pt-1.5"> {user?.role === 'ADMIN' && (
<div className="flex items-center gap-1"> <>
<span className="w-1 h-1 rounded-full bg-amber-500" /> <button
<div className="h-1 bg-white/20 rounded w-5/6" /> onClick={() => {
</div> onEdit(asset);
<div className="flex items-center gap-1"> setActiveMenuId(null);
<span className="w-1 h-1 rounded-full bg-amber-500" /> }}
<div className="h-1 bg-white/20 rounded w-3/4" /> className="w-full text-left px-4 py-2 text-xs font-semibold text-ink-700 hover:bg-ink-50 hover:text-ink-900 flex items-center gap-2"
</div> >
<div className="flex items-center gap-1"> <Edit3 className="w-3.5 h-3.5" />
<span className="w-1 h-1 rounded-full bg-amber-500" /> Edit Asset
<div className="h-1 bg-white/10 rounded w-4/6" /> </button>
</div> <button
</div> onClick={() => {
</div> onShare(asset);
{/* Slide Footer */} setActiveMenuId(null);
<div className="flex items-center justify-between text-[7px] text-slate-405 font-bold border-t border-slate-800/80 pt-1.5 mt-auto relative z-10 font-mono"> }}
<span>KEYNOTE PRESENTATION</span> className="w-full text-left px-4 py-2 text-xs font-semibold text-ink-700 hover:bg-ink-50 hover:text-ink-900 flex items-center gap-2"
<span className="font-extrabold text-amber-550 text-[6px]">PPTX</span> >
</div> <Share2 className="w-3.5 h-3.5" />
</div> Share Settings
</button>
<button
onClick={() => {
onDelete(asset.id);
setActiveMenuId(null);
}}
className="w-full text-left px-4 py-2 text-xs font-semibold text-red-650 hover:bg-red-500/10 flex items-center gap-2"
>
<Trash2 className="w-3.5 h-3.5" />
Delete Asset
</button>
</>
)}
</div> </div>
) : isWord ? ( </>
<div className="w-full h-full bg-white border border-slate-200 text-left select-none relative overflow-hidden p-3.5 flex flex-col justify-between"> )}
{/* Document Margins decorative elements */} </div>
<div className="absolute top-0 right-0 left-0 h-1 bg-slate-800" />
{/* Word Header */}
<div className="flex justify-between items-center text-[7px] text-slate-400 font-bold tracking-wide border-b border-slate-100 pb-1.5">
<span>PARTNER ASSET LIBRARY</span>
<span className="font-extrabold text-slate-800">DOCX</span>
</div>
{/* Page Title & Body Text Mockup */}
<div className="space-y-2 flex-grow mt-3">
<h4 className="text-[11px] font-extrabold text-slate-900 leading-snug font-sans line-clamp-2">
{asset.title}
</h4>
<div className="space-y-1.5">
<div className="h-1.5 bg-slate-100 rounded w-full" />
<div className="h-1.5 bg-slate-100 rounded w-11/12" />
<div className="h-1.5 bg-slate-100 rounded w-full" />
<div className="h-1.5 bg-slate-50 rounded w-3/4" />
</div>
</div>
{/* Signature / Metadata stamp at the bottom */}
<div className="flex items-center justify-between text-[7px] text-slate-400 font-bold border-t border-slate-100 pt-1.5 mt-auto font-mono">
<span>PAGE 1 OF 1</span>
<span className="text-slate-350">CONFIDENTIAL</span>
</div>
</div>
) : isSpreadsheet ? (
<div className="w-full h-full bg-white border border-slate-200 text-left select-none relative overflow-hidden flex flex-col justify-between">
{/* Excel Top Bar */}
<div className="h-5 bg-emerald-700 border-b border-emerald-800 flex items-center px-2 shrink-0 justify-between select-none">
<span className="text-[8px] font-extrabold text-white tracking-wider">Spreadsheet Editor</span>
<span className="text-[6px] font-extrabold text-emerald-100 bg-emerald-800/80 px-1 py-0.5 rounded uppercase">XLSX</span>
</div>
{/* Sheet Grid Layout */}
<div className="flex-1 flex flex-col min-h-0 bg-white font-mono text-[6px]">
{/* Headers Row */}
<div className="h-4 bg-slate-100 border-b border-slate-200 flex shrink-0">
<div className="w-6 border-r border-slate-200 bg-slate-150 flex items-center justify-center text-slate-500 font-bold shrink-0"></div>
<div className="flex-1 border-r border-slate-200 flex items-center justify-center text-slate-500 font-bold">A</div>
<div className="flex-1 border-r border-slate-200 flex items-center justify-center text-slate-500 font-bold">B</div>
<div className="flex-1 border-r border-slate-200 flex items-center justify-center text-slate-500 font-bold">C</div>
<div className="flex-1 flex items-center justify-center text-slate-500 font-bold">D</div>
</div>
{/* Grid Rows */}
<div className="flex-1 flex flex-col divide-y divide-slate-100">
{/* Row 1 - Title */}
<div className="h-4 flex items-center bg-emerald-500/5 divide-x divide-slate-100">
<div className="w-6 bg-slate-100 flex items-center justify-center text-slate-500 font-bold shrink-0">1</div>
<div className="flex-1 px-1 font-bold text-emerald-900 truncate">
{asset.title}
</div>
</div>
{/* Row 2 - Headers */}
<div className="h-4 flex items-center bg-slate-50 divide-x divide-slate-100">
<div className="w-6 bg-slate-100 flex items-center justify-center text-slate-500 font-bold shrink-0">2</div>
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Category</div>
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Date</div>
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Size</div>
<div className="flex-1 px-1 font-bold text-slate-700 truncate">Status</div>
</div>
{/* Row 3 - Data */}
<div className="h-4 flex items-center divide-x divide-slate-100">
<div className="w-6 bg-slate-100 flex items-center justify-center text-slate-500 font-bold shrink-0">3</div>
<div className="flex-1 px-1 text-slate-600 truncate">{asset.categoryId || 'General'}</div>
<div className="flex-1 px-1 text-slate-600 truncate">{new Date(asset.createdAt).toLocaleDateString()}</div>
<div className="flex-1 px-1 text-slate-600 truncate">{formatBytes(asset.size)}</div>
<div className="flex-1 px-1 text-emerald-650 font-bold truncate">Published</div>
</div>
</div>
</div>
{/* Excel Sheet Tab Footer */}
<div className="h-4 bg-slate-50 border-t border-slate-200 flex items-center px-2 shrink-0 select-none justify-between">
<div className="flex gap-1">
<span className="text-[6px] font-extrabold text-emerald-700 bg-white border-x border-t border-slate-200 px-1.5 py-0.5 rounded-t select-none leading-none">Sheet1</span>
<span className="text-[6px] text-slate-400 px-1 py-0.5 select-none leading-none">Sheet2</span>
</div>
<span className="text-[5px] text-slate-350 font-bold">READY</span>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center w-full h-full p-4 bg-ink-500/[0.03] hover:bg-ink-500/[0.06] transition-colors">
<div className="w-[105px] h-[135px] bg-ink-0 border border-ink-200 shadow-md rounded-lg flex flex-col justify-between p-3 relative overflow-hidden transition-all duration-300 group-hover:scale-105">
<div className="absolute top-0 right-0 left-0 bg-ink-700 text-ink-0 py-1 text-[8px] font-extrabold uppercase text-center tracking-wider font-sans">
Resource File
</div>
<div className="flex-grow flex items-center justify-center pt-4">
<File className="w-8 h-8 text-ink-400" />
</div>
<div className="flex items-center justify-between text-[7px] text-ink-400 font-bold border-t border-ink-100 pt-1 mt-1 font-sans">
<span>BINARY</span>
<span className="font-extrabold uppercase text-[6px]">{asset.type.split('/').pop() || 'FILE'}</span>
</div>
</div>
</div>
)}
</div>
)}
</div> </div>
<div className="space-y-1 mb-4 flex-grow flex flex-col"> <div className="space-y-1 mb-4">
{!asset.isDownloadable && ( <div className="flex items-center gap-1.5 mb-2">
<div className="flex items-center gap-1.5 mb-2 overflow-hidden"> <div className="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold text-ink-500 uppercase tracking-wider bg-ink-50 border border-ink-200">
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-ink-600 bg-ink-100 border border-ink-200 shrink-0"> {asset.categoryId || 'General'}
</div>
{!asset.isDownloadable && (
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-ink-600 bg-ink-100 border border-ink-200">
<Lock className="w-2.5 h-2.5" /> <Lock className="w-2.5 h-2.5" />
<span>Strict View Only</span> <span>Strict View Only</span>
</div> </div>
</div>
)}
<h3 className="font-bold text-ink-900 text-sm asset-card-title leading-snug line-clamp-2 group-hover:text-ink-900 transition-colors" title={asset.title}>
{asset.title}
</h3>
{asset.description && (
<p className="text-xs text-ink-500 line-clamp-2 mt-1.5 leading-relaxed" title={asset.description}>
{asset.description}
</p>
)}
<div className="flex items-center justify-between mt-auto pt-2.5">
<p className="text-[11px] font-medium text-ink-400">
{asset.type === 'case_study' ? 'Case Study' : asset.type === 'url' ? 'External Link' : formatBytes(asset.size)} {new Date(asset.createdAt).toLocaleDateString()}
</p>
{asset.type === 'case_study' && (asset.problemStatement || asset.solution) && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleExpand?.();
}}
className="toggle-expand-btn text-[10px] font-bold text-ink-650 hover:text-ink-900 bg-ink-50 hover:bg-ink-100 border border-ink-200 px-2 py-0.5 rounded transition-all cursor-pointer"
>
{isExpanded ? 'Hide Summary' : 'Show Summary'}
</button>
)} )}
</div> </div>
<AnimatePresence initial={false}> <h3 className="font-bold text-ink-900 text-sm leading-snug line-clamp-2 group-hover:text-ink-950 transition-colors" title={asset.title}>
{asset.type === 'case_study' && isExpanded && (asset.problemStatement || asset.solution) && ( {asset.title}
<motion.div </h3>
initial={{ height: 0, opacity: 0 }} <p className="text-[11px] font-medium text-ink-400 mt-1">
animate={{ height: 'auto', opacity: 1 }} {asset.type === 'url' ? 'External Link' : formatBytes(asset.size)} {new Date(asset.createdAt).toLocaleDateString()}
exit={{ height: 0, opacity: 0 }} </p>
transition={{ duration: 0.25, ease: 'easeInOut' }}
className="mt-3.5 space-y-2.5 border-t border-ink-100 pt-3 text-[11px] overflow-hidden"
>
{asset.problemStatement && (
<div>
<span className="font-bold text-red-650 uppercase tracking-wider text-[9px] block mb-0.5">Problem / Challenge</span>
<p className="text-ink-700 leading-relaxed bg-red-500/[0.01] border border-red-500/5 p-2 rounded" title={asset.problemStatement}>
{asset.problemStatement}
</p>
</div>
)}
{asset.solution && (
<div>
<span className="font-bold text-emerald-650 uppercase tracking-wider text-[9px] block mb-0.5">Our Approach</span>
<p className="text-ink-700 leading-relaxed bg-emerald-500/[0.01] border border-emerald-500/5 p-2 rounded" title={asset.solution}>
{asset.solution}
</p>
</div>
)}
<div className="pt-2 flex justify-end">
<a
href={asset.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[10px] font-bold text-ink-600 hover:text-ink-900 transition-colors bg-ink-50 hover:bg-ink-100 border border-ink-200 px-2.5 py-1 rounded-md"
>
<span>Read Full Case Study</span>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
</motion.div>
)}
</AnimatePresence>
</div> </div>
</div> </div>
<div className="pt-3 border-t border-ink-100 flex items-center justify-between mt-auto"> <div className="pt-3 border-t border-ink-100 flex items-center justify-between mt-auto">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[10px] font-semibold uppercase tracking-wider text-ink-400"> <span className="text-[10px] font-semibold uppercase tracking-wider text-ink-400">
{asset.type === 'url' || asset.type === 'case_study' ? 'Type' : 'Downloads'} {asset.type === 'url' ? 'Type' : 'Downloads'}
</span> </span>
<span className="text-xs font-extrabold text-ink-900 mt-0.5"> <span className="text-xs font-extrabold text-ink-900 mt-0.5">
{asset.type === 'case_study' ? 'Case Study' : asset.type === 'url' ? 'URL Link' : asset.downloadsCount} {asset.type === 'url' ? 'URL Link' : asset.downloadsCount}
</span> </span>
</div> </div>
{asset.type === 'url' || asset.type === 'case_study' ? ( {asset.type === 'url' ? (
<a <a
href={asset.url} href={asset.url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="w-7 h-7 rounded-md bg-ink-900 border border-ink-900 flex items-center justify-center text-ink-0 hover:bg-ink-800 transition-all shadow-sm" className="w-7 h-7 rounded-md bg-ink-900 border border-ink-900 flex items-center justify-center text-ink-0 hover:bg-ink-800 transition-all shadow-sm"
title={asset.type === 'case_study' ? 'Open Case Study' : 'Open External URL'} title="Open External URL"
> >
<ExternalLink className="w-3.5 h-3.5" /> <ExternalLink className="w-3.5 h-3.5" />
</a> </a>
@ -750,7 +224,6 @@ export const AssetCard: React.FC<AssetCardProps> = ({
</button> </button>
)} )}
</div> </div>
</motion.div> </div>
); );
}; };

View File

@ -1,8 +1,7 @@
import React from 'react'; import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X } from 'lucide-react';
import type { Asset } from '../../../types/assets'; import type { Asset } from '../../../types/assets';
import { getFullAssetUrl } from '../../../utils/asset-url';
import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button';
interface AssetDetailsModalProps { interface AssetDetailsModalProps {
isOpen: boolean; isOpen: boolean;
@ -27,207 +26,109 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
}; };
return ( return (
<Modal <AnimatePresence>
isOpen={isOpen && !!asset} {isOpen && asset && (
onClose={onClose} <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
title="Asset Details" <motion.div
size="2xl" initial={{ opacity: 0 }}
footer={ animate={{ opacity: 1 }}
<Button exit={{ opacity: 0 }}
onClick={onClose} onClick={onClose}
variant="primary" className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
size="sm" />
> <motion.div
Close initial={{ opacity: 0, scale: 0.95, y: 10 }}
</Button> animate={{ opacity: 1, scale: 1, y: 0 }}
} exit={{ opacity: 0, scale: 0.95, y: 10 }}
> className="relative bg-ink-0 border border-ink-200 rounded-2xl p-6 max-w-md w-full shadow-xl z-10 space-y-5 text-ink-900"
{asset && ( >
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 text-xs font-medium text-ink-900"> <div className="flex justify-between items-center pb-3 border-b border-ink-100">
{/* Left Column */} <h3 className="text-base font-bold text-ink-900">Asset Details</h3>
<div className="space-y-4"> <button
<div> onClick={onClose}
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Title</h4> className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
<p className="text-sm font-extrabold text-ink-900 mt-1 font-sans">{asset.title}</p> >
<X className="w-5 h-5" />
</button>
</div> </div>
{asset.description && ( <div className="space-y-4 text-xs font-medium">
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Description</h4> <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Title</h4>
<p className="text-ink-700 mt-1 leading-relaxed font-sans">{asset.description}</p> <p className="text-sm font-extrabold text-ink-900 mt-1">{asset.title}</p>
</div> </div>
)}
{(asset.type === 'case_study' || asset.thumbnailUrl) && asset.thumbnailUrl && ( {asset.description && (
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans mb-1.5">Banner Image</h4> <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Description</h4>
<div className="w-full h-44 rounded-lg overflow-hidden border border-ink-200 shadow-sm bg-ink-50"> <p className="text-ink-700 mt-1 leading-relaxed">{asset.description}</p>
<img </div>
src={getFullAssetUrl(asset.thumbnailUrl)} )}
alt="Asset Banner"
className="w-full h-full object-cover" <div className="grid grid-cols-2 gap-4">
/> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Category</h4>
<p className="text-ink-900 mt-1 font-bold">{asset.categoryId || 'General'}</p>
</div>
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Subcategory</h4>
<p className="text-ink-900 mt-1 font-bold">{asset.subcategory || '-'}</p>
</div> </div>
</div> </div>
)}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Category</h4>
<p className="text-ink-900 mt-1 font-bold font-sans">{asset.categoryId || 'General'}</p>
</div>
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Subcategory</h4>
<p className="text-ink-900 mt-1 font-bold font-sans">{asset.subcategory || '-'}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">File Size</h4>
<p className="text-ink-900 mt-1 font-bold font-sans">{asset.type === 'url' ? 'N/A' : formatBytes(asset.size)}</p>
</div>
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">File Type</h4>
<p className="text-ink-900 mt-1 font-bold font-sans">{asset.type}</p>
</div>
</div>
</div>
{/* Right Column */}
<div className="space-y-4">
{asset.type === 'case_study' && asset.problemStatement && (
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-red-650 font-sans mb-1.5">Problem / Challenge</h4>
<p className="text-ink-700 leading-relaxed font-sans whitespace-pre-wrap text-xs bg-red-500/[0.02] border border-red-500/10 p-3 rounded-lg shadow-sm">
{asset.problemStatement}
</p>
</div>
)}
{asset.type === 'case_study' && asset.solution && (
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-emerald-650 font-sans mb-1.5">Suggested Solution</h4>
<p className="text-ink-700 leading-relaxed font-sans whitespace-pre-wrap text-xs bg-emerald-500/[0.02] border border-emerald-500/10 p-3 rounded-lg shadow-sm">
{asset.solution}
</p>
</div>
)}
{/* Taxonomy Metadata Section */}
<div className="space-y-3 pt-3 border-t border-ink-200">
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">
Taxonomy & Enterprise Classification
</h4>
{/* Verticals */}
{asset.verticals && asset.verticals.length > 0 && (
<div> <div>
<span className="text-[10px] font-bold text-ink-600 block mb-1">Industry Verticals / Domains:</span> <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">File Size</h4>
<div className="flex flex-wrap gap-1.5"> <p className="text-ink-900 mt-1 font-bold">{asset.type === 'url' ? 'N/A' : formatBytes(asset.size)}</p>
{asset.verticals.map(v => ( </div>
<span <div>
key={v.id} <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">File Type</h4>
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-bold border" <p className="text-ink-900 mt-1 font-bold">{asset.type}</p>
style={{ </div>
backgroundColor: `${v.color || '#3b82f6'}15`, </div>
borderColor: `${v.color || '#3b82f6'}40`,
color: v.color || '#3b82f6', {asset.tags.length > 0 && (
}} <div>
> <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Tags</h4>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: v.color || '#3b82f6' }} /> <div className="flex flex-wrap gap-1.5 mt-1.5">
{v.name} {asset.tags.map(tag => (
<span key={tag} className="px-2 py-0.5 rounded bg-ink-50 border border-ink-200 text-[10px] font-bold text-ink-600">
{tag}
</span> </span>
))} ))}
</div> </div>
</div> </div>
)} )}
{/* Tech Stacks */} {userRole === 'ADMIN' && asset.sharedWith && (
{asset.techStacks && asset.techStacks.length > 0 && (
<div> <div>
<span className="text-[10px] font-bold text-ink-600 block mb-1">Tech Stack & Capabilities:</span> <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Shared With</h4>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5 mt-1.5">
{asset.techStacks.map(t => ( {asset.sharedWith.length === 0 ? (
<span <span className="text-ink-500 font-semibold italic">Not shared with any organization</span>
key={t.id} ) : (
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-bold text-slate-700 dark:text-slate-200 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700" asset.sharedWith.map(sw => (
> <span key={sw.userId ? `${sw.organizationId}-${sw.userId}` : sw.organizationId} className="px-2 py-0.5 rounded bg-ink-900 text-ink-0 text-[10px] font-bold">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: t.color || '#64748b' }} /> {sw.organization?.name || 'Unknown Organization'} {sw.user ? `(${sw.user.email})` : '(Entire Org)'}
{t.name} </span>
</span> ))
))} )}
</div>
</div>
)}
{/* Engagement Types */}
{asset.engagementTypes && asset.engagementTypes.length > 0 && (
<div>
<span className="text-[10px] font-bold text-ink-600 block mb-1">Engagement Type:</span>
<div className="flex flex-wrap gap-1.5">
{asset.engagementTypes.map(e => (
<span
key={e.id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-bold text-sky-700 dark:text-sky-300 bg-sky-50 dark:bg-sky-950/60 border border-sky-200 dark:border-sky-800"
>
{e.name}
</span>
))}
</div>
</div>
)}
{/* Compliance Standards */}
{asset.complianceStandards && asset.complianceStandards.length > 0 && (
<div>
<span className="text-[10px] font-bold text-ink-600 block mb-1">Compliance & Governance:</span>
<div className="flex flex-wrap gap-1.5">
{asset.complianceStandards.map(c => (
<span
key={c.id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-bold text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/60 border border-emerald-200 dark:border-emerald-800"
>
🛡 {c.name}
</span>
))}
</div> </div>
</div> </div>
)} )}
</div> </div>
{asset.tags.length > 0 && ( <div className="pt-3.5 border-t border-ink-100 flex justify-end">
<div> <button
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Tags</h4> onClick={onClose}
<div className="flex flex-wrap gap-1.5 mt-1.5"> className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm"
{asset.tags.map(tag => ( >
<span key={tag} className="px-2 py-0.5 rounded bg-ink-50 border border-ink-200 text-[10px] font-bold text-ink-600 font-sans"> Close
{tag} </button>
</span> </div>
))} </motion.div>
</div>
</div>
)}
{userRole === 'ADMIN' && asset.sharedWith && (
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Shared With</h4>
<div className="flex flex-wrap gap-1.5 mt-1.5">
{asset.sharedWith.length === 0 ? (
<span className="text-ink-500 font-semibold italic font-sans">Not shared with any organization</span>
) : (
asset.sharedWith.map(sw => (
<span key={sw.userId ? `${sw.organizationId}-${sw.userId}` : sw.organizationId} className="px-2 py-0.5 rounded bg-ink-900 text-ink-0 text-[10px] font-bold font-sans">
{sw.organization?.name || 'Unknown Organization'} {sw.user ? `(${sw.user.email})` : '(Entire Org)'}
</span>
))
)}
</div>
</div>
)}
</div>
</div> </div>
)} )}
</Modal> </AnimatePresence>
); );
}; };

View File

@ -1,9 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import type { Asset, Category } from '../../../types'; import type { Asset, Category } from '../../../types';
import { apiClient } from '../../../lib/api-client'; import { apiClient } from '../../../lib/api-client';
import { getFullAssetUrl } from '../../../utils/asset-url';
import { Cpu, Terminal, Brain, Server, Search, Download, Calendar, User, Tag, HelpCircle, X } from 'lucide-react'; import { Cpu, Terminal, Brain, Server, Search, Download, Calendar, User, Tag, HelpCircle, X } from 'lucide-react';
import { useToast } from '../../../hooks/use-toast';
const GithubIcon: React.FC<{ className?: string }> = ({ className }) => ( const GithubIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg <svg
@ -21,7 +19,6 @@ const GithubIcon: React.FC<{ className?: string }> = ({ className }) => (
); );
export const AssetExplorer: React.FC = () => { export const AssetExplorer: React.FC = () => {
const { success, error } = useToast();
const [assets, setAssets] = useState<Asset[]>([]); const [assets, setAssets] = useState<Asset[]>([]);
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -34,6 +31,9 @@ export const AssetExplorer: React.FC = () => {
// Selected asset details modal state // Selected asset details modal state
const [selectedAsset, setSelectedAsset] = useState<Asset | null>(null); const [selectedAsset, setSelectedAsset] = useState<Asset | null>(null);
// Toast notifications simulation
const [toastMessage, setToastMessage] = useState<string | null>(null);
const fetchAssets = async () => { const fetchAssets = async () => {
setLoading(true); setLoading(true);
try { try {
@ -70,8 +70,13 @@ export const AssetExplorer: React.FC = () => {
fetchCategories(); fetchCategories();
}, []); }, []);
const triggerToast = (msg: string) => {
setToastMessage(msg);
setTimeout(() => setToastMessage(null), 3000);
};
const handleDownload = async (asset: Asset) => { const handleDownload = async (asset: Asset) => {
success('Starting download', `Downloading ${asset.title}...`); triggerToast(`Starting download: ${asset.title}`);
try { try {
const updatedAsset = { ...asset, downloadsCount: asset.downloadsCount + 1 }; const updatedAsset = { ...asset, downloadsCount: asset.downloadsCount + 1 };
await apiClient.put(`/assets/${asset.id}`, updatedAsset); await apiClient.put(`/assets/${asset.id}`, updatedAsset);
@ -82,7 +87,6 @@ export const AssetExplorer: React.FC = () => {
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
error('Download tracking failed', 'Unable to record download statistics.');
} }
}; };
@ -100,10 +104,19 @@ export const AssetExplorer: React.FC = () => {
return ( return (
<div className="space-y-6 text-ink-900"> <div className="space-y-6 text-ink-900">
{/* Toast Notification */}
{toastMessage && (
<div className="fixed bottom-5 right-5 z-50 rounded-xl bg-ink-900 text-ink-0 px-5 py-3 text-sm shadow-premium flex items-center gap-2 border border-ink-700 animate-slide-up">
<Download className="h-4 w-4 text-ink-0 animate-bounce" />
<span className="font-semibold text-ink-50">{toastMessage}</span>
</div>
)}
{/* Hero section */}
<div className="rounded-2xl bg-ink-0 p-6 md:p-8 border border-ink-200 flex flex-col md:flex-row items-center justify-between gap-6"> <div className="rounded-2xl bg-ink-0 p-6 md:p-8 border border-ink-200 flex flex-col md:flex-row items-center justify-between gap-6">
<div className="space-y-2"> <div className="space-y-2">
<h1 className="text-2xl explorer-title font-bold text-ink-900 md:text-3xl">Tech4Biz Asset Explorer</h1> <h1 className="text-2xl font-bold text-ink-900 md:text-3xl">Tech4Biz Asset Explorer</h1>
<p className="text-sm explorer-desc text-ink-700 max-w-xl"> <p className="text-sm text-ink-700 max-w-xl">
Browse, preview, and download proprietary hardware IP, framework assemblies, and cloud-native building blocks licensed under your master agreements. Browse, preview, and download proprietary hardware IP, framework assemblies, and cloud-native building blocks licensed under your master agreements.
</p> </p>
</div> </div>
@ -207,7 +220,7 @@ export const AssetExplorer: React.FC = () => {
{/* Card Image banner */} {/* Card Image banner */}
<div className="h-44 w-full relative overflow-hidden bg-ink-100"> <div className="h-44 w-full relative overflow-hidden bg-ink-100">
<img <img
src={getFullAssetUrl(asset.thumbnailUrl)} src={asset.thumbnailUrl}
alt={asset.title} alt={asset.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/> />
@ -242,7 +255,7 @@ export const AssetExplorer: React.FC = () => {
<div className="flex items-center justify-between border-t border-ink-100 pt-3"> <div className="flex items-center justify-between border-t border-ink-100 pt-3">
<button <button
onClick={() => setSelectedAsset(asset)} onClick={() => setSelectedAsset(asset)}
className="text-xs font-bold text-ink-800 hover:text-ink-900 transition-colors" className="text-xs font-bold text-ink-800 hover:text-ink-950 transition-colors"
> >
View Details View Details
</button> </button>
@ -276,7 +289,7 @@ export const AssetExplorer: React.FC = () => {
{/* Asset Details Modal */} {/* Asset Details Modal */}
{selectedAsset && ( {selectedAsset && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-900/20 backdrop-blur-sm p-4 animate-fade-in"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/20 backdrop-blur-sm p-4 animate-fade-in">
<div className="w-full max-w-2xl rounded-2xl border border-ink-200 bg-ink-0 p-6 shadow-xl max-h-[90vh] overflow-y-auto space-y-6 relative animate-scale-up"> <div className="w-full max-w-2xl rounded-2xl border border-ink-200 bg-ink-0 p-6 shadow-xl max-h-[90vh] overflow-y-auto space-y-6 relative animate-scale-up">
<button <button
onClick={() => setSelectedAsset(null)} onClick={() => setSelectedAsset(null)}
@ -288,7 +301,7 @@ export const AssetExplorer: React.FC = () => {
{/* Modal Image banner */} {/* Modal Image banner */}
<div className="h-60 rounded-xl overflow-hidden bg-ink-100 relative"> <div className="h-60 rounded-xl overflow-hidden bg-ink-100 relative">
<img <img
src={getFullAssetUrl(selectedAsset.thumbnailUrl)} src={selectedAsset.thumbnailUrl}
alt={selectedAsset.title} alt={selectedAsset.title}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />

View File

@ -152,8 +152,8 @@ export const AssetManagement: React.FC = () => {
<thead> <thead>
<tr className="border-b border-ink-100 bg-ink-50 text-xs font-bold uppercase tracking-wider text-ink-600"> <tr className="border-b border-ink-100 bg-ink-50 text-xs font-bold uppercase tracking-wider text-ink-600">
<th className="px-5 py-3.5">Asset Title / Category</th> <th className="px-5 py-3.5">Asset Title / Category</th>
<th className="px-5 py-3.5 hidden md:table-cell">Tags</th> <th className="px-5 py-3.5">Tags</th>
<th className="px-5 py-3.5 hidden sm:table-cell">Downloads</th> <th className="px-5 py-3.5">Downloads</th>
<th className="px-5 py-3.5">Status</th> <th className="px-5 py-3.5">Status</th>
<th className="px-5 py-3.5 text-right">Actions</th> <th className="px-5 py-3.5 text-right">Actions</th>
</tr> </tr>
@ -184,7 +184,7 @@ export const AssetManagement: React.FC = () => {
</div> </div>
</div> </div>
</td> </td>
<td className="px-5 py-4 hidden md:table-cell"> <td className="px-5 py-4">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{asset.tags.map(t => ( {asset.tags.map(t => (
<span key={t} className="rounded bg-ink-50 px-2 py-0.5 text-[10px] font-semibold text-ink-600 border border-ink-100"> <span key={t} className="rounded bg-ink-50 px-2 py-0.5 text-[10px] font-semibold text-ink-600 border border-ink-100">
@ -193,7 +193,7 @@ export const AssetManagement: React.FC = () => {
))} ))}
</div> </div>
</td> </td>
<td className="px-5 py-4 font-mono font-semibold text-ink-700 hidden sm:table-cell"> <td className="px-5 py-4 font-mono font-semibold text-ink-700">
{asset.downloadsCount} {asset.downloadsCount}
</td> </td>
<td className="px-5 py-4"> <td className="px-5 py-4">

View File

@ -1,224 +0,0 @@
import React from 'react';
import { Download, ExternalLink, FileText, Globe, Sparkles } from 'lucide-react';
import type { Asset } from '../../../types/assets';
interface AssetTableViewProps {
assets: Asset[];
selectedIds: string[];
recommendedIds?: string[];
onToggleSelect: (id: string, event: React.MouseEvent) => void;
onSelectAll: () => void;
onOpenAsset: (asset: Asset) => void;
onRequestDownload: (id: string) => void;
userRole?: string;
}
export const AssetTableView: React.FC<AssetTableViewProps> = ({
assets,
selectedIds,
recommendedIds = [],
onToggleSelect,
onSelectAll,
onOpenAsset,
onRequestDownload,
}) => {
const allSelected = assets.length > 0 && selectedIds.length === assets.length;
return (
<div className="w-full bg-white dark:bg-slate-900/90 rounded-xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden transition-all">
<div className="overflow-x-auto custom-scrollbar">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50 dark:bg-slate-800/80 border-b border-slate-200 dark:border-slate-800 text-[11px] font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">
<th className="py-3 px-4 w-10">
<input
type="checkbox"
checked={allSelected}
onChange={onSelectAll}
className="rounded border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-100 focus:ring-slate-400 cursor-pointer"
/>
</th>
<th className="py-3 px-4 min-w-[280px]">Asset Name & Domain</th>
<th className="py-3 px-4 min-w-[140px]">Vertical / Industry</th>
<th className="py-3 px-4 min-w-[130px]">Type / Format</th>
<th className="py-3 px-4 min-w-[120px]">Subcategory</th>
<th className="py-3 px-4 min-w-[120px]">Created</th>
<th className="py-3 px-4 text-right min-w-[110px]">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800/60 text-xs">
{assets.map((asset) => {
const isSelected = selectedIds.includes(asset.id);
const isRecommended = recommendedIds.includes(asset.id);
return (
<tr
id={`asset-card-${asset.id}`}
key={asset.id}
onClick={(e) => onToggleSelect(asset.id, e)}
className={`group hover:bg-slate-100/70 dark:hover:bg-slate-800/60 transition-colors cursor-pointer ${
isSelected
? 'bg-slate-100 dark:bg-slate-800/80 font-medium'
: isRecommended
? 'bg-amber-500/[0.03] dark:bg-amber-500/[0.04]'
: ''
}`}
>
{/* Selection Checkbox */}
<td className="py-3 px-4" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelected}
onChange={(e) => onToggleSelect(asset.id, e as any)}
className="rounded border-slate-300 dark:border-slate-600 text-slate-900 focus:ring-slate-400 cursor-pointer"
/>
</td>
{/* Title & Description & Recommended Badge */}
<td className="py-3 px-4 min-w-[280px]">
<div className="flex items-start gap-2.5">
<div className="p-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 shrink-0 mt-0.5">
{asset.type === 'url' || asset.type === 'case_study' ? (
<Globe className="w-4 h-4 text-slate-500 dark:text-slate-400" />
) : (
<FileText className="w-4 h-4 text-amber-500" />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-slate-900 dark:text-slate-100 group-hover:text-slate-900 dark:group-hover:text-white transition-colors truncate">
{asset.title}
</span>
{isRecommended && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-500 text-slate-950 text-[9px] font-extrabold uppercase tracking-wider shrink-0">
<Sparkles className="w-2.5 h-2.5 fill-slate-950" />
<span>Recommended</span>
</span>
)}
</div>
{asset.description && (
<div className="text-[11px] text-slate-500 dark:text-slate-400 line-clamp-1 mt-0.5">
{asset.description}
</div>
)}
{asset.tags && asset.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{asset.tags.slice(0, 3).map(t => (
<span key={t} className="text-[9px] font-mono px-1.5 py-0.2 rounded bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400">
#{t}
</span>
))}
{asset.tags.length > 3 && (
<span className="text-[9px] text-slate-400">+{asset.tags.length - 3}</span>
)}
</div>
)}
</div>
</div>
</td>
{/* Taxonomy Classification */}
<td className="py-3 px-4">
<div className="flex flex-wrap gap-1 max-w-xs">
{asset.verticals && asset.verticals.map(v => (
<span
key={v.id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border"
style={{
backgroundColor: `${v.color || '#3b82f6'}15`,
borderColor: `${v.color || '#3b82f6'}40`,
color: v.color || '#3b82f6',
}}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: v.color || '#3b82f6' }} />
{v.name}
</span>
))}
{asset.techStacks && asset.techStacks.map(t => (
<span
key={t.id}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-medium text-slate-700 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700"
>
{t.name}
</span>
))}
{asset.engagementTypes && asset.engagementTypes.map(e => (
<span
key={e.id}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold text-sky-700 dark:text-sky-300 bg-sky-50 dark:bg-sky-950/60 border border-sky-200 dark:border-sky-800"
>
{e.name}
</span>
))}
{asset.complianceStandards && asset.complianceStandards.map(c => (
<span
key={c.id}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-950/60 border border-emerald-200 dark:border-emerald-800"
>
{c.name}
</span>
))}
{(!asset.verticals || asset.verticals.length === 0) &&
(!asset.techStacks || asset.techStacks.length === 0) &&
(!asset.engagementTypes || asset.engagementTypes.length === 0) &&
(!asset.complianceStandards || asset.complianceStandards.length === 0) && (
<span className="text-slate-400 italic text-[11px]">General</span>
)}
</div>
</td>
{/* Type */}
<td className="py-3 px-4">
<span className="inline-block px-2 py-0.5 rounded text-[11px] font-semibold bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 capitalize">
{asset.contentType ? asset.contentType.replace('_', ' ') : asset.type}
</span>
</td>
{/* Subcategory */}
<td className="py-3 px-4 text-slate-700 dark:text-slate-300 font-semibold">
{asset.subcategory || asset.categoryId || '—'}
</td>
{/* Created Date */}
<td className="py-3 px-4 text-slate-500 text-[11px]">
{new Date(asset.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</td>
{/* Actions */}
<td className="py-3 px-4 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1.5">
<button
onClick={() => onOpenAsset(asset)}
className="p-1.5 rounded-lg text-slate-500 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
title="View Asset"
>
<ExternalLink className="w-4 h-4" />
</button>
{asset.isDownloadable && (
<button
onClick={() => onRequestDownload(asset.id)}
className="p-1.5 rounded-lg text-slate-500 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
title="Download Asset"
>
<Download className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
};

View File

@ -1,84 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { axiosInstance } from '../../../services/axios';
interface DocxThumbnailProps {
url: string;
fallback: React.ReactNode;
}
export const DocxThumbnail: React.FC<DocxThumbnailProps> = ({ url, fallback }) => {
const containerRef = useRef<HTMLDivElement>(null);
const parentRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(0.2);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let isMounted = true;
axiosInstance.get(url, { responseType: 'arraybuffer' })
.then(async (res) => {
if (!isMounted) return;
try {
const { renderAsync } = await import('docx-preview');
if (containerRef.current) {
containerRef.current.innerHTML = '';
await renderAsync(res.data, containerRef.current);
setLoading(false);
}
} catch (err) {
console.error('[DocxThumbnail] Render error:', err);
setError(true);
setLoading(false);
}
})
.catch(err => {
console.error('[DocxThumbnail] Fetch error:', err);
if (isMounted) {
setError(true);
setLoading(false);
}
});
return () => {
isMounted = false;
};
}, [url]);
useEffect(() => {
if (!parentRef.current) return;
const updateScale = () => {
if (parentRef.current) {
const width = parentRef.current.offsetWidth || 280;
setScale(width / 800);
}
};
updateScale();
const observer = new ResizeObserver(updateScale);
observer.observe(parentRef.current);
return () => observer.disconnect();
}, []);
if (error) return <>{fallback}</>;
return (
<div ref={parentRef} className="w-full h-full relative overflow-hidden bg-white select-none pointer-events-none">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-slate-50 z-10">
<div className="w-5 h-5 rounded-full border-2 border-slate-800 border-t-transparent animate-spin" />
</div>
)}
<div
ref={containerRef}
className="absolute top-0 left-0 origin-top-left"
style={{
width: '800px',
height: '1000px',
transform: `scale(${scale})`,
padding: '16px',
overflow: 'hidden',
}}
/>
</div>
);
};

View File

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

View File

@ -1,11 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { UploadCloud, X, Trash2 } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion';
import { updateAsset, uploadThumbnail, getTaxonomyMeta } from '../../../services/assets-api'; import { X } from 'lucide-react';
import { getFullAssetUrl } from '../../../utils/asset-url'; import { updateAsset } from '../../../services/assets-api';
import type { Asset, TaxonomyMeta } from '../../../types/assets'; import type { Asset } from '../../../types/assets';
import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button';
import { useToast } from '../../../hooks/use-toast';
interface EditAssetModalProps { interface EditAssetModalProps {
isOpen: boolean; isOpen: boolean;
@ -20,14 +17,6 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
asset, asset,
onSuccess onSuccess
}) => { }) => {
const { success, error } = useToast();
const [taxonomyMeta, setTaxonomyMeta] = useState<TaxonomyMeta | null>(null);
const [editVerticalIds, setEditVerticalIds] = useState<string[]>([]);
const [editTechStackIds, setEditTechStackIds] = useState<string[]>([]);
const [editEngagementTypeIds, setEditEngagementTypeIds] = useState<string[]>([]);
const [editComplianceIds, setEditComplianceIds] = useState<string[]>([]);
const [editTitle, setEditTitle] = useState(''); const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState(''); const [editDescription, setEditDescription] = useState('');
const [editCategory, setEditCategory] = useState('Marketing'); const [editCategory, setEditCategory] = useState('Marketing');
@ -35,16 +24,8 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
const [editTags, setEditTags] = useState(''); const [editTags, setEditTags] = useState('');
const [editGithubUrl, setEditGithubUrl] = useState(''); const [editGithubUrl, setEditGithubUrl] = useState('');
const [editIsDownloadable, setEditIsDownloadable] = useState(true); const [editIsDownloadable, setEditIsDownloadable] = useState(true);
const [editThumbnailMode, setEditThumbnailMode] = useState<'url' | 'file'>('url');
const [editThumbnailUrl, setEditThumbnailUrl] = useState('');
const [editThumbnailFile, setEditThumbnailFile] = useState<File | null>(null);
const [editThumbnailFilePreview, setEditThumbnailFilePreview] = useState<string | null>(null);
const [isSavingEdit, setIsSavingEdit] = useState(false); const [isSavingEdit, setIsSavingEdit] = useState(false);
useEffect(() => {
getTaxonomyMeta().then(setTaxonomyMeta).catch(console.error);
}, []);
useEffect(() => { useEffect(() => {
if (asset) { if (asset) {
setEditTitle(asset.title); setEditTitle(asset.title);
@ -54,403 +35,173 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
setEditTags(asset.tags.join(', ')); setEditTags(asset.tags.join(', '));
setEditGithubUrl(asset.githubUrl || ''); setEditGithubUrl(asset.githubUrl || '');
setEditIsDownloadable(asset.isDownloadable); setEditIsDownloadable(asset.isDownloadable);
setEditThumbnailUrl(asset.thumbnailUrl || '');
setEditThumbnailFile(null);
setEditThumbnailMode('url');
setEditVerticalIds(asset.verticals ? asset.verticals.map(v => v.id) : []);
setEditTechStackIds(asset.techStacks ? asset.techStacks.map(t => t.id) : []);
setEditEngagementTypeIds(asset.engagementTypes ? asset.engagementTypes.map(e => e.id) : []);
setEditComplianceIds(asset.complianceStandards ? asset.complianceStandards.map(c => c.id) : []);
} }
}, [asset]); }, [asset]);
useEffect(() => {
if (!editThumbnailFile) {
setEditThumbnailFilePreview(null);
return;
}
const url = URL.createObjectURL(editThumbnailFile);
setEditThumbnailFilePreview(url);
return () => {
URL.revokeObjectURL(url);
};
}, [editThumbnailFile]);
const toggleSelection = (list: string[], item: string) => {
return list.includes(item) ? list.filter(i => i !== item) : [...list, item];
};
const formatBytes = (bytes: number, decimals = 2) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
const handleEditSubmit = async (e: React.FormEvent) => { const handleEditSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!asset) return; if (!asset) return;
setIsSavingEdit(true); setIsSavingEdit(true);
try { try {
let finalThumbnailUrl: string | null = editThumbnailUrl.trim() || null;
if (editThumbnailMode === 'file' && editThumbnailFile) {
const uploadRes = await uploadThumbnail(editThumbnailFile);
finalThumbnailUrl = uploadRes.thumbnailUrl;
}
await updateAsset(asset.id, { await updateAsset(asset.id, {
title: editTitle, title: editTitle,
description: editDescription, description: editDescription,
categoryId: editCategory, categoryId: editCategory,
subcategory: editSubcategory, subcategory: editSubcategory,
verticalIds: editVerticalIds,
techStackIds: editTechStackIds,
engagementTypeIds: editEngagementTypeIds,
complianceIds: editComplianceIds,
tags: editTags.split(',').map(t => t.trim()).filter(Boolean), tags: editTags.split(',').map(t => t.trim()).filter(Boolean),
githubUrl: editGithubUrl, githubUrl: editGithubUrl,
isDownloadable: editIsDownloadable, isDownloadable: editIsDownloadable,
thumbnailUrl: finalThumbnailUrl,
}); });
success('Changes saved successfully', 'Asset details have been updated.');
onSuccess(); onSuccess();
onClose(); onClose();
} catch (err: any) { } catch (err) {
console.error('Failed to save asset details', err); console.error('Failed to save asset details', err);
error('Failed to save changes', err.response?.data?.error || 'Something went wrong.');
} finally { } finally {
setIsSavingEdit(false); setIsSavingEdit(false);
} }
}; };
return ( return (
<Modal <AnimatePresence>
isOpen={isOpen && !!asset} {isOpen && asset && (
onClose={onClose} <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
title="Edit Asset Details" <motion.div
size="lg" initial={{ opacity: 0 }}
footer={ animate={{ opacity: 1 }}
<> exit={{ opacity: 0 }}
<Button
type="button"
onClick={onClose} onClick={onClose}
variant="ghost" className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
size="sm" />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="relative bg-ink-0 border border-ink-200 rounded-2xl p-6 max-w-lg w-full max-h-[90vh] shadow-xl z-10 flex flex-col overflow-hidden"
> >
Cancel <div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0">
</Button> <h3 className="text-lg font-bold text-ink-900">Edit Asset Details</h3>
<Button <button
type="submit" onClick={onClose}
form="edit-asset-form" className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
disabled={isSavingEdit} >
variant="primary" <X className="w-5 h-5" />
size="sm" </button>
>
{isSavingEdit ? 'Saving...' : 'Save Changes'}
</Button>
</>
}
>
{asset && (
<form id="edit-asset-form" onSubmit={handleEditSubmit} className="space-y-4">
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Asset Title</label>
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
required
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs font-semibold text-ink-500">Thumbnail Banner (Optional)</label>
<div className="flex bg-ink-100 p-0.5 rounded-lg border border-ink-200 text-[11px]">
<button
type="button"
onClick={() => { setEditThumbnailMode('url'); setEditThumbnailFile(null); }}
className={`px-2 py-0.5 font-medium rounded-md transition-all cursor-pointer ${editThumbnailMode === 'url' ? 'bg-ink-0 text-ink-900 shadow-xs border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`}
>
Image URL
</button>
<button
type="button"
onClick={() => { setEditThumbnailMode('file'); setEditThumbnailUrl(''); }}
className={`px-2 py-0.5 font-medium rounded-md transition-all cursor-pointer ${editThumbnailMode === 'file' ? 'bg-ink-0 text-ink-900 shadow-xs border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`}
>
Upload File
</button>
</div>
</div> </div>
{editThumbnailMode === 'url' ? ( <form onSubmit={handleEditSubmit} className="flex-1 min-h-0 flex flex-col mt-4">
<div className="flex gap-2"> <div className="flex-1 overflow-y-auto pr-1 space-y-4 scrollbar-thin">
<input <div>
type="url" <label className="text-xs font-semibold text-ink-500 mb-1.5 block">Asset Title</label>
value={editThumbnailUrl} <input
onChange={(e) => setEditThumbnailUrl(e.target.value)} type="text"
placeholder="https://example.com/image.png" value={editTitle}
className="flex-1 bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400" onChange={(e) => setEditTitle(e.target.value)}
/> required
{editThumbnailUrl && ( className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
<button />
type="button" </div>
onClick={() => setEditThumbnailUrl('')}
className="px-2.5 py-2 rounded-lg bg-red-500/10 hover:bg-red-500/20 text-red-600 border border-red-200 text-xs font-semibold flex items-center gap-1 cursor-pointer"
title="Remove Thumbnail"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Clear</span>
</button>
)}
</div>
) : (
<div className="border border-dashed border-ink-200 hover:border-ink-400 rounded-xl p-3 text-center transition-colors relative bg-ink-50">
{!editThumbnailFile ? (
<div className="relative py-2">
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 2 * 1024 * 1024) {
error('File Too Large', 'Thumbnail image size should be under 2MB.');
return;
}
setEditThumbnailFile(file);
}
}}
className="absolute inset-0 opacity-0 cursor-pointer z-10"
/>
<UploadCloud className="w-6 h-6 text-ink-400 mx-auto mb-1" />
<p className="text-xs font-bold text-ink-900">Click or drag thumbnail image to upload</p>
<p className="text-[10px] text-ink-450 mt-0.5">PNG, JPG, WebP, GIF Recommended size: 480×270px (Max 2MB)</p>
</div>
) : (
<div className="relative flex items-center justify-between gap-3 p-1">
{editThumbnailFilePreview && (
<img src={editThumbnailFilePreview} alt="Thumb Preview" className="w-16 h-12 object-cover rounded-lg border border-ink-200 shadow-xs" />
)}
<div className="flex-1 text-left min-w-0">
<p className="text-xs font-bold text-ink-900 truncate">{editThumbnailFile.name}</p>
<p className="text-[10px] text-ink-500">{formatBytes(editThumbnailFile.size)} Upload ready</p>
</div>
<button
type="button"
onClick={() => setEditThumbnailFile(null)}
className="p-1 rounded-lg bg-ink-100 hover:bg-red-500/10 text-ink-500 hover:text-red-600 border border-ink-200 transition-colors cursor-pointer"
title="Remove thumbnail"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
)}
{editThumbnailMode === 'url' && editThumbnailUrl && ( <div className="grid grid-cols-2 gap-4">
<div className="mt-2"> <div>
<label className="text-[11px] font-semibold text-ink-500 mb-1 block">Thumbnail Preview</label> <label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label>
<div className="w-full h-28 border border-ink-200 rounded-lg overflow-hidden relative bg-ink-50"> <select
<img value={editCategory}
src={getFullAssetUrl(editThumbnailUrl)} onChange={(e) => setEditCategory(e.target.value)}
alt="Thumbnail Preview" className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900"
className="w-full h-full object-cover" >
<option value="Marketing">Marketing</option>
<option value="Presentations">Presentations</option>
<option value="Branding">Branding</option>
<option value="Resources">Resources</option>
<option value="Technical">Technical</option>
</select>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Subcategory</label>
<input
type="text"
value={editSubcategory}
onChange={(e) => setEditSubcategory(e.target.value)}
placeholder="e.g. Slide Deck"
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
/>
</div>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Description</label>
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Enter short description..."
rows={3}
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 resize-none"
/>
</div>
{asset.type !== 'url' && (
<div className="flex items-center gap-3 p-3 bg-ink-50 border border-ink-200 rounded-lg">
<input
type="checkbox"
id="editIsDownloadable"
checked={editIsDownloadable}
onChange={(e) => setEditIsDownloadable(e.target.checked)}
className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer"
/>
<div>
<label htmlFor="editIsDownloadable" className="text-xs font-bold text-ink-900 cursor-pointer block">
Allow Direct Download (Strict View Only if unchecked)
</label>
<span className="text-[10px] text-ink-500">
Toggle client authorization requirement for asset downloads.
</span>
</div>
</div>
)}
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Tags (comma-separated)</label>
<input
type="text"
value={editTags}
onChange={(e) => setEditTags(e.target.value)}
placeholder="branding, guideline, pitch"
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
/>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">GitHub/Documentation URL (Optional)</label>
<input
type="url"
value={editGithubUrl}
onChange={(e) => setEditGithubUrl(e.target.value)}
placeholder="https://github.com/..."
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
/> />
</div> </div>
</div> </div>
)} <div className="pt-3.5 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0 mt-4">
</div> <button
type="button"
{/* 4-Group Taxonomy Demarcation Selection */} onClick={onClose}
<div className="space-y-4 pt-3 pb-3 border-t border-b border-slate-200 dark:border-slate-800"> className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400"> >
Taxonomy Classification (Strict Admin-Managed) Cancel
</h4> </button>
<button
{/* Group 1: Industry Verticals */} type="submit"
<div> disabled={isSavingEdit}
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block"> className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm disabled:opacity-50"
1. Industry Verticals / Domains >
</label> {isSavingEdit ? 'Saving...' : 'Save Changes'}
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800"> </button>
{(taxonomyMeta?.verticals || []).map(v => {
const isSelected = editVerticalIds.includes(v.id);
return (
<button
key={v.id}
type="button"
onClick={() => setEditVerticalIds(toggleSelection(editVerticalIds, v.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-blue-600 text-white font-bold border-blue-600 shadow-sm ring-2 ring-blue-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
>
{v.name}
</button>
);
})}
</div> </div>
</div> </form>
</motion.div>
{/* Group 2: Tech Stack */} </div>
<div>
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
2. Technology Stack & Capabilities
</label>
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
{['Languages & Frameworks', 'AI & ML', 'Data & Backend', 'Cloud & Infra'].map(catName => {
const items = (taxonomyMeta?.techStacks || []).filter(t => t.category === catName || (!t.category && catName === 'Languages & Frameworks'));
if (items.length === 0) return null;
return (
<div key={catName} className="space-y-1">
<div className="text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
{catName}
</div>
<div className="flex flex-wrap gap-1.5">
{items.map(t => {
const isSelected = editTechStackIds.includes(t.id);
return (
<button
key={t.id}
type="button"
onClick={() => setEditTechStackIds(toggleSelection(editTechStackIds, t.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-purple-600 text-white font-bold border-purple-600 shadow-sm ring-2 ring-purple-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
>
{t.name}
</button>
);
})}
</div>
</div>
);
})}
</div>
</div>
{/* Group 3 & Group 4 Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Group 3: Engagement Type */}
<div>
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
3. Engagement Type
</label>
<div className="flex flex-wrap gap-1.5 p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
{(taxonomyMeta?.engagementTypes || []).map(e => {
const isSelected = editEngagementTypeIds.includes(e.id);
return (
<button
key={e.id}
type="button"
onClick={() => setEditEngagementTypeIds(toggleSelection(editEngagementTypeIds, e.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-sky-600 text-white font-bold border-sky-600 shadow-sm ring-2 ring-sky-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
>
{e.name}
</button>
);
})}
</div>
</div>
{/* Group 4: Compliance Standards */}
<div>
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
4. Compliance & Governance
</label>
<div className="flex flex-wrap gap-1.5 p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
{(taxonomyMeta?.complianceStandards || []).map(c => {
const isSelected = editComplianceIds.includes(c.id);
return (
<button
key={c.id}
type="button"
onClick={() => setEditComplianceIds(toggleSelection(editComplianceIds, c.id))}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all cursor-pointer border ${
isSelected
? 'bg-emerald-600 text-white font-bold border-emerald-600 shadow-sm ring-2 ring-emerald-500/30'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
}`}
>
{c.name}
</button>
);
})}
</div>
</div>
</div>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Description</label>
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Enter short description..."
rows={3}
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 resize-none"
/>
</div>
{asset.type !== 'url' && (
<div className="flex items-center gap-3 p-3 bg-ink-50 border border-ink-200 rounded-lg">
<input
type="checkbox"
id="editIsDownloadable"
checked={editIsDownloadable}
onChange={(e) => setEditIsDownloadable(e.target.checked)}
className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer"
/>
<div>
<label htmlFor="editIsDownloadable" className="text-xs font-bold text-ink-900 cursor-pointer block">
Allow Direct Download (Strict View Only if unchecked)
</label>
<span className="text-[10px] text-ink-500">
Toggle client authorization requirement for asset downloads.
</span>
</div>
</div>
)}
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Tags (comma-separated)</label>
<input
type="text"
value={editTags}
onChange={(e) => setEditTags(e.target.value)}
placeholder="branding, guideline, pitch"
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
/>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">GitHub/Documentation URL (Optional)</label>
<input
type="url"
value={editGithubUrl}
onChange={(e) => setEditGithubUrl(e.target.value)}
placeholder="https://github.com/..."
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
/>
</div>
</form>
)} )}
</Modal> </AnimatePresence>
); );
}; };

View File

@ -1,451 +0,0 @@
import React, { useState } from 'react';
import { X, Filter, Search, RotateCcw, Check, Shield, Cpu, Activity, Landmark, FileCheck, Zap, Leaf, GraduationCap, Factory, Car, ShoppingBag, Link as LinkIcon, FileText, LayoutGrid, Newspaper, Briefcase, PlayCircle, Code, Compass, HelpCircle } from 'lucide-react';
import type { TaxonomyMeta, AssetQueryFilters } from '../../../types/assets';
interface FilterDrawerProps {
isOpen: boolean;
onClose: () => void;
meta: TaxonomyMeta | null;
filters: AssetQueryFilters;
onChangeFilters: (newFilters: AssetQueryFilters) => void;
onClearAll: () => void;
}
const VERTICAL_ICONS: Record<string, React.ReactNode> = {
Shield: <Shield className="w-4 h-4" />,
Cpu: <Cpu className="w-4 h-4" />,
Activity: <Activity className="w-4 h-4" />,
Landmark: <Landmark className="w-4 h-4" />,
FileCheck: <FileCheck className="w-4 h-4" />,
Zap: <Zap className="w-4 h-4" />,
Leaf: <Leaf className="w-4 h-4" />,
GraduationCap: <GraduationCap className="w-4 h-4" />,
Factory: <Factory className="w-4 h-4" />,
Car: <Car className="w-4 h-4" />,
ShoppingBag: <ShoppingBag className="w-4 h-4" />,
Link: <LinkIcon className="w-4 h-4" />,
};
const CONTENT_TYPE_CONFIG: Record<string, { label: string; icon: React.ReactNode; color: string }> = {
case_study: { label: 'Case Studies', icon: <FileText className="w-4 h-4" />, color: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' },
showcase: { label: 'Showcases', icon: <LayoutGrid className="w-4 h-4" />, color: 'bg-slate-500/10 text-slate-700 dark:text-slate-300 border-slate-500/20' },
newsletter: { label: 'Newsletters', icon: <Newspaper className="w-4 h-4" />, color: 'bg-amber-500/10 text-amber-600 border-amber-500/20' },
portfolio: { label: 'Portfolios & Decks', icon: <Briefcase className="w-4 h-4" />, color: 'bg-purple-500/10 text-purple-600 border-purple-500/20' },
mvp: { label: 'Live MVPs', icon: <PlayCircle className="w-4 h-4" />, color: 'bg-sky-500/10 text-sky-600 border-sky-500/20' },
document: { label: 'Documents & PDFs', icon: <FileText className="w-4 h-4" />, color: 'bg-slate-500/10 text-slate-600 border-slate-500/20' },
workflow: { label: 'Workflow Automations', icon: <Code className="w-4 h-4" />, color: 'bg-cyan-500/10 text-cyan-600 border-cyan-500/20' },
use_case: { label: 'Technical Use Cases', icon: <Compass className="w-4 h-4" />, color: 'bg-blue-500/10 text-blue-600 border-blue-500/20' },
test_drive: { label: 'Test Drive Resources', icon: <HelpCircle className="w-4 h-4" />, color: 'bg-rose-500/10 text-rose-600 border-rose-500/20' },
};
export const FilterDrawer: React.FC<FilterDrawerProps> = ({
isOpen,
onClose,
meta,
filters,
onChangeFilters,
onClearAll,
}) => {
const [filterQuery, setFilterQuery] = useState('');
if (!isOpen) return null;
const toggleArrayItem = (current: string[] | undefined, item: string): string[] => {
const arr = current || [];
return arr.includes(item) ? arr.filter(x => x !== item) : [...arr, item];
};
const activeCount =
(filters.verticalIds?.length || 0) +
(filters.contentTypes?.length || 0) +
(filters.subcategories?.length || 0) +
(filters.tags?.length || 0);
const filteredVerticals = (meta?.verticals || []).filter(v =>
v.name.toLowerCase().includes(filterQuery.toLowerCase())
);
const filteredSubcategories = (meta?.subcategories || []).filter(s =>
s.name.toLowerCase().includes(filterQuery.toLowerCase())
);
const filteredTags = (meta?.tags || []).filter(t =>
t.name.toLowerCase().includes(filterQuery.toLowerCase())
);
return (
<div className="fixed inset-0 z-50 overflow-hidden">
{/* Backdrop */}
<div
className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity duration-300 animate-fadeIn"
onClick={onClose}
/>
<div className="fixed inset-y-0 right-0 max-w-full flex pl-10">
<div className="w-screen max-w-md bg-white dark:bg-slate-900 shadow-2xl border-l border-slate-200 dark:border-slate-800 flex flex-col transform transition-transform duration-300">
{/* Header */}
<div className="px-6 py-5 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between bg-slate-50/50 dark:bg-slate-900/50">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-slate-100">
<Filter className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
Asset Discovery Filters
{activeCount > 0 && (
<span className="px-2 py-0.5 text-xs font-bold rounded-full bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900">
{activeCount}
</span>
)}
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400">
Narrow catalog by domains, types & tags
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Quick Search inside Filters */}
<div className="p-4 border-b border-slate-100 dark:border-slate-800 bg-slate-50/30 dark:bg-slate-900/30">
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="Search filter keywords..."
value={filterQuery}
onChange={e => setFilterQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2 text-xs rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400"
/>
</div>
</div>
{/* Body Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-7 custom-scrollbar">
{/* 1. Industry Verticals (Group 1) */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3 flex items-center justify-between">
<span>1. Industry Verticals</span>
<span className="text-[10px] lowercase font-normal text-slate-400">
({filteredVerticals.length})
</span>
</h3>
<div className="space-y-1.5">
{filteredVerticals.map(vertical => {
const isSelected = filters.verticalIds?.includes(vertical.id);
const iconNode = vertical.icon ? VERTICAL_ICONS[vertical.icon] : null;
return (
<button
key={vertical.id}
onClick={() =>
onChangeFilters({
...filters,
verticalIds: toggleArrayItem(filters.verticalIds, vertical.id),
})
}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-xs font-medium transition-all ${
isSelected
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 font-bold shadow-sm'
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800/60 border border-transparent'
}`}
>
<div className="flex items-center gap-2.5 min-w-0">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: vertical.color || '#3b82f6' }}
/>
<span className="shrink-0 opacity-80">
{iconNode}
</span>
<span className="truncate">{vertical.name}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${
isSelected ? 'bg-white/20 text-white dark:bg-slate-800 dark:text-slate-200' : 'bg-slate-100 dark:bg-slate-800 text-slate-500'
}`}>
{vertical._count?.assets ?? 0}
</span>
<div
className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${
isSelected
? 'bg-amber-500 border-amber-500 text-slate-950'
: 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800'
}`}
>
{isSelected && <Check className="w-3 h-3 stroke-[3]" />}
</div>
</div>
</button>
);
})}
</div>
</div>
{/* 2. Technology Stack (Group 2) */}
{(meta?.techStacks || []).length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3 flex items-center justify-between">
<span>2. Technology Stack</span>
<span className="text-[10px] lowercase font-normal text-slate-400">
({(meta?.techStacks || []).length})
</span>
</h3>
<div className="space-y-3">
{['Languages & Frameworks', 'AI & ML', 'Data & Backend', 'Cloud & Infra'].map(cat => {
const groupItems = (meta?.techStacks || []).filter(t => t.category === cat);
if (groupItems.length === 0) return null;
return (
<div key={cat} className="space-y-1">
<div className="text-[11px] font-semibold text-slate-500 dark:text-slate-400 px-1">
{cat}
</div>
<div className="flex flex-wrap gap-1.5">
{groupItems.map(tech => {
const isSelected = filters.techStackIds?.includes(tech.id);
return (
<button
key={tech.id}
onClick={() =>
onChangeFilters({
...filters,
techStackIds: toggleArrayItem(filters.techStackIds, tech.id),
})
}
className={`px-2.5 py-1 rounded-lg text-xs font-medium border transition-all flex items-center gap-1.5 ${
isSelected
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 border-slate-900 dark:border-slate-100 font-bold'
: 'bg-white dark:bg-slate-800/80 text-slate-700 dark:text-slate-200 border-slate-200 dark:border-slate-700 hover:border-slate-400'
}`}
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: tech.color || '#64748b' }} />
{tech.name}
<span className="text-[10px] opacity-70">({tech._count?.assets ?? 0})</span>
</button>
);
})}
</div>
</div>
);
})}
</div>
</div>
)}
{/* 3. Engagement Type (Group 3) */}
{(meta?.engagementTypes || []).length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3 flex items-center justify-between">
<span>3. Engagement Type</span>
</h3>
<div className="grid grid-cols-2 gap-2">
{(meta?.engagementTypes || []).map(eng => {
const isSelected = filters.engagementTypeIds?.includes(eng.id);
return (
<button
key={eng.id}
onClick={() =>
onChangeFilters({
...filters,
engagementTypeIds: toggleArrayItem(filters.engagementTypeIds, eng.id),
})
}
className={`p-2.5 rounded-lg border text-left text-xs transition-all ${
isSelected
? 'bg-slate-900 text-white border-slate-900 shadow-sm font-bold dark:bg-slate-100 dark:text-slate-900'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 border-slate-200 dark:border-slate-700 hover:border-slate-400'
}`}
>
<div className="font-bold flex items-center justify-between">
<span>{eng.name}</span>
<span className="text-[10px] opacity-70 font-mono">({eng._count?.assets ?? 0})</span>
</div>
{eng.description && (
<div className="text-[10px] opacity-75 mt-0.5 line-clamp-1">{eng.description}</div>
)}
</button>
);
})}
</div>
</div>
)}
{/* 4. Compliance & Regulatory (Group 4) */}
{(meta?.complianceStandards || []).length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3 flex items-center justify-between">
<span>4. Compliance & Governance</span>
</h3>
<div className="flex flex-wrap gap-1.5">
{(meta?.complianceStandards || []).map(comp => {
const isSelected = filters.complianceIds?.includes(comp.id);
return (
<button
key={comp.id}
onClick={() =>
onChangeFilters({
...filters,
complianceIds: toggleArrayItem(filters.complianceIds, comp.id),
})
}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold border transition-all flex items-center gap-2 ${
isSelected
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 border-slate-900 dark:border-slate-100 shadow-sm'
: 'bg-slate-50 dark:bg-slate-800/60 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:bg-slate-100'
}`}
>
<Shield className="w-3.5 h-3.5 text-emerald-500" />
{comp.name}
<span className="text-[10px] opacity-70 font-mono">({comp._count?.assets ?? 0})</span>
</button>
);
})}
</div>
</div>
)}
{/* 5. Content Types */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">
Content Types
</h3>
<div className="grid grid-cols-1 gap-2">
{(meta?.contentTypes || []).map(ct => {
const isSelected = filters.contentTypes?.includes(ct.name);
const config = CONTENT_TYPE_CONFIG[ct.name] || {
label: ct.name,
icon: <FileText className="w-4 h-4" />,
color: 'bg-slate-100 text-slate-700',
};
return (
<button
key={ct.name}
onClick={() =>
onChangeFilters({
...filters,
contentTypes: toggleArrayItem(filters.contentTypes, ct.name),
})
}
className={`flex items-center justify-between p-2.5 rounded-lg border text-xs transition-all ${
isSelected
? 'bg-slate-900 text-white border-slate-900 shadow-sm font-bold dark:bg-slate-100 dark:text-slate-900'
: 'bg-white dark:bg-slate-800/80 text-slate-700 dark:text-slate-200 border-slate-200 dark:border-slate-700 hover:border-slate-400'
}`}
>
<div className="flex items-center gap-2.5">
<span>{config.icon}</span>
<span>{config.label}</span>
</div>
<span
className={`text-[10px] font-mono px-2 py-0.5 rounded-full ${
isSelected
? 'bg-white/20 text-white dark:bg-slate-800 dark:text-slate-200'
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300'
}`}
>
{ct.count}
</span>
</button>
);
})}
</div>
</div>
{/* 6. Subcategories */}
{filteredSubcategories.length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">
Subcategories
</h3>
<div className="flex flex-wrap gap-1.5">
{filteredSubcategories.map(sub => {
const isSelected = filters.subcategories?.includes(sub.name);
return (
<button
key={sub.name}
onClick={() =>
onChangeFilters({
...filters,
subcategories: toggleArrayItem(filters.subcategories, sub.name),
})
}
className={`px-2.5 py-1 rounded-md text-xs font-medium border transition-all ${
isSelected
? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900 border-slate-900 dark:border-slate-100 font-semibold'
: 'bg-slate-50 dark:bg-slate-800/60 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:bg-slate-100'
}`}
>
{sub.name} <span className="opacity-60 text-[10px]">({sub.count})</span>
</button>
);
})}
</div>
</div>
)}
{/* 7. Tag Cloud */}
{filteredTags.length > 0 && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-3">
Tags & Keywords
</h3>
<div className="flex flex-wrap gap-1.5">
{filteredTags.map(tag => {
const isSelected = filters.tags?.includes(tag.name);
return (
<button
key={tag.name}
onClick={() =>
onChangeFilters({
...filters,
tags: toggleArrayItem(filters.tags, tag.name),
})
}
className={`px-2 py-0.5 rounded-full text-xs font-mono transition-all ${
isSelected
? 'bg-slate-900 text-white font-semibold dark:bg-slate-100 dark:text-slate-900'
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-700'
}`}
>
#{tag.name} <span className="opacity-60 text-[10px]">({tag.count})</span>
</button>
);
})}
</div>
</div>
)}
</div>
{/* Footer Actions */}
<div className="p-4 border-t border-slate-200 dark:border-slate-800 bg-slate-50/80 dark:bg-slate-900/80 flex items-center justify-between gap-3">
<button
onClick={onClearAll}
disabled={activeCount === 0}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" />
Clear All ({activeCount})
</button>
<button
onClick={onClose}
className="px-5 py-2 rounded-lg bg-slate-900 hover:bg-slate-800 dark:bg-slate-100 dark:hover:bg-white text-white dark:text-slate-900 text-xs font-bold shadow-md transition-all"
>
Apply Filters
</button>
</div>
</div>
</div>
</div>
);
};

View File

@ -1,293 +0,0 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import type { Asset } from '../../../types/assets';
import type { AssetGroup } from '../../../services/assets-api';
import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button';
import { Folder, Plus, Trash2, Search, CheckCircle, ExternalLink } from 'lucide-react';
import { createAssetGroup, deleteAssetGroup } from '../../../services/assets-api';
import { useToast } from '../../../hooks/use-toast';
interface ManageGroupsModalProps {
isOpen: boolean;
onClose: () => void;
assets: Asset[];
groups: AssetGroup[];
onRefresh: () => void;
}
export const ManageGroupsModal: React.FC<ManageGroupsModalProps> = ({
isOpen,
onClose,
assets,
groups,
onRefresh,
}) => {
const { success, error } = useToast();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const filteredAssets = assets.filter(asset =>
asset.title.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleToggleAsset = (id: string) => {
setSelectedAssetIds(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
);
};
const handleSelectAll = () => {
if (selectedAssetIds.length === assets.length) {
setSelectedAssetIds([]);
} else {
setSelectedAssetIds(assets.map(a => a.id));
}
};
const handleCreateGroup = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
setIsSubmitting(true);
try {
await createAssetGroup({
name: name.trim(),
description: description.trim() || undefined,
assetIds: selectedAssetIds,
});
success('Asset group created', `"${name}" group was successfully registered.`);
setName('');
setDescription('');
setSelectedAssetIds([]);
onRefresh();
} catch (err: any) {
console.error(err);
error('Failed to create group', err.response?.data?.error || 'Something went wrong.');
} finally {
setIsSubmitting(false);
}
};
const handleDeleteGroup = async (id: string, groupName: string) => {
if (!window.confirm(`Are you sure you want to permanently delete the asset bundle "${groupName}"?`)) {
return;
}
try {
await deleteAssetGroup(id);
success('Asset group deleted', `"${groupName}" group has been deleted.`);
onRefresh();
} catch (err: any) {
console.error(err);
error('Failed to delete group', err.response?.data?.error || 'Something went wrong.');
}
};
const getFileIcon = (type: string, title: string) => {
const lowerType = type.toLowerCase();
const lowerTitle = title.toLowerCase();
if (lowerType.includes('pdf') || lowerTitle.endsWith('.pdf')) {
return (
<div className="w-6 h-6 rounded bg-red-500/10 border border-red-500/20 flex items-center justify-center text-red-650 shrink-0">
<span className="text-[8px] font-bold">PDF</span>
</div>
);
}
if (lowerType.includes('word') || lowerTitle.endsWith('.docx') || lowerTitle.endsWith('.doc')) {
return (
<div className="w-6 h-6 rounded bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-650 shrink-0">
<span className="text-[8px] font-bold">DOC</span>
</div>
);
}
if (lowerType.includes('presentation') || lowerTitle.endsWith('.pptx') || lowerTitle.endsWith('.ppt')) {
return (
<div className="w-6 h-6 rounded bg-orange-500/10 border border-orange-500/20 flex items-center justify-center text-orange-650 shrink-0">
<span className="text-[8px] font-bold">PPT</span>
</div>
);
}
return (
<div className="w-6 h-6 rounded bg-ink-500/10 border border-ink-500/20 flex items-center justify-center text-ink-600 shrink-0">
<span className="text-[8px] font-bold">URL</span>
</div>
);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Manage Asset Groups & Bundles"
subtitle="Bundle a fixed group of collateral/resources to easily assign them to partner users in one click."
size="xl"
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 min-h-[450px]">
{/* Left Side: Existing Groups */}
<div className="border-r border-ink-200 pr-0 lg:pr-6 flex flex-col min-h-0">
<h3 className="text-xs font-bold uppercase tracking-wider text-ink-500 mb-4 flex items-center gap-1.5 font-sans">
<Folder className="w-4 h-4 text-ink-750" />
<span>Active Asset Groups ({groups.length})</span>
</h3>
<div className="flex-1 overflow-y-auto space-y-3 max-h-[380px] pr-1">
{groups.length === 0 ? (
<div className="text-center py-12 bg-ink-50/55 rounded-xl border border-dashed border-ink-200">
<Folder className="w-10 h-10 text-ink-300 mx-auto mb-2" />
<p className="text-xs font-bold text-ink-900 font-sans">No Asset Groups Created</p>
<p className="text-[10px] text-ink-450 mt-1 font-sans">Use the panel on the right to bundle your first set of resources.</p>
</div>
) : (
groups.map(group => (
<div key={group.id} className="p-3 bg-ink-0 border border-ink-200 rounded-xl shadow-sm hover:border-ink-305 transition-all flex justify-between items-start gap-4">
<div className="min-w-0 flex-1 font-sans">
<p className="text-xs font-bold text-ink-900 truncate">{group.name}</p>
{group.description && (
<p className="text-[10px] text-ink-500 mt-0.5 font-medium">{group.description}</p>
)}
<div className="flex flex-wrap gap-1.5 mt-3 items-center">
<Link
to={`/admin/groups/${group.id}`}
onClick={onClose}
className="text-[9px] px-2 py-0.5 rounded bg-ink-900 hover:bg-ink-800 text-ink-0 font-bold flex items-center gap-1 transition-all border border-transparent shadow-sm cursor-pointer"
>
<ExternalLink className="w-2.5 h-2.5" />
<span>View Page</span>
</Link>
<span className="text-[9px] px-1.5 py-0.5 rounded bg-ink-100 border border-ink-200 text-ink-700 font-bold">
{group.assets.length} Assets
</span>
</div>
</div>
<button
onClick={() => handleDeleteGroup(group.id, group.name)}
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-500/10 rounded-lg transition-colors cursor-pointer shrink-0"
title="Delete Group"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))
)}
</div>
</div>
{/* Right Side: Create Group Form */}
<form onSubmit={handleCreateGroup} className="flex flex-col min-h-0">
<h3 className="text-xs font-bold uppercase tracking-wider text-ink-500 mb-4 flex items-center gap-1.5 font-sans">
<Plus className="w-4 h-4 text-ink-750" />
<span>Create New Bundle</span>
</h3>
<div className="space-y-4 flex-1 overflow-y-auto max-h-[380px] pr-1">
<div>
<label className="text-[10px] font-bold text-ink-500 uppercase tracking-wide mb-1 block font-sans">
Bundle Name *
</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="e.g. Sales Onboarding Pack"
required
className="w-full px-3 py-2 bg-ink-50 border border-ink-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-semibold font-sans"
/>
</div>
<div>
<label className="text-[10px] font-bold text-ink-500 uppercase tracking-wide mb-1 block font-sans">
Description
</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Describe what assets are bundled in this group."
rows={2}
className="w-full px-3 py-2 bg-ink-50 border border-ink-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-semibold resize-none font-sans"
/>
</div>
<div>
<div className="flex justify-between items-center mb-1.5 font-sans">
<label className="text-[10px] font-bold text-ink-500 uppercase tracking-wide">
Select Assets to Include
</label>
<button
type="button"
onClick={handleSelectAll}
className="text-[9px] font-bold text-ink-900 hover:underline cursor-pointer"
>
{selectedAssetIds.length === assets.length ? 'Deselect All' : 'Select All'}
</button>
</div>
{/* Asset search and list */}
<div className="border border-ink-200 rounded-lg overflow-hidden flex flex-col max-h-[200px]">
<div className="relative border-b border-ink-200 shrink-0">
<Search className="w-3.5 h-3.5 text-ink-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search catalog assets..."
className="w-full bg-ink-50/50 py-1.5 pl-8 pr-3 text-[11px] font-medium outline-none text-ink-900 font-sans"
/>
</div>
<div className="overflow-y-auto divide-y divide-ink-200 bg-ink-0">
{filteredAssets.length === 0 ? (
<div className="p-4 text-center text-[10px] text-ink-400 font-medium font-sans">
No assets found
</div>
) : (
filteredAssets.map(asset => {
const isSelected = selectedAssetIds.includes(asset.id);
return (
<div
key={asset.id}
onClick={() => handleToggleAsset(asset.id)}
className={`flex items-center gap-3 p-2 text-xs font-semibold cursor-pointer transition-all hover:bg-ink-100 ${
isSelected ? 'bg-ink-100/70' : ''
}`}
>
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center transition-all ${
isSelected
? 'border-ink-900 bg-ink-900 text-ink-0'
: 'border-ink-200 bg-ink-50'
}`}>
{isSelected && <CheckCircle className="w-2.5 h-2.5 stroke-[3]" />}
</div>
{getFileIcon(asset.type, asset.title)}
<div className="min-w-0 flex-1 font-sans">
<p className="truncate text-ink-900 text-[11px]">{asset.title}</p>
{asset.categoryId && (
<span className="text-[8px] px-1 py-0.2 rounded bg-ink-100 text-ink-600 font-bold uppercase tracking-wider">
{asset.categoryId}
</span>
)}
</div>
</div>
);
})
)}
</div>
</div>
</div>
<Button
type="submit"
variant="primary"
size="sm"
disabled={isSubmitting || !name.trim()}
className="w-full flex justify-center py-2"
>
{isSubmitting ? 'Creating Bundle...' : 'Create Group / Bundle'}
</Button>
</div>
</form>
</div>
</Modal>
);
};

View File

@ -1,129 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
interface PdfThumbnailProps {
url: string;
title: string;
fallback: React.ReactNode;
}
// Simple in-memory cache for rendered thumbnails to avoid reprocessing the same PDFs during a session
const thumbnailCache: Record<string, string> = {};
export const PdfThumbnail: React.FC<PdfThumbnailProps> = ({ url, title, fallback }) => {
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(thumbnailCache[url] || null);
const [error, setError] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(!thumbnailCache[url]);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
if (thumbnailUrl) {
setLoading(false);
return;
}
let isMounted = true;
const loadPdfAndRender = async () => {
try {
// 1. Ensure PDF.js script is loaded dynamically
if (!(window as any).pdfjsLib) {
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.min.js';
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load PDF.js library'));
document.body.appendChild(script);
});
}
const pdfjs = (window as any).pdfjsLib;
if (!pdfjs) throw new Error('PDF.js lib not available');
// Configure PDF.js Worker
pdfjs.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.worker.min.js';
// 2. Fetch and load the PDF document
const loadingTask = pdfjs.getDocument(url);
const pdf = await loadingTask.promise;
if (!isMounted) return;
// 3. Load the first page of the PDF
const page = await pdf.getPage(1);
if (!isMounted) return;
// Create a temporary canvas if not in DOM yet
const canvas = canvasRef.current || document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) throw new Error('Could not get 2D context');
// We want a thumbnail width of around 220px to fit the card well
const originalViewport = page.getViewport({ scale: 1.0 });
const scale = 220 / originalViewport.width;
const viewport = page.getViewport({ scale });
canvas.width = viewport.width;
canvas.height = viewport.height;
// Render PDF page to canvas
const renderContext = {
canvasContext: context,
viewport: viewport,
};
await page.render(renderContext).promise;
if (!isMounted) return;
// Convert canvas rendering to a base64 image URL for high performance image display
const dataUrl = canvas.toDataURL('image/jpeg', 0.8);
thumbnailCache[url] = dataUrl;
if (isMounted) {
setThumbnailUrl(dataUrl);
setLoading(false);
}
} catch (err) {
console.error('[PdfThumbnail] Failed to render first page preview:', err);
if (isMounted) {
setError(true);
setLoading(false);
}
}
};
loadPdfAndRender();
return () => {
isMounted = false;
};
}, [url]);
if (error) {
return <>{fallback}</>;
}
return (
<div className="w-full h-full flex items-center justify-center relative overflow-hidden bg-ink-50">
{/* Hidden canvas for offscreen rendering */}
<canvas ref={canvasRef} className="hidden" />
{loading ? (
<div className="flex flex-col items-center justify-center w-full h-full p-4 space-y-2">
<div className="w-5 h-5 rounded-full border-2 border-ink-400 border-t-transparent animate-spin" />
<span className="text-[10px] text-ink-400 font-medium">Generating preview...</span>
</div>
) : thumbnailUrl ? (
<img
src={thumbnailUrl}
alt={title}
className="w-full h-full object-cover object-top transition-transform duration-500 group-hover:scale-105"
/>
) : (
fallback
)}
</div>
);
};

View File

@ -1,18 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { updateAsset, bulkShareAssets, getAssetGroups, addAssetsToGroup } from '../../../services/assets-api'; import { X, ChevronDown, ChevronUp } from 'lucide-react';
import type { AssetGroup } from '../../../services/assets-api'; import { updateAsset } from '../../../services/assets-api';
import type { Asset, Organization, ShareItem } from '../../../types/assets'; import type { Asset, Organization, ShareItem } from '../../../types/assets';
import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { useToast } from '../../../hooks/use-toast';
interface ShareAssetModalProps { interface ShareAssetModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
asset: Asset | null; asset: Asset | null;
assetIds?: string[] | null;
organizations: Organization[]; organizations: Organization[];
onSuccess: () => void; onSuccess: () => void;
} }
@ -21,44 +16,23 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
isOpen, isOpen,
onClose, onClose,
asset, asset,
assetIds,
organizations, organizations,
onSuccess onSuccess
}) => { }) => {
const { success, error } = useToast();
const [sharesList, setSharesList] = useState<ShareItem[]>([]); const [sharesList, setSharesList] = useState<ShareItem[]>([]);
const [isSavingShare, setIsSavingShare] = useState(false); const [isSavingShare, setIsSavingShare] = useState(false);
const [expandedOrgId, setExpandedOrgId] = useState<string | null>(null); const [expandedOrgId, setExpandedOrgId] = useState<string | null>(null);
const [groups, setGroups] = useState<AssetGroup[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string>('');
const [shareMode, setShareMode] = useState<'ALL' | 'SELECTED'>('ALL');
useEffect(() => {
if (isOpen) {
getAssetGroups().then(setGroups).catch(console.error);
setSelectedGroupId('');
}
}, [isOpen]);
useEffect(() => { useEffect(() => {
if (asset) { if (asset) {
const existingShares = asset.sharedWith?.map(s => ({ setSharesList(
organizationId: s.organizationId, asset.sharedWith?.map(s => ({
userId: s.userId organizationId: s.organizationId,
})) || []; userId: s.userId
setSharesList(existingShares); })) || []
// Pre-select mode: if shared with all orgs, set ALL, otherwise SELECTED
const isSharedWithAll = organizations.length > 0 && organizations.every(org =>
existingShares.some(s => s.organizationId === org.id && s.userId === null)
); );
setShareMode(isSharedWithAll ? 'ALL' : (existingShares.length === 0 ? 'ALL' : 'SELECTED'));
} else {
setSharesList([]);
setShareMode('ALL');
} }
}, [asset, isOpen, organizations]); }, [asset]);
const isOrgSharedEntirely = (orgId: string) => { const isOrgSharedEntirely = (orgId: string) => {
return sharesList.some(s => s.organizationId === orgId && s.userId === null); return sharesList.some(s => s.organizationId === orgId && s.userId === null);
@ -94,225 +68,158 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
const handleShareSubmit = async (e: React.FormEvent) => { const handleShareSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!asset && (!assetIds || assetIds.length === 0)) return; if (!asset) return;
setIsSavingShare(true); setIsSavingShare(true);
try { try {
const targetShares = shareMode === 'ALL' await updateAsset(asset.id, {
? organizations.map(org => ({ organizationId: org.id, userId: null })) shares: sharesList
: sharesList; });
if (asset) {
await updateAsset(asset.id, {
shares: targetShares
});
} else if (assetIds && assetIds.length > 0) {
await bulkShareAssets(assetIds, targetShares);
}
if (selectedGroupId) {
const ids = asset ? [asset.id] : (assetIds || []);
if (ids.length > 0) {
await addAssetsToGroup(selectedGroupId, ids);
}
}
success('Share permissions updated', shareMode === 'ALL'
? 'The asset has been shared with ALL partner organizations under the "All Assets" catalog tab.'
: 'The asset visibility and group access settings have been updated.'
);
onSuccess(); onSuccess();
onClose(); onClose();
} catch (err: any) { } catch (err) {
console.error('Failed to update share permissions', err); console.error('Failed to update share permissions', err);
error('Failed to update share permissions', err.response?.data?.error || 'Something went wrong.');
} finally { } finally {
setIsSavingShare(false); setIsSavingShare(false);
} }
}; };
return ( return (
<Modal <AnimatePresence>
isOpen={isOpen && (!!asset || (!!assetIds && assetIds.length > 0))} {isOpen && asset && (
onClose={onClose} <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
title="Share & Partner Access Settings" <motion.div
subtitle={asset ? asset.title : `${assetIds?.length || 0} selected assets`} initial={{ opacity: 0 }}
size="md" animate={{ opacity: 1 }}
footer={ exit={{ opacity: 0 }}
<>
<Button
type="button"
onClick={onClose} onClick={onClose}
variant="ghost" className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
size="sm" />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="relative bg-ink-0 border border-ink-200 rounded-2xl p-6 max-w-lg w-full shadow-xl z-10 flex flex-col max-h-[85vh] overflow-hidden"
> >
Cancel <div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0">
</Button> <div>
<Button <h3 className="text-lg font-bold text-ink-900">Share Settings</h3>
type="submit" <p className="text-xs text-ink-500 mt-0.5">{asset.title}</p>
form="share-asset-form" </div>
disabled={isSavingShare}
variant="primary"
size="sm"
>
{isSavingShare ? 'Saving...' : 'Update Shares'}
</Button>
</>
}
>
{(asset || (assetIds && assetIds.length > 0)) && (
<form id="share-asset-form" onSubmit={handleShareSubmit} className="space-y-4">
{/* Share Scope Selector Pill */}
<div className="space-y-2">
<label className="text-[10px] font-extrabold uppercase tracking-wider text-ink-500 block font-sans">
Sharing Scope (Admin Access Control)
</label>
<div className="grid grid-cols-2 gap-2 p-1 bg-ink-100 rounded-xl border border-ink-200">
<button <button
type="button" onClick={onClose}
onClick={() => setShareMode('ALL')} className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
className={`py-2 px-3 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 cursor-pointer font-sans ${
shareMode === 'ALL'
? 'bg-ink-900 text-ink-0 shadow-sm'
: 'text-ink-600 hover:text-ink-900 hover:bg-ink-200/60'
}`}
> >
<span>🌐 Share with ALL Partners</span> <X className="w-5 h-5" />
</button>
<button
type="button"
onClick={() => setShareMode('SELECTED')}
className={`py-2 px-3 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 cursor-pointer font-sans ${
shareMode === 'SELECTED'
? 'bg-ink-900 text-ink-0 shadow-sm'
: 'text-ink-600 hover:text-ink-900 hover:bg-ink-200/60'
}`}
>
<span>👥 Selected Partners Only</span>
</button> </button>
</div> </div>
</div>
{shareMode === 'ALL' ? ( <form onSubmit={handleShareSubmit} className="flex-1 min-h-0 flex flex-col mt-4">
<div className="p-4 bg-emerald-500/10 border border-emerald-500/30 rounded-xl text-xs text-emerald-800 dark:text-emerald-300 font-sans space-y-1"> <div className="flex-1 overflow-y-auto pr-1 space-y-4 scrollbar-thin">
<span className="font-extrabold block">🌐 Global Access Mode Enabled</span> <p className="text-xs text-ink-600 leading-relaxed">
<p className="leading-relaxed text-[11px]"> Select organizations or expand to specify exact users that can access this asset:
This asset will be automatically accessible to <strong>ALL registered partner organizations</strong> and visible under the "All Assets" catalog tab. </p>
</p>
</div>
) : (
<div className="space-y-2">
<p className="text-xs text-ink-600 leading-relaxed font-sans font-medium">
Select specific partner organizations or expand to specify exact users:
</p>
<div className="max-h-64 overflow-y-auto border border-ink-200 rounded-xl divide-y divide-ink-250 bg-ink-50 scrollbar-thin"> <div className="max-h-64 overflow-y-auto border border-ink-200 rounded-xl divide-y divide-ink-250 bg-ink-50 scrollbar-thin">
{organizations.length === 0 ? ( {organizations.length === 0 ? (
<p className="p-4 text-xs text-ink-500 text-center font-medium font-sans">No partner organizations registered yet.</p> <p className="p-4 text-xs text-ink-500 text-center font-medium">No partner organizations registered yet.</p>
) : ( ) : (
organizations.map(org => { organizations.map(org => {
const isEntireShared = isOrgSharedEntirely(org.id); const isEntireShared = isOrgSharedEntirely(org.id);
const isExpanded = expandedOrgId === org.id; const isExpanded = expandedOrgId === org.id;
const activeUsers = org.users || []; const activeUsers = org.users || [];
const specificSharedCount = sharesList.filter(s => s.organizationId === org.id && s.userId !== null).length; const specificSharedCount = sharesList.filter(s => s.organizationId === org.id && s.userId !== null).length;
return ( return (
<div key={org.id} className="flex flex-col"> <div key={org.id} className="flex flex-col">
<div className="flex items-center justify-between p-3 hover:bg-ink-100 transition-colors"> <div className="flex items-center justify-between p-3 hover:bg-ink-100 transition-colors">
<label className="flex items-center gap-3 cursor-pointer flex-1 select-none"> <label className="flex items-center gap-3 cursor-pointer flex-1 select-none">
<input <input
type="checkbox" type="checkbox"
checked={isEntireShared} checked={isEntireShared}
onChange={() => handleToggleOrg(org.id)} onChange={() => handleToggleOrg(org.id)}
className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer" className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer"
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-xs font-bold text-ink-900 font-sans">{org.name}</span> <span className="text-xs font-bold text-ink-900">{org.name}</span>
{specificSharedCount > 0 && !isEntireShared && ( {specificSharedCount > 0 && !isEntireShared && (
<span className="text-[10px] text-ink-500 font-semibold font-sans"> <span className="text-[10px] text-ink-500 font-semibold">
Shared with {specificSharedCount} specific {specificSharedCount === 1 ? 'user' : 'users'} Shared with {specificSharedCount} specific {specificSharedCount === 1 ? 'user' : 'users'}
</span>
)}
</div>
</label>
<button
type="button"
onClick={() => setExpandedOrgId(isExpanded ? null : org.id)}
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-200 transition-colors flex items-center gap-1 text-[11px] font-bold cursor-pointer font-sans"
>
<span>Users</span>
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="bg-ink-100 border-t border-b border-ink-200 overflow-hidden divide-y divide-ink-150"
>
{activeUsers.length === 0 ? (
<p className="p-3 text-[10px] text-ink-500 italic font-sans">No users found in this organization.</p>
) : (
activeUsers.map(userItem => {
const isUserShared = isUserSharedSpecifically(org.id, userItem.id);
return (
<label key={userItem.id} className="flex items-center gap-3 py-2 px-8 cursor-pointer hover:bg-ink-200/50 transition-all select-none">
<input
type="checkbox"
disabled={isEntireShared}
checked={isEntireShared || isUserShared}
onChange={() => handleToggleUser(org.id, userItem.id)}
className="w-3.5 h-3.5 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer disabled:opacity-50"
/>
<span className={`text-[11px] font-semibold font-sans ${isEntireShared ? 'text-ink-400' : 'text-ink-800'}`}>
{userItem.email}
</span> </span>
</label> )}
); </div>
}) </label>
)}
</motion.div> <button
)} type="button"
</AnimatePresence> onClick={() => setExpandedOrgId(isExpanded ? null : org.id)}
</div> className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-200 transition-colors flex items-center gap-1 text-[11px] font-bold"
); >
}) <span>Users</span>
)} {isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</div> </button>
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="bg-ink-100 border-t border-b border-ink-200 overflow-hidden divide-y divide-ink-150"
>
{activeUsers.length === 0 ? (
<p className="p-3 text-[10px] text-ink-500 italic">No users found in this organization.</p>
) : (
activeUsers.map(userItem => {
const isUserShared = isUserSharedSpecifically(org.id, userItem.id);
return (
<label key={userItem.id} className="flex items-center gap-3 py-2 px-8 cursor-pointer hover:bg-ink-200/50 transition-all select-none">
<input
type="checkbox"
disabled={isEntireShared}
checked={isEntireShared || isUserShared}
onChange={() => handleToggleUser(org.id, userItem.id)}
className="w-3.5 h-3.5 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer disabled:opacity-50"
/>
<span className={`text-[11px] font-semibold ${isEntireShared ? 'text-ink-400' : 'text-ink-800'}`}>
{userItem.email}
</span>
</label>
);
})
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
})
)}
</div>
</div>
<div className="pt-3.5 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0 mt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isSavingShare}
className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm disabled:opacity-50"
>
{isSavingShare ? 'Saving...' : 'Update Shares'}
</button>
</div>
</form>
</motion.div>
</div> </div>
)} )}
</AnimatePresence>
{groups.length > 0 && (
<div className="pt-2 border-t border-ink-200">
<label className="text-[10px] font-bold text-ink-500 uppercase tracking-wide mb-1 block">
Add to Asset Group / Bundle (Optional)
</label>
<select
value={selectedGroupId}
onChange={(e) => setSelectedGroupId(e.target.value)}
className="w-full px-3 py-2 bg-ink-0 border border-ink-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 font-semibold"
>
<option value="">-- Select Group --</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<p className="text-[9px] text-ink-450 mt-1 font-medium">
This will automatically register the shared asset(s) as part of the selected bundle.
</p>
</div>
)}
</form>
)}
</Modal>
); );
}; };

View File

@ -1,110 +0,0 @@
import React, { useEffect, useState, useRef } from 'react';
import { axiosInstance } from '../../../services/axios';
import * as XLSX from 'xlsx';
interface SpreadsheetThumbnailProps {
url: string;
fallback: React.ReactNode;
}
export const SpreadsheetThumbnail: React.FC<SpreadsheetThumbnailProps> = ({ url, fallback }) => {
const parentRef = useRef<HTMLDivElement>(null);
const [html, setHtml] = useState<string>('');
const [scale, setScale] = useState(0.2);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let isMounted = true;
axiosInstance.get(url, { responseType: 'arraybuffer' })
.then(res => {
if (!isMounted) return;
try {
const workbook = XLSX.read(res.data, { type: 'array' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const sheetHtml = XLSX.utils.sheet_to_html(worksheet, { header: '', footer: '' });
if (isMounted) {
setHtml(sheetHtml);
setLoading(false);
}
} catch (err) {
console.error('[SpreadsheetThumbnail] Render error:', err);
if (isMounted) {
setError(true);
setLoading(false);
}
}
})
.catch(err => {
console.error('[SpreadsheetThumbnail] Fetch error:', err);
if (isMounted) {
setError(true);
setLoading(false);
}
});
return () => {
isMounted = false;
};
}, [url]);
useEffect(() => {
if (!parentRef.current) return;
const updateScale = () => {
if (parentRef.current) {
const width = parentRef.current.offsetWidth || 280;
setScale(width / 900);
}
};
updateScale();
const observer = new ResizeObserver(updateScale);
observer.observe(parentRef.current);
return () => observer.disconnect();
}, []);
if (error) return <>{fallback}</>;
return (
<div ref={parentRef} className="w-full h-full relative overflow-hidden bg-white select-none pointer-events-none p-1">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-slate-50 z-10">
<div className="w-5 h-5 rounded-full border-2 border-emerald-600 border-t-transparent animate-spin" />
</div>
)}
<div
className="excel-thumbnail-container absolute top-0 left-0 origin-top-left"
style={{
width: '900px',
height: '600px',
transform: `scale(${scale})`,
overflow: 'hidden',
}}
>
<style>{`
.excel-thumbnail-container table {
border-collapse: collapse;
width: 100%;
font-size: 10px;
font-family: monospace;
color: #0f172a;
}
.excel-thumbnail-container td {
border: 1px solid #cbd5e1;
padding: 4px 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #0f172a;
}
.excel-thumbnail-container tr:nth-child(even) {
background-color: #f8fafc;
}
`}</style>
<div dangerouslySetInnerHTML={{ __html: html }} />
</div>
</div>
);
};

View File

@ -95,12 +95,14 @@ const OrbitingRing: React.FC = () => (
{/* Center icon */} {/* Center icon */}
<div className="absolute inset-0 flex items-center justify-center z-10"> <div className="absolute inset-0 flex items-center justify-center z-10">
<div <div
className="w-16 h-16 rounded-2xl flex items-center justify-center animate-pulse-glow bg-white border border-ink-200 shadow-sm p-2" className="w-16 h-16 rounded-2xl flex items-center justify-center animate-pulse-glow"
style={{ style={{
background: 'linear-gradient(135deg, var(--color-primary-400), var(--color-primary-600))',
boxShadow: '0 0 30px rgba(162,231,113,0.5)',
transform: 'perspective(300px) rotateY(-8deg) rotateX(4deg)', transform: 'perspective(300px) rotateY(-8deg) rotateX(4deg)',
}} }}
> >
<img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" /> <ShieldCheck className="w-8 h-8" style={{ color: 'var(--color-ink-800)' }} />
</div> </div>
</div> </div>
@ -258,10 +260,10 @@ export const LoginForm: React.FC = () => {
<OrbitingRing /> <OrbitingRing />
<div> <div>
<h1 className="text-2xl login-form-title font-extrabold tracking-tight" style={{ color: 'var(--color-ink-800)' }}> <h1 className="text-2xl font-extrabold tracking-tight" style={{ color: 'var(--color-ink-800)' }}>
{mfaPendingEmail ? 'Verify Identity' : 'Tech4Biz Portal'} {mfaPendingEmail ? 'Verify Identity' : 'Tech4Biz Portal'}
</h1> </h1>
<p className="text-sm login-form-subtitle mt-1.5" style={{ color: 'var(--color-ink-600)' }}> <p className="text-sm mt-1.5" style={{ color: 'var(--color-ink-600)' }}>
{mfaPendingEmail {mfaPendingEmail
? `Code sent to ${mfaPendingEmail}` ? `Code sent to ${mfaPendingEmail}`
: 'Enterprise hardware & software asset distribution'} : 'Enterprise hardware & software asset distribution'}

View File

@ -0,0 +1,286 @@
import React, { useState, useEffect } from 'react';
import type { BlogPost } from '../../../types';
import { apiClient } from '../../../lib/api-client';
import { useAuth } from '../../auth/store/AuthContext';
import { BookOpen, User, Calendar, Clock, Plus, X, Sparkles, Send } from 'lucide-react';
export const BlogCatalog: React.FC = () => {
const { user } = useAuth();
const [posts, setPosts] = useState<BlogPost[]>([]);
const [loading, setLoading] = useState(true);
const isAdmin = user?.role === 'ADMIN';
// CMS modal state
const [isOpen, setIsOpen] = useState(false);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [tagsInput, setTagsInput] = useState('');
const [thumbnailUrl, setThumbnailUrl] = useState('');
const [status, setStatus] = useState<'draft' | 'published'>('draft');
const [formError, setFormError] = useState('');
const [submitting, setSubmitting] = useState(false);
const fetchPosts = async () => {
setLoading(true);
try {
const response = await apiClient.get<BlogPost[]>('/blog');
setPosts(response.data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPosts();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError('');
if (!title.trim() || !content.trim()) {
setFormError('Title and content are required.');
return;
}
setSubmitting(true);
try {
const tags = tagsInput.split(',').map(t => t.trim()).filter(t => t.length > 0);
const payload = {
title,
content,
tags,
thumbnailUrl: thumbnailUrl.trim() || undefined,
status,
author: 'Technical Architect',
};
const response = await apiClient.post<BlogPost>('/blog/new', payload);
setPosts(prev => [response.data, ...prev]);
setTitle('');
setContent('');
setTagsInput('');
setThumbnailUrl('');
setStatus('draft');
setIsOpen(false);
} catch (err) {
setFormError('Failed to publish article.');
} finally {
setSubmitting(false);
}
};
return (
<div className="space-y-6 text-ink-900">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-ink-800">Engineering Blog & Insights</h2>
<p className="text-sm text-ink-600">Deep-dives into RISC-V pipelining, CodeNuk scaffolding practices, and edge security optimizations.</p>
</div>
{isAdmin && (
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-1.5 rounded-lg bg-ink-900 px-4 py-2.5 text-xs font-semibold text-ink-0 hover:bg-ink-800 transition-all shadow-sm"
>
<Plus className="h-4 w-4" />
Write Post
</button>
)}
</div>
{loading ? (
<div className="grid gap-6 md:grid-cols-2">
{[1, 2].map(n => (
<div key={n} className="animate-pulse rounded-xl border border-ink-200 bg-ink-0 p-5 space-y-4">
<div className="h-48 rounded-lg bg-ink-100" />
<div className="h-4 w-3/4 rounded bg-ink-100" />
<div className="h-20 rounded bg-ink-100" />
</div>
))}
</div>
) : posts.length === 0 ? (
<div className="rounded-xl border border-ink-200 bg-ink-0 p-12 text-center">
<BookOpen className="h-8 w-8 text-ink-300 mx-auto mb-2" />
<p className="text-sm text-ink-600">No blog posts published yet.</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-2">
{posts.map(post => (
<article
key={post.id}
className="rounded-xl border border-ink-200 bg-ink-0 shadow-sm overflow-hidden flex flex-col hover:border-ink-400 transition-all duration-300"
>
<div className="h-48 w-full bg-ink-100 relative">
<img
src={post.thumbnailUrl}
alt={post.title}
className="w-full h-full object-cover"
/>
{isAdmin && (
<span className={`absolute right-3 top-3 rounded-full border px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider ${
post.status === 'published' ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20' : 'bg-ink-100 text-ink-700 border-ink-200'
}`}>
{post.status}
</span>
)}
</div>
<div className="p-5 flex-1 flex flex-col justify-between space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-3 text-[10px] font-bold text-ink-600 uppercase tracking-wide">
<span className="flex items-center gap-1">
<User className="h-3.5 w-3.5" />
{post.author}
</span>
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{post.publishDate}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{post.readTime}
</span>
</div>
<h3 className="text-base font-bold text-ink-800 leading-snug line-clamp-1">{post.title}</h3>
<p className="text-xs text-ink-600 leading-relaxed line-clamp-3">{post.content}</p>
</div>
<div className="flex flex-wrap gap-1.5 pt-2 border-t border-ink-100">
{post.tags.map(t => (
<span key={t} className="rounded bg-ink-50 px-2 py-0.5 text-[9px] font-semibold text-ink-700 border border-ink-200">
#{t}
</span>
))}
</div>
</div>
</article>
))}
</div>
)}
{/* Post Creator Modal */}
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/20 backdrop-blur-sm p-4 animate-fade-in">
<div className="w-full max-w-lg rounded-2xl border border-ink-200 bg-ink-0 p-6 shadow-xl max-h-[90vh] overflow-y-auto relative animate-scale-up">
<button
onClick={() => setIsOpen(false)}
className="absolute right-4 top-4 rounded-full p-1.5 text-ink-600 hover:bg-ink-100 hover:text-ink-800 transition-colors"
>
<X className="h-5 w-5" />
</button>
<div className="mb-5">
<h3 className="text-lg font-bold text-ink-800 flex items-center gap-2">
<Sparkles className="h-5 w-5 text-ink-900" />
Write Blog Article
</h3>
<p className="text-xs text-ink-600">Draft or publish a technical write-up for the developer channel.</p>
</div>
{formError && (
<div className="mb-4 rounded-lg bg-red-500/10 border border-red-500/20 p-3 text-xs font-semibold text-red-600 dark:text-red-400">
{formError}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Article Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Optimizing Pipeline Hazards in RV64GC Core Designs"
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Content (Markdown supported)</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write the full post text..."
rows={6}
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none resize-y bg-ink-50 text-ink-900 placeholder-ink-400"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Tags (comma-separated)</label>
<input
type="text"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="RISC-V, RTL-Design, Edge-Compute"
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Article Cover Photo URL</label>
<input
type="url"
value={thumbnailUrl}
onChange={(e) => setThumbnailUrl(e.target.value)}
placeholder="https://images.unsplash.com/..."
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Publish Status</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-1.5 text-xs text-ink-800 font-semibold cursor-pointer">
<input
type="radio"
name="blogStatus"
checked={status === 'draft'}
onChange={() => setStatus('draft')}
className="text-ink-900 focus:ring-ink-900/20"
/>
Draft
</label>
<label className="flex items-center gap-1.5 text-xs text-ink-800 font-semibold cursor-pointer">
<input
type="radio"
name="blogStatus"
checked={status === 'published'}
onChange={() => setStatus('published')}
className="text-ink-900 focus:ring-ink-900/20"
/>
Published
</label>
</div>
</div>
<div className="border-t border-ink-100 pt-4 flex justify-end gap-3 mt-6">
<button
type="button"
onClick={() => setIsOpen(false)}
className="rounded-lg border border-ink-200 bg-ink-0 px-4 py-2 text-sm font-semibold text-ink-600 hover:bg-ink-50"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-ink-900 px-5 py-2 text-sm font-semibold text-ink-0 hover:bg-ink-800 flex items-center gap-1.5"
>
<Send className="h-4 w-4" />
{submitting ? 'Publishing...' : 'Publish Article'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default BlogCatalog;

View File

@ -1 +0,0 @@
declare module '@fontsource-variable/inter';

View File

@ -3,7 +3,6 @@ import { persist, createJSONStorage } from 'zustand/middleware';
import type { User, AuthResponse } from '../types/auth'; import type { User, AuthResponse } from '../types/auth';
import { refreshAuthToken } from '../services/auth-api'; import { refreshAuthToken } from '../services/auth-api';
import { axiosInstance } from '../services/axios'; import { axiosInstance } from '../services/axios';
import { useThemeStore } from './use-theme';
interface AuthState { interface AuthState {
user: User | null; user: User | null;
@ -22,17 +21,12 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: false, isAuthenticated: false,
accessToken: null, accessToken: null,
isInitializing: true, isInitializing: true,
setAuth: (data) => { setAuth: (data) => set({
if (data.user?.defaultTheme) { user: data.user,
useThemeStore.getState().setTheme(data.user.defaultTheme as any); accessToken: data.accessToken,
} isAuthenticated: true,
set({ isInitializing: false,
user: data.user, }),
accessToken: data.accessToken,
isAuthenticated: true,
isInitializing: false,
});
},
logout: () => set({ logout: () => set({
user: null, user: null,
accessToken: null, accessToken: null,
@ -45,9 +39,6 @@ export const useAuthStore = create<AuthState>()(
if (state.accessToken && state.user) { if (state.accessToken && state.user) {
try { try {
const res = await axiosInstance.get('/auth/me'); const res = await axiosInstance.get('/auth/me');
if (res.data?.defaultTheme) {
useThemeStore.getState().setTheme(res.data.defaultTheme as any);
}
set({ set({
user: res.data, user: res.data,
isAuthenticated: true, isAuthenticated: true,
@ -67,9 +58,6 @@ export const useAuthStore = create<AuthState>()(
// Try refreshing token // Try refreshing token
try { try {
const data = await refreshAuthToken(); const data = await refreshAuthToken();
if (data.user?.defaultTheme) {
useThemeStore.getState().setTheme(data.user.defaultTheme as any);
}
set({ set({
user: data.user, user: data.user,
accessToken: data.accessToken, accessToken: data.accessToken,

View File

@ -1,11 +1,10 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { UseQueryResult } from "@tanstack/react-query"; import type { UseQueryResult } from "@tanstack/react-query";
import { getPendingPartners, getMyAcceptances } from "../services/legal-api"; import { getPendingPartners } from "../services/legal-api";
import type { PendingPartner, LegalAcceptance } from "../services/legal-api"; import type { PendingPartner } from "../services/legal-api";
export const LEGAL_QUERY_KEYS = { export const LEGAL_QUERY_KEYS = {
pending: () => ["legal", "pending"] as const, pending: () => ["legal", "pending"] as const,
myAcceptances: () => ["legal", "my-acceptances"] as const,
}; };
export const usePendingPartnersQuery = ( export const usePendingPartnersQuery = (
@ -18,14 +17,3 @@ export const usePendingPartnersQuery = (
staleTime: 5 * 60 * 1000, // 5 minutes stale time staleTime: 5 * 60 * 1000, // 5 minutes stale time
}); });
}; };
export const useMyAcceptancesQuery = (
enabled: boolean = true
): UseQueryResult<LegalAcceptance[]> => {
return useQuery<LegalAcceptance[]>({
queryKey: LEGAL_QUERY_KEYS.myAcceptances(),
queryFn: getMyAcceptances,
enabled,
staleTime: 5 * 60 * 1000,
});
};

View File

@ -1,52 +1,18 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { UseMutationResult } from "@tanstack/react-query"; import type { UseMutationResult } from "@tanstack/react-query";
import { invitePartner, updatePartner, resendInvitePartner } from "../services/auth-api"; import { invitePartner } from "../services/auth-api";
import type { InviteResponse, InviteParams, UpdatePartnerParams, Partner } from "../services/auth-api"; import type { InviteResponse } from "../services/auth-api";
import { PARTNERS_QUERY_KEYS } from "./use-partners-query"; import { PARTNERS_QUERY_KEYS } from "./use-partners-query";
export const useInvitePartnerMutation = (): UseMutationResult< export const useInvitePartnerMutation = (): UseMutationResult<
InviteResponse, InviteResponse,
Error, Error,
InviteParams
> => {
const queryClient = useQueryClient();
return useMutation<InviteResponse, Error, InviteParams>({
mutationFn: (params: InviteParams) => invitePartner(params),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: PARTNERS_QUERY_KEYS.list(),
});
},
});
};
export const useUpdatePartnerMutation = (): UseMutationResult<
Partner,
Error,
{ partnerId: string; params: UpdatePartnerParams }
> => {
const queryClient = useQueryClient();
return useMutation<Partner, Error, { partnerId: string; params: UpdatePartnerParams }>({
mutationFn: ({ partnerId, params }) => updatePartner(partnerId, params),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: PARTNERS_QUERY_KEYS.list(),
});
},
});
};
export const useResendInviteMutation = (): UseMutationResult<
{ success: boolean },
Error,
string string
> => { > => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<{ success: boolean }, Error, string>({ return useMutation<InviteResponse, Error, string>({
mutationFn: (partnerId: string) => resendInvitePartner(partnerId), mutationFn: (email: string) => invitePartner(email),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: PARTNERS_QUERY_KEYS.list(), queryKey: PARTNERS_QUERY_KEYS.list(),

View File

@ -6,7 +6,6 @@ interface ThemeState {
theme: Theme; theme: Theme;
toggleTheme: () => void; toggleTheme: () => void;
initTheme: () => void; initTheme: () => void;
setTheme: (theme: Theme) => void;
} }
export const useThemeStore = create<ThemeState>((set) => ({ export const useThemeStore = create<ThemeState>((set) => ({
@ -23,7 +22,8 @@ export const useThemeStore = create<ThemeState>((set) => ({
}), }),
initTheme: () => set(() => { initTheme: () => set(() => {
const saved = localStorage.getItem('theme-preference'); const saved = localStorage.getItem('theme-preference');
const initialTheme = saved ? (saved as Theme) : 'dark'; const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialTheme = saved ? (saved as Theme) : (prefersDark ? 'dark' : 'light');
if (initialTheme === 'dark') { if (initialTheme === 'dark') {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
@ -32,14 +32,5 @@ export const useThemeStore = create<ThemeState>((set) => ({
} }
return { theme: initialTheme }; return { theme: initialTheme };
}),
setTheme: (theme) => set(() => {
localStorage.setItem('theme-preference', theme);
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
return { theme };
}) })
})); }));

View File

@ -1 +0,0 @@
export { useToast } from '../components/ui/Toast';

View File

@ -40,7 +40,7 @@
--color-info: #3b82f6; --color-info: #3b82f6;
/* ── Typography ── */ /* ── Typography ── */
--font-sans: 'Poppins', 'Inter', ui-sans-serif, system-ui, sans-serif; --font-sans: 'Outfit', 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; --font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
/* ── Spacing Scale ── */ /* ── Spacing Scale ── */
@ -594,71 +594,3 @@ body {
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent); background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
animation: shimmer 1.5s linear infinite; animation: shimmer 1.5s linear infinite;
} }
/* ============================================================
TYPOGRAPHY FINE-TUNING (+30% size increase)
============================================================ */
.page-title {
/* text-xl fallback: 1.25rem (20px) * 1.3 = 1.625rem (26px) */
font-size: 1.625rem !important;
}
.page-subtitle {
/* text-xs fallback: 0.75rem (12px) * 1.3 = 0.975rem (15.6px) */
font-size: 0.975rem !important;
}
.action-card-title {
/* text-lg fallback: 1.125rem (18px) * 1.3 = 1.4625rem (23.4px) */
font-size: 1.4625rem !important;
}
.action-card-desc {
/* text-xs fallback: 0.75rem (12px) * 1.3 = 0.975rem (15.6px) */
font-size: 0.975rem !important;
}
.asset-card-title {
/* text-sm fallback: 0.875rem (14px) * 1.3 = 1.1375rem (18.2px) */
font-size: 1.1375rem !important;
}
.explorer-title {
/* text-2xl fallback: 1.5rem (24px) * 1.3 = 1.95rem (31.2px) */
font-size: 1.95rem !important;
}
@media (min-width: 768px) {
.explorer-title {
/* md:text-3xl fallback: 1.875rem (30px) * 1.3 = 2.4375rem (39px) */
font-size: 2.4375rem !important;
}
}
.explorer-desc {
/* text-sm fallback: 0.875rem (14px) * 1.3 = 1.1375rem (18.2px) */
font-size: 1.1375rem !important;
}
.agreement-card-title {
/* text-base fallback: 1.0rem (16px) * 1.3 = 1.3rem (20.8px) */
font-size: 1.3rem !important;
}
.agreement-card-desc {
/* text-xs fallback: 0.75rem (12px) * 1.3 = 0.975rem (15.6px) */
font-size: 0.975rem !important;
}
.login-title {
/* text-3xl fallback: 1.875rem (30px) * 1.3 = 2.4375rem (39px) */
font-size: 2.4375rem !important;
}
@media (min-width: 1024px) {
.login-title {
/* lg:text-4xl fallback: 2.25rem (36px) * 1.3 = 2.925rem (46.8px) */
font-size: 2.925rem !important;
}
}
.login-desc {
/* text-sm fallback: 0.875rem (14px) * 1.3 = 1.1375rem (18.2px) */
font-size: 1.1375rem !important;
}
.login-form-title {
/* text-2xl fallback: 1.5rem (24px) * 1.3 = 1.95rem (31.2px) */
font-size: 1.95rem !important;
}
.login-form-subtitle {
/* text-sm fallback: 0.875rem (14px) * 1.3 = 1.1375rem (18.2px) */
font-size: 1.1375rem !important;
}

View File

@ -1,4 +1,4 @@
import type { User, Asset, Category, Announcement } from '../types'; import type { User, Asset, Category, BlogPost, Announcement } from '../types';
import { initializeStorage } from './mock-data'; import { initializeStorage } from './mock-data';
// Ensure data is seeded in localStorage // Ensure data is seeded in localStorage
@ -90,7 +90,19 @@ export const apiClient = {
return { data: categories as unknown as T, status: 200 }; return { data: categories as unknown as T, status: 200 };
} }
// Blog posts
if (cleanUrl === '/blog') {
const posts = getStored<BlogPost[]>('t4b_blog_posts');
// Filter out drafts for client roles
const activeUserEmail = sessionStorage.getItem('t4b_session_email');
const users = getStored<User[]>('t4b_users');
const activeUser = users.find(u => u.email === activeUserEmail);
let filtered = [...posts];
if (!activeUser || activeUser.role !== 'ADMIN') {
filtered = filtered.filter(p => p.status === 'published');
}
return { data: filtered as unknown as T, status: 200 };
}
// Announcements // Announcements
if (cleanUrl === '/announcements') { if (cleanUrl === '/announcements') {
@ -251,7 +263,23 @@ export const apiClient = {
return { data: newAsset as unknown as T, status: 201 }; return { data: newAsset as unknown as T, status: 201 };
} }
if (url === '/blog/new') {
const posts = getStored<BlogPost[]>('t4b_blog_posts');
const newPost: BlogPost = {
id: `blog-${Date.now()}`,
title: payload.title,
content: payload.content,
author: payload.author || 'Admin Staff',
publishDate: new Date().toISOString().split('T')[0],
thumbnailUrl: payload.thumbnailUrl || 'https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=500&auto=format&fit=crop&q=60',
readTime: `${Math.ceil(payload.content.split(' ').length / 200)} min read`,
tags: payload.tags || [],
status: payload.status || 'draft'
};
posts.push(newPost);
setStored('t4b_blog_posts', posts);
return { data: newPost as unknown as T, status: 201 };
}
throw { status: 404, message: 'Route not found' }; throw { status: 404, message: 'Route not found' };
}, },
@ -259,30 +287,6 @@ export const apiClient = {
put: async <T>(url: string, payload: any): Promise<ApiResponse<T>> => { put: async <T>(url: string, payload: any): Promise<ApiResponse<T>> => {
await delay(); await delay();
if (url === '/auth/profile') {
const activeUserEmail = sessionStorage.getItem('t4b_session_email');
const users = getStored<User[]>('t4b_users');
const index = users.findIndex(u => u.email === activeUserEmail);
if (index === -1) {
throw { status: 401, message: 'Unauthorized' };
}
const user = users[index];
if (payload.password) {
user.passwordHash = 'hashed_new_password';
}
if (payload.companyName !== undefined) user.companyName = payload.companyName;
if (payload.website !== undefined) user.website = payload.website;
if (payload.sector !== undefined) user.sector = payload.sector;
if (payload.companySize !== undefined) user.companySize = payload.companySize;
if (payload.defaultTheme !== undefined) user.defaultTheme = payload.defaultTheme;
users[index] = user;
setStored('t4b_users', users);
return { data: user as unknown as T, status: 200 };
}
if (url.startsWith('/admin/clients/')) { if (url.startsWith('/admin/clients/')) {
const clientId = url.split('/admin/clients/')[1]; const clientId = url.split('/admin/clients/')[1];
const users = getStored<User[]>('t4b_users'); const users = getStored<User[]>('t4b_users');

View File

@ -1,4 +1,4 @@
import type { User, Asset, Category, Announcement } from "../types"; import type { User, Asset, Category, BlogPost, Announcement } from "../types";
// Pre-defined categories // Pre-defined categories
export const SEED_CATEGORIES: Category[] = [ export const SEED_CATEGORIES: Category[] = [
@ -149,7 +149,35 @@ export const SEED_ASSETS: Asset[] = [
}, },
]; ];
// Pre-defined seed blog posts
export const SEED_BLOG_POSTS: BlogPost[] = [
{
id: "blog-1",
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&ixlib=rb-4.0.3",
readTime: "6 min read",
tags: ["RISC-V", "Hardware-Design", "Edge-AI"],
status: "published",
},
{
id: "blog-2",
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&ixlib=rb-4.0.3",
readTime: "8 min read",
tags: ["CodeNuk", "TypeScript", "Microservices"],
status: "published",
},
];
// Pre-defined seed announcements // Pre-defined seed announcements
export const SEED_ANNOUNCEMENTS: Announcement[] = [ export const SEED_ANNOUNCEMENTS: Announcement[] = [
@ -255,7 +283,9 @@ export const initializeStorage = () => {
localStorage.setItem("t4b_categories", JSON.stringify(SEED_CATEGORIES)); localStorage.setItem("t4b_categories", JSON.stringify(SEED_CATEGORIES));
} }
if (!localStorage.getItem("t4b_blog_posts")) {
localStorage.setItem("t4b_blog_posts", JSON.stringify(SEED_BLOG_POSTS));
}
if (!localStorage.getItem("t4b_announcements")) { if (!localStorage.getItem("t4b_announcements")) {
localStorage.setItem( localStorage.setItem(

View File

@ -1,4 +1,3 @@
import '@fontsource-variable/inter'
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'

View File

@ -1,13 +1,9 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect } from 'react';
import { useLocation } from "react-router-dom"; import { motion } from 'framer-motion';
import { motion } from "framer-motion"; import type { Variants } from 'framer-motion';
import type { Variants } from "framer-motion"; import { UploadCloud, Search, File, CheckCircle } from 'lucide-react';
import { import { useAuthStore } from '../hooks/use-auth';
UploadCloud, Search, File, Share2, Folder, Sparkles, Filter, LayoutGrid, import { axiosInstance } from '../services/axios';
List, ArrowUpDown, Shield, X, ChevronDown, Check
} from "lucide-react";
import { useAuthStore } from "../hooks/use-auth";
import { axiosInstance } from "../services/axios";
import { import {
getAssets, getAssets,
getOrganizations, getOrganizations,
@ -15,109 +11,41 @@ import {
requestDownload, requestDownload,
approveDownloadRequest, approveDownloadRequest,
rejectDownloadRequest, rejectDownloadRequest,
downloadAssetFile, downloadAssetFile
getAssetGroups, } from '../services/assets-api';
getTaxonomyMeta,
} from "../services/assets-api";
import type { AssetGroup } from "../services/assets-api";
import { useToast } from "../hooks/use-toast";
// Subcomponents // Subcomponents
import { AssetCard } from "../features/assets/components/AssetCard"; import { AssetCard } from '../features/assets/components/AssetCard';
import { UploadAssetModal } from "../features/assets/components/UploadAssetModal"; import { UploadAssetModal } from '../features/assets/components/UploadAssetModal';
import { EditAssetModal } from "../features/assets/components/EditAssetModal"; import { EditAssetModal } from '../features/assets/components/EditAssetModal';
import { ShareAssetModal } from "../features/assets/components/ShareAssetModal"; import { ShareAssetModal } from '../features/assets/components/ShareAssetModal';
import { AssetDetailsModal } from "../features/assets/components/AssetDetailsModal"; import { AssetDetailsModal } from '../features/assets/components/AssetDetailsModal';
import { AssetViewerModal } from "../features/assets/components/AssetViewerModal"; import { AssetViewerModal } from '../features/assets/components/AssetViewerModal';
import { DownloadRequestsModal } from "../features/assets/components/DownloadRequestsModal"; import { DownloadRequestsModal } from '../features/assets/components/DownloadRequestsModal';
import { ManageGroupsModal } from "../features/assets/components/ManageGroupsModal"; import { PageHeader } from '../components/ui/PageHeader';
import { FilterDrawer } from "../features/assets/components/FilterDrawer";
import { AssetTableView } from "../features/assets/components/AssetTableView";
import { AssetAdminManagerModal } from "../features/assets/components/AssetAdminManagerModal";
import { PageHeader } from "../components/ui/PageHeader";
import Button from "../components/ui/Button";
import { PageLayout } from "../components/layout/PageLayout";
import Modal from "../components/ui/Modal";
// Type Definitions // Type Definitions
import type { Asset, Organization, TaxonomyMeta, AssetQueryFilters } from "../types/assets"; import type { Asset, Organization } from '../types/assets';
const containerVariants: Variants = { const containerVariants: Variants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.04 } }, show: { opacity: 1, transition: { staggerChildren: 0.05 } }
}; };
const itemVariants: Variants = { const itemVariants: Variants = {
hidden: { opacity: 0, y: 12, scale: 0.98 }, hidden: { opacity: 0, y: 15, scale: 0.98 },
show: { show: { opacity: 1, y: 0, scale: 1, transition: { type: 'spring', stiffness: 350, damping: 25 } }
opacity: 1,
y: 0,
scale: 1,
transition: { type: "spring", stiffness: 350, damping: 25 },
},
}; };
export const AssetsPage = () => { export const AssetsPage = () => {
const location = useLocation();
const { success, error } = useToast();
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const [assets, setAssets] = useState<Asset[]>([]); const [assets, setAssets] = useState<Asset[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]); const [organizations, setOrganizations] = useState<Organization[]>([]);
const [taxonomyMeta, setTaxonomyMeta] = useState<TaxonomyMeta | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [groups, setGroups] = useState<AssetGroup[]>([]);
// Layout Density State // Search & Filter
const [viewMode, setViewMode] = useState<'grid' | 'compact' | 'table'>('grid'); const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('ALL');
// Search, Sort & Filter State
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>("ALL");
const [isSortOpen, setIsSortOpen] = useState(false);
const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'title_asc' | 'title_desc' | 'type'>('newest');
const [filters, setFilters] = useState<AssetQueryFilters>({});
// Slide-over Filter Drawer & Admin Manager Controls
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
const [isAdminManagerOpen, setIsAdminManagerOpen] = useState(false);
// Multi-Selection State (Shift+Click Engine)
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(null);
// Recommended assets logic
const partnerGroupStrings = user?.partnerGroup && user.role === "PARTNER_USER"
? user.partnerGroup.split(',').map(s => s.trim().toLowerCase())
: [];
const recommendedAssets = (() => {
if (partnerGroupStrings.length === 0) return [];
const activeGroups = groups.filter(g => partnerGroupStrings.includes(g.name.trim().toLowerCase()));
return activeGroups
.flatMap(g => g.assets)
.filter((recAsset, index, self) =>
self.findIndex(a => a.id === recAsset.id) === index &&
assets.some(allAsset => allAsset.id === recAsset.id)
);
})();
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const targetId = urlParams.get('highlight') || location.state?.highlightAssetId;
if (targetId && !loading) {
setTimeout(() => {
const el = document.getElementById(`asset-card-${targetId}`);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("ring-4", "ring-emerald-500", "scale-[1.03]", "shadow-2xl", "transition-all", "duration-500");
setTimeout(() => {
el.classList.remove("ring-4", "ring-emerald-500", "scale-[1.03]", "shadow-2xl");
}, 5000);
}
}, 300);
}
}, [location.state, location.search, loading]);
// Modal Control States // Modal Control States
const [isUploadOpen, setIsUploadOpen] = useState(false); const [isUploadOpen, setIsUploadOpen] = useState(false);
@ -126,12 +54,9 @@ export const AssetsPage = () => {
const [isDetailsOpen, setIsDetailsOpen] = useState(false); const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const [isViewerOpen, setIsViewerOpen] = useState(false); const [isViewerOpen, setIsViewerOpen] = useState(false);
const [isRequestsOpen, setIsRequestsOpen] = useState(false); const [isRequestsOpen, setIsRequestsOpen] = useState(false);
const [isGroupsOpen, setIsGroupsOpen] = useState(false);
const [activeAsset, setActiveAsset] = useState<Asset | null>(null); const [activeAsset, setActiveAsset] = useState<Asset | null>(null);
const [activeMenuId, setActiveMenuId] = useState<string | null>(null); const [activeMenuId, setActiveMenuId] = useState<string | null>(null);
const [expandedAssetId, setExpandedAssetId] = useState<string | null>(null);
const [assetToDelete, setAssetToDelete] = useState<{ id: string, title: string } | null>(null);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
@ -140,81 +65,26 @@ export const AssetsPage = () => {
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
try { try {
const [assetsData, metaData, groupsData] = await Promise.all([ const assetsData = await getAssets();
getAssets({ ...filters, search: searchQuery, sortBy }),
getTaxonomyMeta(),
getAssetGroups(),
]);
setAssets(assetsData); setAssets(assetsData);
setTaxonomyMeta(metaData);
setGroups(groupsData);
if (user?.role === "ADMIN") { if (user?.role === 'ADMIN') {
const orgsData = await getOrganizations(); const orgsData = await getOrganizations();
setOrganizations(orgsData); setOrganizations(orgsData);
} }
} catch (err) { } catch (err) {
console.error("Failed to fetch assets data", err); console.error('Failed to fetch assets data', err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
// Re-fetch when query or filters change
useEffect(() => {
const timer = setTimeout(() => {
fetchFilteredAssets();
}, 250);
return () => clearTimeout(timer);
}, [searchQuery, sortBy, filters]);
const fetchFilteredAssets = async () => {
try {
const assetsData = await getAssets({
...filters,
search: searchQuery,
sortBy,
});
setAssets(assetsData);
} catch (err) {
console.error("Failed to filter assets", err);
}
};
// Shift + Click Range Multi-Selection Handler
const handleToggleSelectAsset = (assetId: string, event?: React.MouseEvent) => {
const clickedIndex = filteredAssets.findIndex(a => a.id === assetId);
if (event?.shiftKey && lastSelectedIndex !== null && clickedIndex !== -1) {
const start = Math.min(lastSelectedIndex, clickedIndex);
const end = Math.max(lastSelectedIndex, clickedIndex);
const rangeIds = filteredAssets.slice(start, end + 1).map(a => a.id);
setSelectedAssetIds(prev => Array.from(new Set([...prev, ...rangeIds])));
} else {
setSelectedAssetIds(prev =>
prev.includes(assetId) ? prev.filter(id => id !== assetId) : [...prev, assetId]
);
setLastSelectedIndex(clickedIndex);
}
};
const handleSelectAll = () => {
if (selectedAssetIds.length === filteredAssets.length) {
setSelectedAssetIds([]);
} else {
setSelectedAssetIds(filteredAssets.map(a => a.id));
}
};
const openEditModal = (asset: Asset) => { const openEditModal = (asset: Asset) => {
setActiveAsset(asset); setActiveAsset(asset);
setIsEditOpen(true); setIsEditOpen(true);
}; };
const openShareModal = (asset: Asset) => { const openShareModal = (asset: Asset) => {
setSelectedAssetIds([]);
setActiveAsset(asset); setActiveAsset(asset);
setIsShareOpen(true); setIsShareOpen(true);
}; };
@ -229,481 +99,194 @@ export const AssetsPage = () => {
setIsDetailsOpen(true); setIsDetailsOpen(true);
}; };
const handleDeleteAsset = (id: string) => { const handleDeleteAsset = async (id: string) => {
const asset = assets.find(a => a.id === id); if (!window.confirm('Are you sure you want to permanently delete this asset?')) return;
if (asset) {
setAssetToDelete({ id: asset.id, title: asset.title });
}
};
const confirmDeleteAsset = async () => {
if (!assetToDelete) return;
try { try {
await deleteAsset(assetToDelete.id); await deleteAsset(id);
success("Asset deleted successfully", `"${assetToDelete.title}" has been permanently removed.`);
setAssetToDelete(null);
await fetchData(); await fetchData();
} catch (err: any) { } catch (err) {
error("Failed to delete asset", err.response?.data?.error || "Something went wrong."); console.error('Failed to delete asset', err);
} }
}; };
const handleRequestDownload = async (asset: Asset) => { const handleRequestDownload = async (asset: Asset) => {
try { try {
await requestDownload(asset.id); await requestDownload(asset.id);
success("Download request submitted", "An administrator has been notified of your request.");
await fetchData(); await fetchData();
} catch (err: any) { } catch (err) {
error("Failed to submit request", err.response?.data?.error || "Something went wrong."); console.error('Failed to request download access', err);
} }
}; };
const handleApproveRequest = async (assetId: string, requestId: string) => { const handleApproveRequest = async (assetId: string, requestId: string) => {
try { try {
await approveDownloadRequest(assetId, requestId); await approveDownloadRequest(assetId, requestId);
success("Download request approved", "The partner can now download this asset.");
await fetchData(); await fetchData();
} catch (err: any) { } catch (err) {
error("Failed to approve request", err.response?.data?.error || "Something went wrong."); console.error('Failed to approve request', err);
} }
}; };
const handleRejectRequest = async (assetId: string, requestId: string) => { const handleRejectRequest = async (assetId: string, requestId: string) => {
try { try {
await rejectDownloadRequest(assetId, requestId); await rejectDownloadRequest(assetId, requestId);
success("Download request rejected", "The access request was denied.");
await fetchData(); await fetchData();
} catch (err: any) { } catch (err) {
error("Failed to reject request", err.response?.data?.error || "Something went wrong."); console.error('Failed to reject request', err);
} }
}; };
const handleDownload = async (asset: Asset) => { const handleDownload = async (asset: Asset) => {
success("Download started", `Downloading "${asset.title}"...`);
try { try {
await downloadAssetFile(asset.id); await downloadAssetFile(asset.id);
const downloadUrl = asset.url.startsWith("http")
? asset.url
: `${axiosInstance.defaults.baseURL?.replace("/api/v1", "")}${asset.url}`;
const a = document.createElement("a"); const downloadUrl = asset.url.startsWith('http')
? asset.url
: `${axiosInstance.defaults.baseURL?.replace('/api/v1', '')}${asset.url}`;
const a = document.createElement('a');
a.href = downloadUrl; a.href = downloadUrl;
a.download = asset.title; a.download = asset.title;
a.target = "_blank"; a.target = '_blank';
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
setAssets(prev => setAssets(prev => prev.map(item =>
prev.map(item => item.id === asset.id ? { ...item, downloadsCount: item.downloadsCount + 1 } : item) item.id === asset.id ? { ...item, downloadsCount: item.downloadsCount + 1 } : item
); ));
} catch (err: any) { } catch (err) {
error("Download failed", err.response?.data?.error || "Could not retrieve asset file."); console.error('Failed to process download', err);
} }
}; };
// Instant Client-side filtering & sorting const CATEGORIES = ['ALL', 'Marketing', 'Presentations', 'Branding', 'Resources', 'Technical'];
const filteredAssets = useMemo(() => {
let result = assets.filter((asset) => {
if (selectedCategory === "RECOMMENDED") {
return recommendedAssets.some((r) => r.id === asset.id);
}
return true;
});
const sorted = [...result]; const filteredAssets = assets.filter(asset => {
if (sortBy === 'oldest') { const query = searchQuery.toLowerCase().trim();
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
} else if (sortBy === 'newest') {
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
} else if (sortBy === 'title_asc') {
sorted.sort((a, b) => a.title.localeCompare(b.title));
} else if (sortBy === 'title_desc') {
sorted.sort((a, b) => b.title.localeCompare(a.title));
} else if (sortBy === 'type') {
sorted.sort((a, b) => (a.type || '').localeCompare(b.type || ''));
}
return sorted;
}, [assets, selectedCategory, recommendedAssets, sortBy]);
const activeFilterCount = const matchesSearch = !query ||
(filters.verticalIds?.length || 0) + asset.title.toLowerCase().includes(query) ||
(filters.contentTypes?.length || 0) + (asset.description && asset.description.toLowerCase().includes(query)) ||
(filters.subcategories?.length || 0) + (asset.categoryId && asset.categoryId.toLowerCase().includes(query)) ||
(filters.tags?.length || 0); (asset.subcategory && asset.subcategory.toLowerCase().includes(query)) ||
(asset.githubUrl && asset.githubUrl.toLowerCase().includes(query)) ||
asset.type.toLowerCase().includes(query) ||
asset.tags.some(tag => tag.toLowerCase().includes(query));
const matchesCategory =
selectedCategory === 'ALL' ||
asset.categoryId === selectedCategory;
return matchesSearch && matchesCategory;
});
const pendingRequestsCount = assets.reduce((acc, asset) => { const pendingRequestsCount = assets.reduce((acc, asset) => {
return ( return acc + (asset.downloadRequests?.filter(r => r.status === 'PENDING').length || 0);
acc + (asset.downloadRequests?.filter((r) => r.status === "PENDING").length || 0)
);
}, 0); }, 0);
// Header component return (
const headerNode = ( <motion.div variants={containerVariants} initial="hidden" animate="show" className="w-full space-y-6 text-ink-900 animate-fade-in">
<PageHeader
title="Asset Discovery Platform"
subtitle="Enterprise asset catalog featuring vertical domain search, multi-view density, and partner access controls."
/>
);
// Toolbar component <PageHeader
const toolbarNode = ( title="Asset Library"
<div className="flex flex-col lg:flex-row gap-3 items-stretch lg:items-center justify-between p-3.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm"> subtitle="Securely manage, distribute, and track marketing collateral and partner resources."
<div className="flex flex-wrap items-center gap-3 flex-1 min-w-0"> badge={
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-ink-100 border border-ink-200 text-[10px] font-bold text-ink-900 tracking-wider uppercase shrink-0">
<CheckCircle className="w-3.5 h-3.5 text-ink-950" />
<span>Global CDN Active</span>
</div>
}
actions={
<>
{user?.role === 'ADMIN' && pendingRequestsCount > 0 && (
<button
onClick={() => setIsRequestsOpen(true)}
className="bg-ink-100 text-ink-900 border border-ink-300 font-bold py-1.5 px-3 rounded-lg hover:bg-ink-200 transition-all flex items-center justify-center gap-2 relative shadow-sm text-xs cursor-pointer"
>
<span>Download Requests</span>
<span className="w-4 h-4 rounded-full bg-ink-900 text-ink-0 text-[10px] flex items-center justify-center font-extrabold">
{pendingRequestsCount}
</span>
</button>
)}
{/* Search Bar */} {user?.role === 'ADMIN' && (
<div className="relative w-full sm:w-[280px] shrink-0"> <button
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" /> onClick={() => setIsUploadOpen(true)}
className="group relative bg-ink-900 text-ink-0 font-bold py-1.5 px-3 rounded-lg hover:bg-ink-800 transition-all duration-300 overflow-hidden flex items-center justify-center gap-2 shadow-sm text-xs cursor-pointer"
>
<UploadCloud className="w-3.5 h-3.5" />
<span>Create / Upload Asset</span>
</button>
)}
</>
}
/>
{/* Action Bar */}
<motion.div variants={itemVariants} className="flex flex-col md:flex-row gap-4 items-center pt-2">
<div className="relative flex-1 w-full group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="w-4 h-4 text-ink-400 group-focus-within:text-ink-900 transition-colors" />
</div>
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg py-2 pl-9 pr-4 text-xs font-medium text-slate-900 dark:text-slate-100 placeholder-slate-400 outline-none focus:ring-2 ring-slate-400/20 focus:border-slate-500 transition-all" className="w-full bg-ink-0 border border-ink-200 rounded-lg py-1.5 pl-9 pr-4 text-xs font-semibold text-ink-900 placeholder-ink-400 outline-none transition-all focus:border-ink-900/50 focus:ring-2 ring-ink-900/10 shadow-sm hover:border-ink-300"
placeholder="Search catalog titles, tags..." placeholder="Search by title, desc, tag, category, URL..."
/> />
</div> </div>
{/* Premium Custom Sorting Dropdown */} <div className="flex gap-2 w-full md:w-auto overflow-x-auto pb-1 md:pb-0 scrollbar-none">
<div className="relative shrink-0"> {CATEGORIES.map((cat) => (
<button
type="button"
onClick={() => setIsSortOpen(!isSortOpen)}
className="flex items-center gap-2 bg-slate-50 dark:bg-slate-800 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 text-xs font-bold text-slate-800 dark:text-slate-200 hover:border-slate-400 dark:hover:border-slate-600 transition-all cursor-pointer shadow-xs"
>
<ArrowUpDown className="w-3.5 h-3.5 text-amber-500" />
<span>
{sortBy === 'newest' && 'Newest First'}
{sortBy === 'oldest' && 'Oldest First'}
{sortBy === 'title_asc' && 'Title A - Z'}
{sortBy === 'title_desc' && 'Title Z - A'}
{sortBy === 'type' && 'Format / Type'}
</span>
<ChevronDown className={`w-3.5 h-3.5 text-slate-400 transition-transform duration-200 ${isSortOpen ? 'rotate-180' : ''}`} />
</button>
{isSortOpen && (
<>
<div className="fixed inset-0 z-20" onClick={() => setIsSortOpen(false)} />
<div className="absolute right-0 mt-1.5 w-44 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-xl z-30 py-1 overflow-hidden backdrop-blur-md">
{[
{ id: 'newest', label: 'Newest First' },
{ id: 'oldest', label: 'Oldest First' },
{ id: 'title_asc', label: 'Title A - Z' },
{ id: 'title_desc', label: 'Title Z - A' },
{ id: 'type', label: 'Format / Type' },
].map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => {
setSortBy(opt.id as any);
setIsSortOpen(false);
}}
className={`w-full text-left px-3.5 py-2 text-xs font-semibold flex items-center justify-between transition-colors cursor-pointer ${
sortBy === opt.id
? 'bg-amber-500/10 text-amber-500 font-bold dark:bg-amber-500/20'
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'
}`}
>
<span>{opt.label}</span>
{sortBy === opt.id && <Check className="w-3.5 h-3.5 text-amber-500" />}
</button>
))}
</div>
</>
)}
</div>
{/* View Density Switcher */}
<div className="flex items-center p-0.5 bg-slate-100 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 shrink-0">
<button
onClick={() => setViewMode('grid')}
className={`p-1.5 rounded-md text-xs transition-all ${viewMode === 'grid'
? 'bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs font-bold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
title="Grid Cards"
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('compact')}
className={`p-1.5 rounded-md text-xs transition-all ${viewMode === 'compact'
? 'bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs font-bold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
title="Compact Cards"
>
<LayoutGrid className="w-3.5 h-3.5 stroke-[2.5]" />
</button>
<button
onClick={() => setViewMode('table')}
className={`p-1.5 rounded-md text-xs transition-all ${viewMode === 'table'
? 'bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs font-bold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
title="Tabular List View (Shift+Click)"
>
<List className="w-4 h-4" />
</button>
</div>
{/* Partner Recommended Toggle */}
{user?.role === "PARTNER_USER" && recommendedAssets.length > 0 && (
<div className="flex items-center gap-1 bg-slate-100 dark:bg-slate-800 p-1 rounded-xl shrink-0">
<button <button
onClick={() => setSelectedCategory("ALL")} key={cat}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all ${selectedCategory === "ALL" onClick={() => setSelectedCategory(cat)}
? "bg-slate-900 text-white shadow-xs" className={`px-3 py-1.5 rounded-lg border text-xs font-bold transition-all whitespace-nowrap shadow-sm cursor-pointer ${
: "text-slate-600 dark:text-slate-300 hover:bg-slate-200" selectedCategory === cat
}`} ? 'bg-ink-900 text-ink-0 border-ink-900'
: 'bg-ink-0 text-ink-700 border-ink-200 hover:bg-ink-50'
}`}
> >
All Assets {cat}
</button> </button>
<button ))}
onClick={() => setSelectedCategory("RECOMMENDED")}
className={`px-3 py-1 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all ${selectedCategory === "RECOMMENDED"
? "bg-amber-500 text-slate-950 font-bold shadow-xs"
: "text-amber-600 dark:text-amber-400 hover:bg-amber-500/10"
}`}
>
<Sparkles className="w-3.5 h-3.5" />
<span>Recommended ({recommendedAssets.length})</span>
</button>
</div>
)}
</div>
{/* Right Action & Filter Drawer Trigger Area */}
<div className="flex items-center gap-2 shrink-0 justify-end">
{user?.role === "ADMIN" && (
<>
{selectedAssetIds.length > 0 ? (
<Button
onClick={() => setSelectedAssetIds([])}
variant="ghost"
size="sm"
className="text-xs font-semibold text-slate-500 hover:text-slate-900 dark:hover:text-slate-100"
>
Deselect All
</Button>
) : (
<Button
onClick={handleSelectAll}
variant="ghost"
size="sm"
className="text-xs font-semibold text-slate-500 hover:text-slate-900 dark:hover:text-slate-100"
>
Select All
</Button>
)}
<Button
onClick={() => setIsAdminManagerOpen(true)}
variant="secondary"
size="sm"
icon={<Shield className="w-3.5 h-3.5 text-slate-700 dark:text-slate-300" />}
>
Taxonomy & Announcements
</Button>
</>
)}
{user?.role === "ADMIN" && pendingRequestsCount > 0 && (
<Button
onClick={() => setIsRequestsOpen(true)}
variant="secondary"
size="sm"
icon={
<span className="w-4 h-4 rounded-full bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 text-[10px] flex items-center justify-center font-bold">
{pendingRequestsCount}
</span>
}
>
Requests
</Button>
)}
{user?.role === "ADMIN" && selectedAssetIds.length > 0 && (
<Button
onClick={() => {
setActiveAsset(null);
setIsShareOpen(true);
}}
variant="secondary"
size="sm"
icon={<Share2 className="w-3.5 h-3.5" />}
>
Share ({selectedAssetIds.length})
</Button>
)}
{user?.role === "ADMIN" && (
<Button
onClick={() => setIsGroupsOpen(true)}
variant="secondary"
size="sm"
icon={<Folder className="w-3.5 h-3.5" />}
>
Groups
</Button>
)}
{user?.role === "ADMIN" && (
<Button
onClick={() => setIsUploadOpen(true)}
variant="primary"
size="sm"
icon={<UploadCloud className="w-3.5 h-3.5" />}
>
Create Asset
</Button>
)}
{/* Filter Drawer Trigger Button positioned on the far right end */}
<button
onClick={() => setIsFilterDrawerOpen(true)}
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg text-xs font-bold border transition-all ${activeFilterCount > 0
? "bg-slate-900 text-white border-slate-900 shadow-sm dark:bg-slate-100 dark:text-slate-900"
: "bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700"
}`}
>
<Filter className="w-4 h-4" />
<span>Filters</span>
{activeFilterCount > 0 && (
<span className="px-1.5 py-0.2 text-[10px] font-extrabold rounded-full bg-amber-500 text-slate-950">
{activeFilterCount}
</span>
)}
</button>
</div>
</div>
);
return (
<PageLayout header={headerNode} toolbar={toolbarNode}>
<div className="p-5 flex-1 min-h-0 overflow-y-auto custom-scrollbar">
{/* Result Counter & Active Filter Badges */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 text-xs">
<div className="text-slate-500 font-medium flex items-center gap-2">
<span>Showing <strong className="text-slate-900 dark:text-slate-100">{filteredAssets.length}</strong> of {taxonomyMeta?.totalAssets || assets.length} assets</span>
{selectedAssetIds.length > 0 && (
<span className="px-2 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-slate-100 font-bold border border-slate-300 dark:border-slate-700">
{selectedAssetIds.length} selected
</span>
)}
</div>
{activeFilterCount > 0 && (
<button
onClick={() => setFilters({})}
className="text-xs text-slate-700 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white font-bold flex items-center gap-1"
>
<X className="w-3.5 h-3.5" />
Reset Filters ({activeFilterCount})
</button>
)}
</div> </div>
</motion.div>
{loading ? ( {/* Grid Content */}
<div className="py-20 flex justify-center items-center"> {loading ? (
<div className="w-8 h-8 border-4 border-slate-400/30 border-t-slate-900 dark:border-t-slate-100 rounded-full animate-spin" /> <div className="py-20 flex justify-center items-center">
</div> <div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin" />
) : ( </div>
<> ) : filteredAssets.length === 0 ? (
{filteredAssets.length === 0 ? ( <div className="py-12 text-center bg-ink-0 border border-ink-200 rounded-xl">
<div className="py-16 text-center bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl"> <File className="w-12 h-12 text-ink-300 mx-auto mb-4" />
<File className="w-12 h-12 text-slate-300 mx-auto mb-4" /> <h3 className="text-lg font-bold text-ink-900">No assets found</h3>
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">No assets match your query</h3> <p className="text-ink-500 text-sm mt-1">There are no assets matching your criteria.</p>
<p className="text-slate-500 text-xs mt-1 max-w-sm mx-auto"> </div>
Try broadening your search keywords or clearing active domain filters. ) : (
</p> <motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 pt-2">
<button {filteredAssets.map((asset) => (
onClick={() => { setSearchQuery(''); setFilters({}); }} <AssetCard
className="mt-4 px-4 py-2 rounded-lg bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 text-xs font-bold" key={asset.id}
> asset={asset}
Clear All Filters user={user}
</button> activeMenuId={activeMenuId}
</div> setActiveMenuId={setActiveMenuId}
) : viewMode === 'table' ? ( onViewDetails={openDetailsModal}
/* TABULAR LIST VIEW */ onEdit={openEditModal}
<AssetTableView onShare={openShareModal}
assets={filteredAssets} onDelete={handleDeleteAsset}
selectedIds={selectedAssetIds} onOpenViewer={openViewerModal}
recommendedIds={recommendedAssets.map(r => r.id)} onDownload={handleDownload}
onToggleSelect={handleToggleSelectAsset} onRequestDownload={handleRequestDownload}
onSelectAll={handleSelectAll} />
onOpenAsset={openViewerModal} ))}
onRequestDownload={(id) => { </motion.div>
const asset = assets.find(a => a.id === id); )}
if (asset) handleRequestDownload(asset);
}}
userRole={user?.role}
/>
) : (
/* GRID / COMPACT VIEW */
<motion.div
variants={containerVariants}
initial="hidden"
animate="show"
className={`grid grid-cols-1 ${viewMode === 'compact'
? 'sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-3'
: 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6'
} items-start`}
>
{filteredAssets.map((asset) => (
<motion.div
key={asset.id}
variants={itemVariants}
className={`relative ${viewMode === 'compact' ? 'min-h-[360px]' : 'min-h-[410px]'} h-full w-full flex flex-col`}
>
<AssetCard
asset={asset}
user={user}
activeMenuId={activeMenuId}
setActiveMenuId={setActiveMenuId}
onViewDetails={openDetailsModal}
onEdit={openEditModal}
onShare={openShareModal}
onDelete={handleDeleteAsset}
onOpenViewer={openViewerModal}
onDownload={handleDownload}
onRequestDownload={handleRequestDownload}
isSelected={selectedAssetIds.includes(asset.id)}
onToggleSelect={(id, event) => handleToggleSelectAsset(id, event)}
isExpanded={expandedAssetId === asset.id}
onToggleExpand={() => setExpandedAssetId(expandedAssetId === asset.id ? null : asset.id)}
isRecommended={recommendedAssets.some(r => r.id === asset.id)}
/>
</motion.div>
))}
</motion.div>
)}
</>
)}
</div>
{/* Slide-over Filter Drawer */}
<FilterDrawer
isOpen={isFilterDrawerOpen}
onClose={() => setIsFilterDrawerOpen(false)}
meta={taxonomyMeta}
filters={filters}
onChangeFilters={setFilters}
onClearAll={() => setFilters({})}
/>
{/* Admin Taxonomy & Announcements Control Modal */}
<AssetAdminManagerModal
isOpen={isAdminManagerOpen}
onClose={() => setIsAdminManagerOpen(false)}
meta={taxonomyMeta}
organizations={organizations}
allAssets={assets}
onRefreshMeta={fetchData}
/>
{/* Modals Container */} {/* Modals Container */}
<UploadAssetModal <UploadAssetModal
@ -727,10 +310,8 @@ export const AssetsPage = () => {
onClose={() => { onClose={() => {
setIsShareOpen(false); setIsShareOpen(false);
setActiveAsset(null); setActiveAsset(null);
setSelectedAssetIds([]);
}} }}
asset={activeAsset} asset={activeAsset}
assetIds={selectedAssetIds}
organizations={organizations} organizations={organizations}
onSuccess={fetchData} onSuccess={fetchData}
/> />
@ -764,45 +345,7 @@ export const AssetsPage = () => {
onReject={handleRejectRequest} onReject={handleRejectRequest}
/> />
<ManageGroupsModal </motion.div>
isOpen={isGroupsOpen}
onClose={() => setIsGroupsOpen(false)}
assets={assets}
groups={groups}
onRefresh={fetchData}
/>
{/* Delete Confirmation Modal */}
<Modal
isOpen={assetToDelete !== null}
onClose={() => setAssetToDelete(null)}
title="Delete Asset"
subtitle="Confirm permanent resource deletion."
size="sm"
>
<div className="space-y-4 font-sans">
<p className="text-xs text-slate-600 dark:text-slate-400 leading-relaxed">
Are you sure you want to permanently delete <span className="font-bold text-slate-900 dark:text-white">"{assetToDelete?.title}"</span>? This action cannot be undone.
</p>
<div className="flex gap-3 justify-end pt-2">
<Button
onClick={() => setAssetToDelete(null)}
variant="secondary"
size="sm"
>
Cancel
</Button>
<Button
onClick={confirmDeleteAsset}
variant="danger"
size="sm"
>
Delete Permanently
</Button>
</div>
</div>
</Modal>
</PageLayout>
); );
}; };

View File

@ -1,401 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { Shield, FileText, CheckCircle, Clock, Download, ExternalLink, RefreshCw, Sparkles } from 'lucide-react';
import { useAuthStore } from '../hooks/use-auth';
import { useMyAcceptancesQuery } from '../hooks/use-legal-query';
import PageHeader from '../components/ui/PageHeader';
import Button from '../components/ui/Button';
import { PageLayout } from '../components/layout/PageLayout';
import Modal from '../components/ui/Modal';
export const ClientAgreementsPage: React.FC = () => {
const location = useLocation();
const user = useAuthStore((state) => state.user);
const { data: acceptances, isLoading, refetch, isFetching } = useMyAcceptancesQuery();
const [selectedDoc, setSelectedDoc] = useState<'NDA' | 'MSA' | null>(null);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const targetId = urlParams.get('highlight') || location.state?.highlightAssetId;
if (targetId && !isLoading) {
setTimeout(() => {
const el = document.getElementById(`asset-card-${targetId}`) || document.getElementById('asset-card-nda');
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl', 'transition-all', 'duration-500');
setTimeout(() => {
el.classList.remove('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl');
}, 5000);
}
}, 300);
}
}, [location.state, location.search, isLoading]);
const ndaAcceptance = acceptances?.find(a => a.document.type === 'NDA');
const msaAcceptance = acceptances?.find(a => a.document.type === 'MSA');
const fileHost = (import.meta.env.VITE_API_URL || '/api/v1').replace('/api/v1', '');
// Header component
const headerNode = (
<PageHeader
title="Compliance & Legal Agreements"
subtitle="Review and download your signed partnership agreements and compliance certificates."
badge={
<div className="flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-emerald-50 border border-emerald-200 text-[10px] font-bold text-emerald-800 tracking-wider uppercase shrink-0">
<Shield className="w-3.5 h-3.5" />
<span>Compliance Certified</span>
</div>
}
/>
);
// Toolbar component
const toolbarNode = (
<div className="flex items-center justify-between p-3.5 bg-ink-0 border border-ink-200 rounded-xl shadow-sm">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-ink-500">Last audited: today</span>
</div>
<button
onClick={() => refetch()}
disabled={isFetching}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-ink-200 bg-ink-0 text-xs font-bold text-ink-700 hover:bg-ink-50 transition-all cursor-pointer disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${isFetching ? 'animate-spin' : ''}`} />
<span>Sync Ledger</span>
</button>
</div>
);
const activeDocData = selectedDoc === 'NDA' ? ndaAcceptance : msaAcceptance;
return (
<PageLayout header={headerNode} toolbar={toolbarNode}>
<div className="p-5 flex-1 min-h-0 overflow-y-auto flex flex-col gap-6">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-20 flex-1">
<RefreshCw className="w-8 h-8 text-ink-900 animate-spin mb-3" />
<span className="text-sm font-semibold text-ink-500">Querying compliance ledger...</span>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* NDA Card */}
<div
id={`asset-card-${ndaAcceptance?.document?.id || 'nda'}`}
draggable={true}
onDragStart={(e) => {
const payload = {
id: ndaAcceptance?.document?.id || 'nda',
title: 'Mutual Non-Disclosure Agreement (NDA)',
type: 'legal',
entityKind: 'LEGAL',
description: 'Required to protect proprietary IP, silicon designs, and private data sharing.',
url: ndaAcceptance?.documentUrl,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.setData('text/plain', 'Mutual Non-Disclosure Agreement (NDA)');
}}
className="bg-ink-0 border border-ink-200 rounded-2xl p-5 shadow-sm flex flex-col justify-between hover:border-ink-400 transition-all duration-500 cursor-pointer"
>
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-10 h-10 rounded-xl bg-ink-900 text-ink-0 flex items-center justify-center">
<FileText className="w-5 h-5" />
</div>
<button
onClick={(e) => {
e.stopPropagation();
const payload = {
id: ndaAcceptance?.document?.id || 'nda',
title: 'Mutual Non-Disclosure Agreement (NDA)',
type: 'legal',
entityKind: 'LEGAL',
description: 'Required to protect proprietary IP, silicon designs, and private data sharing.',
url: ndaAcceptance?.documentUrl,
};
window.dispatchEvent(new CustomEvent('attach-ai-entity', { detail: payload }));
}}
className="px-2.5 py-1 rounded-full bg-ink-900 text-ink-0 hover:bg-ink-950 text-[10px] font-extrabold shadow-sm transition-all hover:scale-105 flex items-center gap-1 border border-ink-700 cursor-pointer"
title="Inspect NDA with AI Advisor Workbench"
>
<Sparkles className="w-3 h-3 text-amber-400 fill-amber-400" />
<span>ask AI</span>
</button>
</div>
{ndaAcceptance ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-50 text-emerald-700 border border-emerald-250">
<CheckCircle className="w-3.5 h-3.5" />
Signed & Active
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold bg-amber-50 text-amber-700 border border-amber-250">
<Clock className="w-3.5 h-3.5" />
Pending Action
</span>
)}
</div>
<h3 className="text-base agreement-card-title font-extrabold text-ink-900">Mutual Non-Disclosure Agreement (NDA)</h3>
<p className="text-xs agreement-card-desc text-ink-500 mt-1.5 leading-relaxed font-semibold">
Required to protect proprietary IP, silicon designs, and private data sharing during development.
</p>
{ndaAcceptance && (
<div className="mt-4 pt-4 border-t border-ink-100 space-y-2.5">
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">Document Version</span>
<span className="font-bold text-ink-900">v{ndaAcceptance.document.version}</span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">Accepted On</span>
<span className="font-bold text-ink-900">{new Date(ndaAcceptance.acceptedAt).toLocaleDateString()}</span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">Signing IP</span>
<span className="font-mono text-ink-900 font-bold">{ndaAcceptance.ipAddress}</span>
</div>
<div className="flex flex-col gap-1 text-xs pt-1">
<span className="font-semibold text-ink-400 uppercase text-[9px] tracking-wider">Verification Hash</span>
<span className="font-mono text-[10px] text-ink-650 bg-ink-50 p-1.5 rounded border border-ink-150 break-all">
{ndaAcceptance.signatureHash || 'N/A'}
</span>
</div>
</div>
)}
</div>
{ndaAcceptance && (
<div className="mt-6 pt-4 border-t border-ink-100 flex gap-3">
<Button
onClick={() => setSelectedDoc('NDA')}
variant="secondary"
size="sm"
className="flex-1 justify-center"
icon={<ExternalLink className="w-3.5 h-3.5" />}
>
View Document
</Button>
{ndaAcceptance.documentUrl && (
<a
href={`${fileHost}${ndaAcceptance.documentUrl}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center p-2 rounded-lg border border-ink-200 bg-ink-0 text-ink-700 hover:bg-ink-50 hover:border-ink-300 transition-all shadow-sm"
title="Download PDF"
>
<Download className="w-4 h-4" />
</a>
)}
</div>
)}
</div>
{/* MSA Card */}
<div
id={`asset-card-${msaAcceptance?.document?.id || 'msa'}`}
draggable={true}
onDragStart={(e) => {
const payload = {
id: msaAcceptance?.document?.id || 'msa',
title: 'Master Services Agreement (MSA)',
type: 'legal',
entityKind: 'LEGAL',
description: 'Defines commercial framework, SLA guidelines, and consulting provisions.',
url: msaAcceptance?.documentUrl,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.setData('text/plain', 'Master Services Agreement (MSA)');
}}
className="bg-ink-0 border border-ink-200 rounded-2xl p-5 shadow-sm flex flex-col justify-between hover:border-ink-400 transition-all duration-500 cursor-pointer"
>
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-10 h-10 rounded-xl bg-ink-900 text-ink-0 flex items-center justify-center">
<FileText className="w-5 h-5" />
</div>
<button
onClick={(e) => {
e.stopPropagation();
const payload = {
id: msaAcceptance?.document?.id || 'msa',
title: 'Master Services Agreement (MSA)',
type: 'legal',
entityKind: 'LEGAL',
description: 'Defines commercial framework, SLA guidelines, and consulting provisions.',
url: msaAcceptance?.documentUrl,
};
window.dispatchEvent(new CustomEvent('attach-ai-entity', { detail: payload }));
}}
className="px-2.5 py-1 rounded-full bg-ink-900 text-ink-0 hover:bg-ink-950 text-[10px] font-extrabold shadow-sm transition-all hover:scale-105 flex items-center gap-1 border border-ink-700 cursor-pointer"
title="Inspect MSA with AI Advisor Workbench"
>
<Sparkles className="w-3 h-3 text-amber-400 fill-amber-400" />
<span>ask AI</span>
</button>
</div>
{msaAcceptance ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-50 text-emerald-700 border border-emerald-250">
<CheckCircle className="w-3.5 h-3.5" />
Signed & Active
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold bg-amber-50 text-amber-700 border border-amber-250">
<Clock className="w-3.5 h-3.5" />
Pending Action
</span>
)}
</div>
<h3 className="text-base agreement-card-title font-extrabold text-ink-900">Master Services Agreement (MSA)</h3>
<p className="text-xs agreement-card-desc text-ink-500 mt-1.5 leading-relaxed font-semibold">
Defines the commercial framework, SLA guidelines, and consulting provisions for the partnership.
</p>
{msaAcceptance && (
<div className="mt-4 pt-4 border-t border-ink-100 space-y-2.5">
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">Document Version</span>
<span className="font-bold text-ink-900">v{msaAcceptance.document.version}</span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">Accepted On</span>
<span className="font-bold text-ink-900">{new Date(msaAcceptance.acceptedAt).toLocaleDateString()}</span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">Signing IP</span>
<span className="font-mono text-ink-900 font-bold">{msaAcceptance.ipAddress}</span>
</div>
<div className="flex flex-col gap-1 text-xs pt-1">
<span className="font-semibold text-ink-400 uppercase text-[9px] tracking-wider">Verification Hash</span>
<span className="font-mono text-[10px] text-ink-650 bg-ink-50 p-1.5 rounded border border-ink-150 break-all">
{msaAcceptance.signatureHash || 'N/A'}
</span>
</div>
</div>
)}
</div>
{msaAcceptance && (
<div className="mt-6 pt-4 border-t border-ink-100 flex gap-3">
<Button
onClick={() => setSelectedDoc('MSA')}
variant="secondary"
size="sm"
className="flex-1 justify-center"
icon={<ExternalLink className="w-3.5 h-3.5" />}
>
View Document
</Button>
{msaAcceptance.documentUrl && (
<a
href={`${fileHost}${msaAcceptance.documentUrl}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center p-2 rounded-lg border border-ink-200 bg-ink-0 text-ink-700 hover:bg-ink-50 hover:border-ink-300 transition-all shadow-sm"
title="Download PDF"
>
<Download className="w-4 h-4" />
</a>
)}
</div>
)}
</div>
</div>
)}
</div>
{/* Document Viewer Modal */}
<Modal
isOpen={selectedDoc !== null}
onClose={() => setSelectedDoc(null)}
title={selectedDoc === 'NDA' ? 'Non-Disclosure Agreement (NDA)' : 'Master Services Agreement (MSA)'}
subtitle={activeDocData ? `Version ${activeDocData.document.version} — Cryptographically signed` : ''}
size="lg"
>
{activeDocData && (
<div className="space-y-6">
{activeDocData.documentUrl ? (
<div className="w-full h-[600px] border border-ink-200 rounded-xl overflow-hidden shadow-sm">
{activeDocData.documentUrl.toLowerCase().endsWith('.pdf') ? (
<iframe
src={`${fileHost}${activeDocData.documentUrl}`}
title="Signed Document"
className="w-full h-full border-0 bg-white"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-ink-50 overflow-auto p-4">
<img
src={`${fileHost}${activeDocData.documentUrl}`}
alt="Signed Document"
className="max-w-full max-h-full object-contain shadow-md rounded border border-ink-200"
/>
</div>
)}
</div>
) : (
<div className="space-y-6">
<div className="max-h-[450px] overflow-y-auto p-4 bg-ink-50 rounded-xl border border-ink-200 font-serif text-sm leading-relaxed text-ink-800 whitespace-pre-line">
{activeDocData.document.content}
</div>
<div className="p-4 border border-emerald-500 bg-emerald-500/5 rounded-xl space-y-2 text-xs">
<h4 className="font-bold text-emerald-800 tracking-wider uppercase">Signature Verification Ledger</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-ink-700">
<p><strong>Signed By:</strong> {user?.email}</p>
<p><strong>Signed On:</strong> {new Date(activeDocData.acceptedAt).toLocaleString()}</p>
<p><strong>IP Address:</strong> {activeDocData.ipAddress}</p>
<p className="sm:col-span-2"><strong>Verification Hash:</strong> <span className="font-mono text-[10px] break-all">{activeDocData.signatureHash}</span></p>
</div>
</div>
</div>
)}
<div className="flex justify-end gap-3 pt-4 border-t border-ink-100">
<Button variant="secondary" onClick={() => setSelectedDoc(null)}>
Close
</Button>
{activeDocData.documentUrl ? (
<a
href={`${fileHost}${activeDocData.documentUrl}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-ink-900 px-4 py-2 text-xs font-semibold text-ink-0 hover:bg-ink-800 transition-all shadow-sm"
>
<Download className="w-4 h-4" />
<span>Download Signed PDF</span>
</a>
) : (
<Button
onClick={() => {
const element = document.createElement("a");
const file = new Blob([
`${selectedDoc === 'NDA' ? 'Non-Disclosure Agreement (NDA)' : 'Master Services Agreement (MSA)'}\n\n`,
activeDocData.document.content,
`\n\n=== DIGITAL SIGNATURE ===\n`,
`Signed By: ${user?.email}\n`,
`Verification Hash: ${activeDocData.signatureHash || 'N/A'}\n`,
`IP Address: ${activeDocData.ipAddress}\n`,
`Signed On: ${new Date(activeDocData.acceptedAt).toLocaleString()}\n`
], { type: 'text/plain' });
element.href = URL.createObjectURL(file);
element.download = `${selectedDoc}_Agreement_${user?.email?.split('@')[0]}.txt`;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}}
variant="primary"
icon={<Download className="w-4 h-4" />}
>
Download Signed Copy
</Button>
)}
</div>
</div>
)}
</Modal>
</PageLayout>
);
};
export default ClientAgreementsPage;

View File

@ -1,10 +1,9 @@
import { useAuthStore } from '../hooks/use-auth'; import { useAuthStore } from '../hooks/use-auth';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import type { Variants } from 'framer-motion'; import type { Variants } from 'framer-motion';
import { FolderKanban, FileSignature, Users, ArrowUpRight, Activity, Zap, ShieldCheck, Globe } from 'lucide-react'; import { FolderKanban, FileSignature, Users, ArrowUpRight, Activity, Zap, ShieldCheck } from 'lucide-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { PageHeader } from '../components/ui/PageHeader'; import { PageHeader } from '../components/ui/PageHeader';
import { PageLayout } from '../components/layout/PageLayout';
const containerVariants: Variants = { const containerVariants: Variants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
@ -55,97 +54,73 @@ export const DashboardPage = () => {
icon: FileSignature, icon: FileSignature,
path: '/client/agreements', path: '/client/agreements',
metrics: 'My Agreements' metrics: 'My Agreements'
},
{
title: 'Explore More',
description: 'Explore CodeNuk, Cloudtopiaa, and other leading products and services in our ecosystem.',
icon: Globe,
path: '/client/ecosystem',
metrics: 'Explore Offerings'
} }
]; ];
const isAdmin = user?.role === 'ADMIN';
// Header component
const headerNode = (
<PageHeader
title={`Welcome back, ${user?.email?.split('@')[0]}`}
subtitle={isAdmin
? 'Manage your channel network, monitor compliance, and distribute assets globally.'
: `Access your shared resources, legal agreements, and explore our ecosystem of products and services.`
}
badge={
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-ink-900 border border-ink-800 text-[10px] font-bold text-ink-0 tracking-wider uppercase shrink-0">
<div className="w-1 h-1 rounded-full bg-ink-0 animate-pulse" />
<span>System Active</span>
</div>
}
/>
);
return ( return (
<PageLayout header={headerNode}> <motion.div variants={containerVariants} initial="hidden" animate="show" className="w-full space-y-6 text-ink-900 animate-fade-in">
<motion.div <PageHeader
variants={containerVariants} title={`Welcome back, ${user?.email?.split('@')[0]}`}
initial="hidden" subtitle={`You are authenticated as ${user?.role}. Manage your channel network, monitor compliance, and distribute assets globally.`}
animate="show" badge={
className="p-5 space-y-6 flex flex-col min-h-0 flex-1 overflow-y-auto" <div className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-ink-900 border border-ink-800 text-[10px] font-bold text-ink-0 tracking-wider uppercase shrink-0">
> <div className="w-1 h-1 rounded-full bg-ink-0 animate-pulse" />
{/* Stats Grid */} <span>System Active</span>
{user?.role === 'ADMIN' && ( </div>
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2"> }
{[ />
{ label: 'Global Network Uptime', value: '99.99%', icon: Activity, trend: '+0.01%' },
{ label: 'Active Data Streams', value: '1,492', icon: Zap, trend: '+12%' },
{ label: 'Security Compliance', value: 'Level 4', icon: ShieldCheck, trend: 'Verified' }
].map((stat, i) => (
<div key={i} className="relative group overflow-hidden rounded-xl bg-ink-0 border border-ink-200 p-4 transition-all duration-300 shadow-sm hover:shadow-md">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity duration-500 group-hover:scale-110 transform">
<stat.icon className="w-16 h-16 text-ink-900" />
</div>
<p className="text-xs font-semibold uppercase tracking-wider text-ink-500 mb-1">{stat.label}</p>
<div className="flex items-end gap-3 mt-2">
<h3 className="text-xl font-bold text-ink-900 tracking-tight">{stat.value}</h3>
<span className="text-xs font-bold text-ink-900 bg-ink-100 px-2 py-0.5 rounded-md mb-0.5 border border-ink-200">{stat.trend}</span>
</div>
</div>
))}
</motion.div>
)}
{/* Main Action Cards */} {/* Stats Grid */}
<motion.div variants={itemVariants} className={`grid grid-cols-1 ${CARDS.length === 2 ? 'md:grid-cols-2' : 'md:grid-cols-3'} gap-4 pt-2`}> <motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
{CARDS.map((card, idx) => ( {[
<Link key={idx} to={card.path} className="group relative block h-full"> { label: 'Global Network Uptime', value: '99.99%', icon: Activity, trend: '+0.01%' },
<div className="relative h-full bg-ink-0 border border-ink-200 rounded-xl p-5 hover:border-ink-400 transition-all duration-300 overflow-hidden shadow-sm hover:shadow-md hover:-translate-y-0.5 flex flex-col justify-between"> { label: 'Active Data Streams', value: '1,492', icon: Zap, trend: '+12%' },
{ label: 'Security Compliance', value: 'Level 4', icon: ShieldCheck, trend: 'Verified' }
<div className="flex justify-between items-start mb-8 relative z-10"> ].map((stat, i) => (
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-ink-900 to-ink-800 shadow-sm group-hover:scale-105 transition-all duration-350 flex items-center justify-center text-ink-0"> <div key={i} className="relative group overflow-hidden rounded-xl bg-ink-0 border border-ink-200 p-4 transition-all duration-300 shadow-sm hover:shadow-md">
<card.icon className="w-5 h-5" /> <div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity duration-500 group-hover:scale-110 transform">
</div> <stat.icon className="w-16 h-16 text-ink-900" />
<div className="w-8 h-8 rounded-full bg-ink-50 flex items-center justify-center group-hover:bg-ink-100 transition-all duration-300 border border-ink-200 group-hover:border-ink-300"> </div>
<ArrowUpRight className="w-4 h-4 text-ink-400 group-hover:text-ink-900 transition-colors" /> <p className="text-xs font-semibold uppercase tracking-wider text-ink-500 mb-1">{stat.label}</p>
</div> <div className="flex items-end gap-3 mt-2">
</div> <h3 className="text-xl font-bold text-ink-900 tracking-tight">{stat.value}</h3>
<span className="text-xs font-bold text-ink-900 bg-ink-100 px-2 py-0.5 rounded-md mb-0.5 border border-ink-200">{stat.trend}</span>
<div className="relative z-10 mt-auto"> </div>
<div className="inline-block px-2 py-0.5 rounded-md bg-ink-50 border border-ink-200 text-xs font-bold text-ink-500 mb-3 shadow-sm"> </div>
{card.metrics} ))}
</div>
<h3 className="text-lg action-card-title font-bold text-ink-900 mb-2 tracking-tight">
{card.title}
</h3>
<p className="text-ink-500 action-card-desc leading-normal text-xs font-medium">
{card.description}
</p>
</div>
</div>
</Link>
))}
</motion.div>
</motion.div> </motion.div>
</PageLayout>
{/* Main Action Cards */}
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
{CARDS.map((card, idx) => (
<Link key={idx} to={card.path} className="group relative block h-full">
<div className="relative h-full bg-ink-0 border border-ink-200 rounded-xl p-5 hover:border-ink-400 transition-all duration-300 overflow-hidden shadow-sm hover:shadow-md hover:-translate-y-0.5 flex flex-col justify-between">
<div className="flex justify-between items-start mb-8 relative z-10">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-ink-900 to-ink-800 shadow-sm group-hover:scale-105 transition-all duration-350 flex items-center justify-center text-ink-0">
<card.icon className="w-5 h-5" />
</div>
<div className="w-8 h-8 rounded-full bg-ink-50 flex items-center justify-center group-hover:bg-ink-100 transition-all duration-300 border border-ink-200 group-hover:border-ink-300">
<ArrowUpRight className="w-4 h-4 text-ink-400 group-hover:text-ink-900 transition-colors" />
</div>
</div>
<div className="relative z-10 mt-auto">
<div className="inline-block px-2 py-0.5 rounded-md bg-ink-50 border border-ink-200 text-xs font-bold text-ink-500 mb-3 shadow-sm">
{card.metrics}
</div>
<h3 className="text-lg font-bold text-ink-900 mb-2 tracking-tight">
{card.title}
</h3>
<p className="text-ink-500 leading-normal text-xs font-medium">
{card.description}
</p>
</div>
</div>
</Link>
))}
</motion.div>
</motion.div>
); );
}; };
export default DashboardPage; export default DashboardPage;

View File

@ -1,276 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
Code,
Briefcase,
Shield,
Cloud,
Globe,
ExternalLink,
Layers,
Sparkles
} from 'lucide-react';
import { PageHeader } from '../components/ui/PageHeader';
import { PageLayout } from '../components/layout/PageLayout';
import { getEcosystemOfferings } from '../services/ecosystem-api';
import type { EcosystemOffering } from '../services/ecosystem-api';
import { BrandLogo } from './admin/EcosystemManagerPage';
const iconMap: Record<string, any> = {
Code,
Briefcase,
Shield,
Cloud,
Globe
};
export const EcosystemPage: React.FC = () => {
const location = useLocation();
const [offerings, setOfferings] = useState<EcosystemOffering[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<'ALL' | 'PRODUCT' | 'SERVICE'>('ALL');
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const targetId = urlParams.get('highlight') || location.state?.highlightAssetId;
if (targetId && !loading) {
setTimeout(() => {
const el = document.getElementById(`asset-card-${targetId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl', 'transition-all', 'duration-500');
setTimeout(() => {
el.classList.remove('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl');
}, 5000);
}
}, 300);
}
}, [location.state, location.search, loading]);
useEffect(() => {
const fetchData = async () => {
try {
const data = await getEcosystemOfferings();
setOfferings(data);
} catch (err) {
console.error('Failed to load ecosystem data:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const filteredOfferings = offerings.filter(o => {
if (filter === 'ALL') return true;
return o.type === filter;
});
const headerNode = (
<PageHeader
title="Ecosystem Explorer"
subtitle="Unlock access to Tech4Biz's complete network of industry-leading products, platforms, and specialized services."
/>
);
return (
<PageLayout header={headerNode}>
<div className="p-6 space-y-8">
{/* Segmented controls filter */}
<div className="flex justify-between items-center border-b border-ink-200 pb-4">
<div className="flex bg-ink-100 p-0.5 rounded-xl border border-ink-200 overflow-x-auto max-w-full">
{(['ALL', 'PRODUCT', 'SERVICE'] as const).map((type) => {
const labels: Record<string, string> = {
ALL: 'All Solutions',
PRODUCT: 'Products',
SERVICE: 'Services'
};
return (
<button
key={type}
onClick={() => setFilter(type)}
className={`px-4 py-2 rounded-lg text-xs font-black tracking-wider uppercase transition-all duration-300 whitespace-nowrap cursor-pointer ${filter === type
? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200/50'
: 'text-ink-500 hover:text-ink-900'
}`}
>
{labels[type]}
</button>
);
})}
</div>
<div className="text-[10px] font-bold text-ink-400 uppercase tracking-widest hidden sm:block">
{filteredOfferings.length} Solutions Available
</div>
</div>
{loading ? (
<div className="flex flex-1 items-center justify-center py-20">
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin"></div>
</div>
) : (
<AnimatePresence mode="wait">
<motion.div
key="offerings-grid"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -15 }}
transition={{ duration: 0.25 }}
className="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-8"
>
{filteredOfferings.map((offering) => {
const IconComponent = iconMap[offering.logoIcon] || Globe;
const isProduct = offering.type === 'PRODUCT';
return (
<div
key={offering.id}
id={`asset-card-${offering.id}`}
draggable={true}
onDragStart={(e) => {
const payload = {
id: offering.id,
title: offering.name,
type: offering.type,
entityKind: 'ECOSYSTEM',
url: offering.websiteUrl,
description: offering.description,
tagline: offering.tagline,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.setData('text/plain', offering.name);
}}
className="group flex flex-col justify-between bg-ink-0 border border-ink-200 hover:border-ink-450 rounded-3xl p-6 md:p-8 hover:shadow-xl transition-all duration-500 cursor-pointer"
>
<div>
{/* Logo and Badges */}
<div className="flex justify-between items-center mb-6">
<div className="h-10 flex items-center shrink-0 gap-3">
{offering.logoUrl ? (
<BrandLogo name={offering.logoUrl} className="max-h-7 max-w-[150px] object-contain text-ink-900 dark:text-ink-0" />
) : (
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-ink-0 bg-ink-900 shadow-md">
<IconComponent className="w-5 h-5" />
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
const payload = {
id: offering.id,
title: offering.name,
type: offering.type,
entityKind: 'ECOSYSTEM',
url: offering.websiteUrl,
description: offering.description,
tagline: offering.tagline,
};
window.dispatchEvent(new CustomEvent('attach-ai-entity', { detail: payload }));
}}
className="px-2.5 py-1 rounded-full bg-ink-900 text-ink-0 hover:bg-ink-950 text-[10px] font-extrabold shadow-sm transition-all hover:scale-105 flex items-center gap-1 border border-ink-700 cursor-pointer"
title="Inspect Offering with AI Advisor Workbench"
>
<Sparkles className="w-3 h-3 text-amber-400 fill-amber-400" />
<span>ask AI</span>
</button>
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[9px] font-black tracking-widest uppercase border ${isProduct
? 'bg-blue-500/10 text-blue-600 border-blue-500/20'
: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20'
}`}>
<Layers className="w-3 h-3" />
{offering.type}
</span>
</div>
</div>
{/* Info Copy */}
<div className="space-y-3">
<h3 className="text-lg font-black tracking-tight text-ink-900">
{offering.name}
</h3>
<p className="text-xs font-bold text-ink-700 leading-snug">
{offering.tagline}
</p>
<p className="text-xs font-medium leading-relaxed text-ink-500 pt-1">
{offering.description}
</p>
</div>
{/* Key Benefits */}
{offering.benefits && offering.benefits.length > 0 && (
<div className="pt-6 space-y-2">
<span className="text-[10px] font-extrabold uppercase tracking-widest text-ink-400">Key Benefits</span>
<ul className="grid grid-cols-1 gap-2.5 pt-1">
{offering.benefits.map((benefit, bIdx) => (
<li key={bIdx} className="flex items-start gap-2.5 text-xs font-medium text-ink-600">
<span className={`w-1.5 h-1.5 rounded-full shrink-0 mt-1.5 ${isProduct ? 'bg-blue-500' : 'bg-emerald-500'
}`} />
<span>{benefit}</span>
</li>
))}
</ul>
</div>
)}
{/* Optional Media (GIF, Video, Image) */}
{offering.mediaUrl && offering.mediaType && offering.mediaType !== 'NONE' && (
<div className="mt-6 border border-ink-200 rounded-2xl overflow-hidden bg-ink-50 relative aspect-video shadow-sm">
{offering.mediaType === 'VIDEO' ? (
offering.mediaUrl.includes('youtube.com') || offering.mediaUrl.includes('youtube-nocookie.com') || offering.mediaUrl.includes('vimeo.com') ? (
<iframe
src={offering.mediaUrl}
title={`${offering.name} Video Preview`}
className="w-full h-full border-0 absolute inset-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
) : (
<video
src={offering.mediaUrl}
controls
muted
loop
playsInline
className="w-full h-full object-cover"
/>
)
) : (
<img
src={offering.mediaUrl}
alt={`${offering.name} Screenshot`}
className="w-full h-full object-cover group-hover:scale-102 transition-transform duration-700"
/>
)}
</div>
)}
</div>
{/* CTA Button */}
<div className="pt-8 mt-auto">
<a
href={offering.websiteUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 w-full px-5 py-3 rounded-xl text-xs font-black tracking-wider uppercase bg-ink-900 text-ink-0 hover:bg-ink-800 hover:shadow-lg transition-all duration-300 cursor-pointer"
>
<span>{offering.ctaText || 'Explore Solution'}</span>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
</div>
);
})}
</motion.div>
</AnimatePresence>
)}
</div>
</PageLayout>
);
};
export default EcosystemPage;

View File

@ -1,13 +1,12 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Shield, CheckCircle, AlertCircle, ChevronRight, KeyRound, Eye, EyeOff } from 'lucide-react'; import { Shield, CheckCircle, AlertCircle, ChevronRight, KeyRound } from 'lucide-react';
import axios from 'axios';
import { axiosInstance } from '../services/axios'; import { axiosInstance } from '../services/axios';
import { useAuthStore } from '../hooks/use-auth'; import { useAuthStore } from '../hooks/use-auth';
import { useToast } from '../hooks/use-toast';
export const InvitePage: React.FC = () => { export const InvitePage: React.FC = () => {
const { success } = useToast();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const token = searchParams.get('token'); const token = searchParams.get('token');
const navigate = useNavigate(); const navigate = useNavigate();
@ -17,8 +16,6 @@ export const InvitePage: React.FC = () => {
const [email, setEmail] = useState<string>(''); const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -28,10 +25,9 @@ export const InvitePage: React.FC = () => {
return; return;
} }
// verify token on mount const validateToken = async () => {
const verifyToken = async () => {
try { try {
const response = await axiosInstance.get(`/auth/invite/${token}`); const response = await axios.get(`${import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1'}/auth/invite/${token}`);
setEmail(response.data.email); setEmail(response.data.email);
setStatus('valid'); setStatus('valid');
} catch (err) { } catch (err) {
@ -39,7 +35,7 @@ export const InvitePage: React.FC = () => {
} }
}; };
verifyToken(); validateToken();
}, [token]); }, [token]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@ -51,8 +47,8 @@ export const InvitePage: React.FC = () => {
return; return;
} }
if (password.length < 8) { if (password.length < 6) {
setError('Password must be at least 8 characters long'); setError('Password must be at least 6 characters');
return; return;
} }
@ -63,24 +59,10 @@ export const InvitePage: React.FC = () => {
password password
}); });
// Show success toast setAuth(response.data);
success('Account activated successfully!'); navigate('/onboarding');
// Set auth store
setAuth({ user: response.data.user, accessToken: response.data.token });
// Redirect based on role / status
setTimeout(() => {
if (response.data.user.role === 'ADMIN') {
navigate('/admin');
} else if (response.data.user.onboardingStatus === 'APPROVED') {
navigate('/client');
} else {
navigate('/onboarding');
}
}, 1000);
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.detail || err.response?.data?.error || 'Activation failed'); setError(err.response?.data?.error || 'Failed to accept invite');
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
@ -88,7 +70,7 @@ export const InvitePage: React.FC = () => {
if (status === 'loading') { if (status === 'loading') {
return ( return (
<div className="min-h-screen bg-ink-50 flex items-center justify-center"> <div className="min-h-screen bg-ink-50 flex items-center justify-center">
<div className="w-8 h-8 border-4 border-ink-900/35 border-t-ink-900 rounded-full animate-spin" /> <div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin" />
</div> </div>
); );
} }
@ -96,13 +78,13 @@ export const InvitePage: React.FC = () => {
if (status === 'invalid') { if (status === 'invalid') {
return ( return (
<div className="min-h-screen bg-ink-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-ink-50 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-ink-0 rounded-[2rem] p-8 border border-ink-200 shadow-2xl text-center"> <div className="w-full max-w-md bg-ink-0 rounded-3xl p-8 border border-ink-200 text-center shadow-2xl">
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-6"> <div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertCircle className="w-8 h-8 text-red-600 dark:text-red-500" /> <AlertCircle className="w-8 h-8 text-red-600 dark:text-red-500" />
</div> </div>
<h2 className="text-2xl login-form-title font-bold text-ink-900 mb-2">Invalid or Expired Link</h2> <h2 className="text-2xl font-bold text-ink-900 mb-2">Invalid or Expired Link</h2>
<p className="text-sm login-form-subtitle text-ink-500 max-w-md mx-auto mb-6"> <p className="text-ink-500 text-sm mb-8">
The onboarding invitation link is invalid, expired, or has already been used. Please request a new invitation from your administrator. This invitation link is no longer valid. Please request a new invitation from your administrator.
</p> </p>
<button onClick={() => navigate('/login')} className="text-ink-900 font-extrabold hover:underline"> <button onClick={() => navigate('/login')} className="text-ink-900 font-extrabold hover:underline">
Return to Login Return to Login
@ -118,12 +100,12 @@ export const InvitePage: React.FC = () => {
<div className="fixed top-0 right-0 w-[500px] h-[500px] bg-ink-900/5 rounded-full blur-[120px] pointer-events-none" /> <div className="fixed top-0 right-0 w-[500px] h-[500px] bg-ink-900/5 rounded-full blur-[120px] pointer-events-none" />
<div className="w-full max-w-md z-10"> <div className="w-full max-w-md z-10">
<div className="text-center mb-6"> <div className="text-center mb-10">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-ink-900 to-ink-800 shadow-xl flex items-center justify-center mx-auto mb-6"> <div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-ink-900 to-ink-800 shadow-xl flex items-center justify-center mx-auto mb-6">
<Shield className="w-6 h-6 text-ink-0" /> <Shield className="w-6 h-6 text-ink-0" />
</div> </div>
<h1 className="text-3xl login-title font-extrabold tracking-tight mb-2">Welcome to Tech4Biz</h1> <h1 className="text-3xl font-extrabold tracking-tight mb-2">Welcome to Tech4Biz</h1>
<p className="text-sm login-desc text-ink-500"> <p className="text-sm font-medium text-ink-500">
Set up your partner account for <span className="text-ink-900 font-bold">{email}</span> Set up your partner account for <span className="text-ink-900 font-bold">{email}</span>
</p> </p>
</div> </div>
@ -148,20 +130,13 @@ export const InvitePage: React.FC = () => {
<KeyRound className="w-4 h-4 text-ink-400" /> <KeyRound className="w-4 h-4 text-ink-400" />
</div> </div>
<input <input
type={showPassword ? 'text' : 'password'} type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="w-full pl-11 pr-10 py-3.5 bg-ink-50 border border-ink-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-ink-900/10 outline-none transition-all placeholder-ink-400 text-ink-900" className="w-full pl-11 pr-4 py-3.5 bg-ink-50 border border-ink-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-ink-900/10 outline-none transition-all placeholder-ink-400 text-ink-900"
placeholder="Enter a secure password" placeholder="Enter a secure password"
required required
/> />
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-ink-400 hover:text-ink-900 cursor-pointer"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div> </div>
</div> </div>
@ -172,20 +147,13 @@ export const InvitePage: React.FC = () => {
<CheckCircle className="w-4 h-4 text-ink-400" /> <CheckCircle className="w-4 h-4 text-ink-400" />
</div> </div>
<input <input
type={showConfirmPassword ? 'text' : 'password'} type="password"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full pl-11 pr-10 py-3.5 bg-ink-50 border border-ink-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-ink-900/10 outline-none transition-all placeholder-ink-400 text-ink-900" className="w-full pl-11 pr-4 py-3.5 bg-ink-50 border border-ink-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-ink-900/10 outline-none transition-all placeholder-ink-400 text-ink-900"
placeholder="Confirm your password" placeholder="Confirm your password"
required required
/> />
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-ink-400 hover:text-ink-900 cursor-pointer"
>
{showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div> </div>
</div> </div>

View File

@ -1,25 +1,21 @@
import { useForm } from "react-hook-form"; import { useForm } from 'react-hook-form';
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from "zod"; import { z } from 'zod';
import { loginUser } from "../services/auth-api"; import { loginUser } from '../services/auth-api';
import { useAuthStore } from "../hooks/use-auth"; import { useAuthStore } from '../hooks/use-auth';
import { useNavigate } from "react-router-dom"; import { useNavigate } from 'react-router-dom';
import { useState } from "react"; import { useState } from 'react';
import { Lock, Mail, ArrowRight, Eye, EyeOff } from "lucide-react"; import { Hexagon, Lock, Mail, ArrowRight, Eye, EyeOff } from 'lucide-react';
import { motion } from "framer-motion"; import { motion } from 'framer-motion';
import { useToast } from "../hooks/use-toast";
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().email({ message: "Invalid email address" }), email: z.string().email({ message: 'Invalid email address' }),
password: z password: z.string().min(6, { message: 'Password must be at least 6 characters' }),
.string()
.min(6, { message: "Password must be at least 6 characters" }),
}); });
type LoginFormValues = z.infer<typeof loginSchema>; type LoginFormValues = z.infer<typeof loginSchema>;
export const LoginPage = () => { export const LoginPage = () => {
const { success, error: toastError } = useToast();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const setAuth = useAuthStore((state) => state.setAuth); const setAuth = useAuthStore((state) => state.setAuth);
@ -36,165 +32,106 @@ export const LoginPage = () => {
const onSubmit = async (data: LoginFormValues) => { const onSubmit = async (data: LoginFormValues) => {
try { try {
setError(null); setError(null);
const response = await loginUser({ const response = await loginUser({ email: data.email, password: data.password });
email: data.email,
password: data.password,
});
setAuth(response); setAuth(response);
success("Successfully signed in", `Welcome back, ${response.user.email}!`); if (response.user.role === 'ADMIN') {
if (response.user.role === "ADMIN") { navigate('/admin');
navigate("/admin");
} else { } else {
navigate("/client"); navigate('/client');
} }
} catch (err: any) { } catch (err: any) {
const errMsg = err.response?.data?.error || "Authentication failed. Verify credentials."; setError(err.response?.data?.error || 'Authentication failed. Verify credentials.');
setError(errMsg);
toastError("Authentication failed", errMsg);
} }
}; };
return ( return (
<div className="min-h-screen bg-ink-50 flex flex-col justify-between items-center p-6 md:p-12 relative overflow-hidden font-sans transition-colors duration-500"> <div className="min-h-screen bg-ink-50 flex flex-col justify-center items-center p-4 relative overflow-hidden font-sans transition-colors duration-500">
{/* Monochromatic Soft Background Blurs */} {/* Monochromatic Soft Background Blurs */}
<div className="absolute top-1/4 left-1/4 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" /> <div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" />
<div className="absolute bottom-1/4 right-1/4 w-[700px] h-[700px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" /> <div className="absolute bottom-1/4 right-1/4 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" />
{/* Spacer to push content down slightly on desktop for better centering */}
<div className="hidden md:block h-6" />
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1] }} transition={{ duration: 1, ease: [0.16, 1, 0.3, 1] }}
className="w-full max-w-5xl relative z-10 grid grid-cols-1 md:grid-cols-12 gap-12 md:gap-16 items-center my-auto" className="w-full max-w-md relative z-10"
> >
{/* Left Column - Partner Explanation */} <div className="bg-ink-0 border border-ink-200 rounded-[2rem] shadow-2xl p-12 relative overflow-hidden">
<div className="md:col-span-6 lg:col-span-7 flex flex-col justify-center text-left"> <div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-ink-300 to-transparent" />
<div className="space-y-4 max-w-xl">
<h1 className="text-3xl login-title lg:text-4xl font-extrabold text-ink-900 tracking-tight leading-tight"> <div className="flex flex-col items-center mb-12 text-center">
Enterprise Scale, Secure Delivery <div className="relative flex items-center justify-center w-20 h-20 rounded-[1.5rem] bg-gradient-to-tr from-ink-900 to-ink-800 shadow-lg mb-8">
</h1> <Hexagon className="text-ink-0 w-10 h-10 absolute" />
<p className="text-ink-500 text-sm login-desc mt-5 font-medium leading-relaxed max-w-lg"> </div>
Tech4Biz Channel Partner Portal facilitates authenticated, low-latency distribution of physical asset coordinates, design assets, and cryptographically verified legal agreements. <h2 className="text-3xl font-extrabold text-ink-900 tracking-tight">Channel Portal</h2>
</p> <p className="text-ink-500 text-sm mt-3 font-medium uppercase tracking-widest">Authorized Access Only</p>
</div> </div>
</div>
{/* Right Column - Login Component */} {error && (
<div className="md:col-span-6 lg:col-span-5 w-full max-w-sm justify-self-center md:justify-self-end"> <motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="bg-red-500/10 border border-red-500/20 text-red-600 dark:text-red-400 p-4 rounded-xl text-sm mb-8 flex items-center gap-3 font-medium shadow-sm">
<div className="bg-ink-0 border border-ink-200 rounded-2xl shadow-xl p-8 md:p-10 relative overflow-hidden"> <div className="w-2 h-2 rounded-full bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]" />
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-ink-300 to-transparent" /> {error}
</motion.div>
)}
<div className="flex flex-col items-center mb-8 text-center"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="relative flex items-center justify-center w-16 h-16 rounded-2xl bg-ink-0 border border-ink-200 shadow-sm mb-6 p-2"> <div className="space-y-2">
<img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" /> <label className="block text-[11px] font-bold text-ink-500 uppercase tracking-widest">Work Email</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Mail className={`w-5 h-5 transition-colors ${errors.email ? 'text-red-400' : 'text-ink-400 group-focus-within:text-ink-900'}`} />
</div>
<input
type="email"
{...register('email')}
className={`w-full bg-ink-50 border ${errors.email ? 'border-red-500/50 focus:border-red-500' : 'border-ink-200 focus:border-ink-900/50'} rounded-2xl py-4 pl-12 pr-4 text-ink-900 placeholder-ink-400 outline-none transition-all focus:bg-ink-0 focus:ring-4 ring-ink-900/10 font-medium`}
placeholder="admin@tech4biz.com"
/>
</div> </div>
<h2 className="text-2xl login-form-title font-bold text-ink-900 tracking-tight"> {errors.email && <p className="text-red-500 dark:text-red-400 text-xs mt-1.5 font-bold">{errors.email.message}</p>}
Partner Identity Gateway
</h2>
<p className="text-ink-500 text-[10px] mt-1.5 font-bold uppercase tracking-wider">
Authorized Access Only
</p>
</div> </div>
{error && ( <div className="space-y-2">
<motion.div <div className="flex justify-between items-center">
initial={{ opacity: 0, scale: 0.95 }} <label className="block text-[11px] font-bold text-ink-500 uppercase tracking-widest">Password</label>
animate={{ opacity: 1, scale: 1 }} <a href="#" className="text-[11px] font-bold text-ink-500 hover:text-ink-900 transition-colors tracking-wider">RECOVERY?</a>
className="bg-red-500/10 border border-red-500/20 text-red-600 dark:text-red-400 p-3 rounded-lg text-xs mb-6 flex items-center gap-2.5 font-medium shadow-sm font-sans"
>
<div className="w-1.5 h-1.5 rounded-full bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)] shrink-0" />
{error}
</motion.div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<div className="space-y-1.5">
<label className="block text-[10px] font-bold text-ink-500 uppercase tracking-wider">
Work Email
</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
<Mail
className={`w-4 h-4 transition-colors ${errors.email ? "text-red-400" : "text-ink-400 group-focus-within:text-ink-900"}`}
/>
</div>
<input
type="email"
{...register("email")}
className={`w-full bg-ink-50 border ${errors.email ? "border-red-500/50 focus:border-red-500" : "border-ink-200 focus:border-ink-900/50"} rounded-xl py-2.5 pl-11 pr-4 text-xs font-medium text-ink-900 placeholder-ink-400 outline-none transition-all focus:bg-ink-0 focus:ring-4 ring-ink-900/10 font-sans`}
placeholder="Enter Your Registered Email.."
/>
</div>
{errors.email && (
<p className="text-red-500 dark:text-red-400 text-[10px] mt-1 font-bold font-sans">
{errors.email.message}
</p>
)}
</div> </div>
<div className="relative group">
<div className="space-y-1.5"> <div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<div className="flex justify-between items-center"> <Lock className={`w-5 h-5 transition-colors ${errors.password ? 'text-red-400' : 'text-ink-400 group-focus-within:text-ink-900'}`} />
<label className="block text-[10px] font-bold text-ink-500 uppercase tracking-wider">
Password
</label>
{/* <a
href="#"
className="text-[10px] font-bold text-ink-500 hover:text-ink-900 transition-colors tracking-wider"
>
RECOVERY?
</a> */}
</div> </div>
<div className="relative group"> <input
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none"> type={showPassword ? 'text' : 'password'}
<Lock {...register('password')}
className={`w-4 h-4 transition-colors ${errors.password ? "text-red-400" : "text-ink-400 group-focus-within:text-ink-900"}`} className={`w-full bg-ink-50 border ${errors.password ? 'border-red-500/50 focus:border-red-500' : 'border-ink-200 focus:border-ink-900/50'} rounded-2xl py-4 pl-12 pr-12 text-ink-900 placeholder-ink-400 outline-none transition-all focus:bg-ink-0 focus:ring-4 ring-ink-900/10 font-medium`}
/> placeholder="••••••••"
</div> />
<input <button
type={showPassword ? "text" : "password"} type="button"
{...register("password")} onClick={() => setShowPassword(!showPassword)}
className={`w-full bg-ink-50 border ${errors.password ? "border-red-500/50 focus:border-red-500" : "border-ink-200 focus:border-ink-900/50"} rounded-xl py-2.5 pl-11 pr-11 text-xs font-medium text-ink-900 placeholder-ink-400 outline-none transition-all focus:bg-ink-0 focus:ring-4 ring-ink-900/10 font-sans`} className="absolute inset-y-0 right-0 pr-4 flex items-center text-ink-400 hover:text-ink-950 focus:outline-none transition-colors"
placeholder="Enter Your Password" >
/> {showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
<button </button>
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-ink-400 hover:text-ink-900 focus:outline-none transition-colors"
>
{showPassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
{errors.password && (
<p className="text-red-500 dark:text-red-400 text-[10px] mt-1 font-bold font-sans">
{errors.password.message}
</p>
)}
</div> </div>
{errors.password && <p className="text-red-500 dark:text-red-400 text-xs mt-1.5 font-bold">{errors.password.message}</p>}
</div>
<button <button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
className="group relative w-full bg-ink-900 text-ink-0 font-bold text-xs uppercase tracking-wider py-2.5 px-4 rounded-xl hover:bg-ink-800 transition-all duration-300 disabled:opacity-70 disabled:cursor-not-allowed mt-4 overflow-hidden flex items-center justify-center gap-2 shadow-md font-sans cursor-pointer" className="group relative w-full bg-ink-900 text-ink-0 font-extrabold tracking-wide py-4 px-4 rounded-2xl hover:bg-ink-800 transition-all duration-300 disabled:opacity-70 disabled:cursor-not-allowed mt-4 overflow-hidden flex items-center justify-center gap-3 shadow-lg"
> >
{isSubmitting ? "AUTHENTICATING..." : "SECURE SIGN IN"} {isSubmitting ? 'AUTHENTICATING...' : 'SECURE SIGN IN'}
{!isSubmitting && ( {!isSubmitting && <ArrowRight className="w-5 h-5 group-hover:translate-x-1.5 transition-transform" />}
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" /> </button>
)} </form>
</button>
</form>
</div>
</div> </div>
</motion.div>
<p className="text-center text-ink-400 text-[10px] font-bold tracking-wider uppercase mt-8 relative z-10"> <p className="text-center text-ink-400 text-xs mt-10 font-bold tracking-widest uppercase">
© 2026 Tech4Biz Solutions. © 2026 Tech4Biz Solutions.
</p> </p>
</motion.div>
</div> </div>
); );
}; };

View File

@ -10,12 +10,10 @@ import {
uploadSignedLegalDocument, uploadSignedLegalDocument,
signLegalDocument signLegalDocument
} from '../services/legal-api'; } from '../services/legal-api';
import { useToast } from '../hooks/use-toast';
type DocumentType = 'NDA' | 'MSA'; type DocumentType = 'NDA' | 'MSA';
export const OnboardingPage: React.FC = () => { export const OnboardingPage: React.FC = () => {
const { success, error: toastError } = useToast();
const { user, checkAuth } = useAuthStore(); const { user, checkAuth } = useAuthStore();
const navigate = useNavigate(); const navigate = useNavigate();
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
@ -38,8 +36,6 @@ export const OnboardingPage: React.FC = () => {
const [msaContent, setMsaContent] = useState<string>(''); const [msaContent, setMsaContent] = useState<string>('');
const [msaPdfUrl, setMsaPdfUrl] = useState<string | null>(null); const [msaPdfUrl, setMsaPdfUrl] = useState<string | null>(null);
const [loadingDocs, setLoadingDocs] = useState(true); const [loadingDocs, setLoadingDocs] = useState(true);
const [ndaSkipped, setNdaSkipped] = useState(false);
const [msaSkipped, setMsaSkipped] = useState(false);
useEffect(() => { useEffect(() => {
const fetchDocs = async () => { const fetchDocs = async () => {
@ -48,73 +44,18 @@ export const OnboardingPage: React.FC = () => {
getActiveLegalDocument('NDA'), getActiveLegalDocument('NDA'),
getActiveLegalDocument('MSA') getActiveLegalDocument('MSA')
]); ]);
setNdaContent(ndaData.content);
const ndaIsSkipped = !!ndaData.skipped; setNdaPdfUrl(ndaData.pdfUrl || null);
const msaIsSkipped = !!msaData.skipped; setMsaContent(msaData.content);
setMsaPdfUrl(msaData.pdfUrl || null);
setNdaSkipped(ndaIsSkipped);
setMsaSkipped(msaIsSkipped);
if (!ndaIsSkipped) {
setNdaContent(ndaData.content);
setNdaPdfUrl(ndaData.pdfUrl || null);
}
if (!msaIsSkipped) {
setMsaContent(msaData.content);
setMsaPdfUrl(msaData.pdfUrl || null);
}
// Fetch acceptances to determine active step
const acceptances = await getMyAcceptances();
const ndaAcceptance = acceptances.find((a: any) => a.document.type === 'NDA');
const msaAcceptance = acceptances.find((a: any) => a.document.type === 'MSA');
if (ndaAcceptance) {
if (ndaAcceptance.signatureBase64) {
setNdaSignature(ndaAcceptance.signatureBase64);
setNdaMode('draw');
} else if (ndaAcceptance.documentUrl) {
setNdaUploadUrl(ndaAcceptance.documentUrl);
setNdaMode('upload');
}
}
if (msaAcceptance) {
if (msaAcceptance.signatureBase64) {
setMsaSignature(msaAcceptance.signatureBase64);
setMsaMode('draw');
} else if (msaAcceptance.documentUrl) {
setMsaUploadUrl(msaAcceptance.documentUrl);
setMsaMode('upload');
}
}
// Determine step based on skipped/accepted statuses
if (ndaIsSkipped || ndaAcceptance) {
if (msaIsSkipped || msaAcceptance) {
setStep(3); // both done/skipped
} else {
setStep(2); // nda done/skipped, msa needed
}
} else {
setStep(1); // nda needed
}
} catch (err) { } catch (err) {
console.error('Failed to fetch legal documents:', err); console.error('Failed to fetch legal documents:', err);
} finally { } finally {
setLoadingDocs(false); setLoadingDocs(false);
} }
}; };
fetchDocs();
if (user?.onboardingStatus === 'APPROVED') { }, []);
navigate('/client');
} else if (user?.onboardingStatus === 'PENDING_APPROVAL') {
setStep(3);
setLoadingDocs(false);
} else {
fetchDocs();
}
}, [user, navigate]);
const handlePrint = (type: DocumentType) => { const handlePrint = (type: DocumentType) => {
const content = type === 'NDA' ? ndaContent : msaContent; const content = type === 'NDA' ? ndaContent : msaContent;
@ -147,7 +88,7 @@ export const OnboardingPage: React.FC = () => {
}; };
const renderDocumentViewer = (type: DocumentType) => { const renderDocumentViewer = (type: DocumentType) => {
const fileHost = (import.meta.env.VITE_API_URL || '/api/v1').replace('/api/v1', ''); const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', '');
const pdfUrl = type === 'NDA' ? ndaPdfUrl : msaPdfUrl; const pdfUrl = type === 'NDA' ? ndaPdfUrl : msaPdfUrl;
if (pdfUrl) { if (pdfUrl) {
@ -202,6 +143,22 @@ export const OnboardingPage: React.FC = () => {
); );
}; };
useEffect(() => {
if (user?.onboardingStatus === 'APPROVED') {
navigate('/client');
} else if (user?.onboardingStatus === 'PENDING_APPROVAL') {
setStep(3); // Go straight to pending screen
} else {
// Check existing acceptances to skip steps
getMyAcceptances().then(data => {
const hasNDA = data.some((a: any) => a.document.type === 'NDA');
const hasMSA = data.some((a: any) => a.document.type === 'MSA');
if (hasNDA && !hasMSA) setStep(2);
if (hasNDA && hasMSA) setStep(3);
}).catch(console.error);
}
}, [user, navigate]);
useEffect(() => { useEffect(() => {
let intervalId: any; let intervalId: any;
@ -234,10 +191,8 @@ export const OnboardingPage: React.FC = () => {
if (type === 'NDA') setNdaUploadUrl(data.url); if (type === 'NDA') setNdaUploadUrl(data.url);
if (type === 'MSA') setMsaUploadUrl(data.url); if (type === 'MSA') setMsaUploadUrl(data.url);
success("Document uploaded successfully", `Signed ${type} has been uploaded.`); } catch (error) {
} catch (err: any) { console.error(`Failed to upload ${type}:`, error);
console.error(`Failed to upload ${type}:`, err);
toastError("Upload failed", err.response?.data?.error || `Failed to upload signed ${type}.`);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -255,22 +210,14 @@ export const OnboardingPage: React.FC = () => {
documentUrl documentUrl
}); });
success(`${type} Agreement signed`, `Your signature on the ${type} has been registered.`);
if (type === 'NDA') { if (type === 'NDA') {
if (msaSkipped) { setStep(2);
await checkAuth();
setStep(3);
} else {
setStep(2);
}
} else { } else {
await checkAuth(); await checkAuth();
setStep(3); // PENDING_APPROVAL setStep(3); // PENDING_APPROVAL
} }
} catch (err: any) { } catch (error) {
console.error(`Failed to submit ${type}:`, err); console.error(`Failed to submit ${type}:`, error);
toastError("Submission failed", err.response?.data?.error || `Failed to submit signed ${type}.`);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -295,7 +242,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} initialSignature={signature} /> <SignatureCapture onSignatureComplete={setSignature} />
{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">
@ -373,28 +320,24 @@ export const OnboardingPage: React.FC = () => {
</div> </div>
<div className="relative pl-8"> <div className="relative pl-8">
<div className={`absolute left-0 top-1 w-6 h-6 rounded-full flex items-center justify-center shadow-md transition-colors duration-300 ${ndaSkipped || step > 1 ? 'bg-ink-900' : step === 1 ? 'border-2 border-ink-900 bg-ink-0' : 'bg-ink-200'}`}> <div className={`absolute left-0 top-1 w-6 h-6 rounded-full flex items-center justify-center shadow-md transition-colors duration-300 ${step > 1 ? 'bg-ink-900' : step === 1 ? 'border-2 border-ink-900 bg-ink-0' : 'bg-ink-200'}`}>
{(ndaSkipped || step > 1) ? <CheckCircle className="w-3.5 h-3.5 text-ink-0" /> : <div className="w-2 h-2 rounded-full bg-ink-900" />} {step > 1 ? <CheckCircle className="w-3.5 h-3.5 text-ink-0" /> : <div className="w-2 h-2 rounded-full bg-ink-900" />}
</div> </div>
<div className="absolute left-3 top-7 bottom-[-20px] w-0.5 bg-ink-200"> <div className="absolute left-3 top-7 bottom-[-20px] w-0.5 bg-ink-200">
<div className="w-full bg-ink-900/20 transition-all duration-500" style={{ height: (ndaSkipped || step > 1) ? '100%' : '0%' }}></div> <div className="w-full bg-ink-900/20 transition-all duration-500" style={{ height: step > 1 ? '100%' : '0%' }}></div>
</div> </div>
<h3 className={`font-bold transition-colors ${ndaSkipped ? 'text-ink-400 line-through' : step >= 1 ? 'text-ink-900' : 'text-ink-400'}`}> <h3 className={`font-bold transition-colors ${step >= 1 ? 'text-ink-900' : 'text-ink-400'}`}>NDA Agreement</h3>
NDA Agreement {ndaSkipped && <span className="text-[10px] text-ink-400 ml-1 font-normal">(Not Required)</span>}
</h3>
<p className="text-xs font-medium text-ink-500 mt-1">Non-disclosure signature.</p> <p className="text-xs font-medium text-ink-500 mt-1">Non-disclosure signature.</p>
</div> </div>
<div className="relative pl-8"> <div className="relative pl-8">
<div className={`absolute left-0 top-1 w-6 h-6 rounded-full flex items-center justify-center shadow-md transition-colors duration-300 ${msaSkipped || step > 2 ? 'bg-ink-900' : step === 2 ? 'border-2 border-ink-900 bg-ink-0' : 'bg-ink-200'}`}> <div className={`absolute left-0 top-1 w-6 h-6 rounded-full flex items-center justify-center shadow-md transition-colors duration-300 ${step > 2 ? 'bg-ink-900' : step === 2 ? 'border-2 border-ink-900 bg-ink-0' : 'bg-ink-200'}`}>
{(msaSkipped || step > 2) ? <CheckCircle className="w-3.5 h-3.5 text-ink-0" /> : step === 2 ? <div className="w-2 h-2 rounded-full bg-ink-900" /> : <div className="w-2 h-2 rounded-full bg-transparent" />} {step > 2 ? <CheckCircle className="w-3.5 h-3.5 text-ink-0" /> : step === 2 ? <div className="w-2 h-2 rounded-full bg-ink-900" /> : <div className="w-2 h-2 rounded-full bg-transparent" />}
</div> </div>
<div className="absolute left-3 top-7 bottom-[-20px] w-0.5 bg-ink-200"> <div className="absolute left-3 top-7 bottom-[-20px] w-0.5 bg-ink-200">
<div className="w-full bg-ink-900/20 transition-all duration-500" style={{ height: (msaSkipped || step > 2) ? '100%' : '0%' }}></div> <div className="w-full bg-ink-900/20 transition-all duration-500" style={{ height: step > 2 ? '100%' : '0%' }}></div>
</div> </div>
<h3 className={`font-bold transition-colors ${msaSkipped ? 'text-ink-400 line-through' : step >= 2 ? 'text-ink-900' : 'text-ink-400'}`}> <h3 className={`font-bold transition-colors ${step >= 2 ? 'text-ink-900' : 'text-ink-400'}`}>MSA Agreement</h3>
MSA Agreement {msaSkipped && <span className="text-[10px] text-ink-400 ml-1 font-normal">(Not Required)</span>}
</h3>
<p className="text-xs font-medium text-ink-500 mt-1">Master Services Agreement.</p> <p className="text-xs font-medium text-ink-500 mt-1">Master Services Agreement.</p>
</div> </div>
@ -430,9 +373,9 @@ export const OnboardingPage: React.FC = () => {
<Lock className="w-3.5 h-3.5 text-ink-0" /> <Lock className="w-3.5 h-3.5 text-ink-0" />
<span className="text-[10px] font-bold text-ink-0 tracking-widest uppercase">Action Required</span> <span className="text-[10px] font-bold text-ink-0 tracking-widest uppercase">Action Required</span>
</div> </div>
<h2 className="text-3xl login-title font-extrabold tracking-tight mb-2">Non-Disclosure Agreement</h2> <h2 className="text-3xl font-extrabold tracking-tight mb-2">Non-Disclosure Agreement</h2>
<p className="text-sm login-desc text-ink-500 mt-1.5 font-medium max-w-md"> <p className="text-sm font-medium text-ink-500">
Please review the mutual NDA terms carefully before signing. Please provide your signature or upload a signed copy of our standard NDA to proceed.
</p> </p>
</div> </div>
@ -446,7 +389,7 @@ export const OnboardingPage: React.FC = () => {
disabled={(!ndaSignature && !ndaUploadUrl) || isSubmitting} disabled={(!ndaSignature && !ndaUploadUrl) || isSubmitting}
className="flex items-center gap-2 px-8 py-3.5 rounded-xl bg-ink-900 text-ink-0 font-bold hover:bg-ink-800 hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed" className="flex items-center gap-2 px-8 py-3.5 rounded-xl bg-ink-900 text-ink-0 font-bold hover:bg-ink-800 hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
{isSubmitting ? 'Processing...' : msaSkipped ? 'Submit' : 'Continue to MSA'} {isSubmitting ? 'Processing...' : 'Continue to MSA'}
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</button> </button>
</div> </div>
@ -460,9 +403,9 @@ export const OnboardingPage: React.FC = () => {
<FileText className="w-3.5 h-3.5 text-ink-600" /> <FileText className="w-3.5 h-3.5 text-ink-600" />
<span className="text-[10px] font-bold text-ink-700 tracking-widest uppercase">Final Agreement</span> <span className="text-[10px] font-bold text-ink-700 tracking-widest uppercase">Final Agreement</span>
</div> </div>
<h2 className="text-3xl login-title font-extrabold tracking-tight mb-2">Master Services Agreement</h2> <h2 className="text-3xl font-extrabold tracking-tight mb-2">Master Services Agreement</h2>
<p className="text-sm login-desc text-ink-500 mt-1.5 font-medium max-w-md"> <p className="text-sm font-medium text-ink-500">
Please review the master services partnership terms before signing. Sign the MSA to finalize your compliance requirements and enter the approval queue.
</p> </p>
</div> </div>
@ -471,13 +414,9 @@ export const OnboardingPage: React.FC = () => {
{renderDocumentTab('MSA', msaMode, setMsaMode, msaSignature, setMsaSignature, msaUploadUrl, setMsaUploadUrl)} {renderDocumentTab('MSA', msaMode, setMsaMode, msaSignature, setMsaSignature, msaUploadUrl, setMsaUploadUrl)}
<div className="mt-auto pt-6 border-t border-ink-200 flex justify-between items-center"> <div className="mt-auto pt-6 border-t border-ink-200 flex justify-between items-center">
{!ndaSkipped ? ( <button onClick={() => setStep(1)} className="text-sm font-bold text-ink-500 hover:text-ink-900 transition-colors">
<button onClick={() => setStep(1)} className="text-sm font-bold text-ink-500 hover:text-ink-900 transition-colors"> Back to NDA
Back to NDA </button>
</button>
) : (
<div />
)}
<button <button
onClick={() => submitDocument('MSA')} onClick={() => submitDocument('MSA')}
disabled={(!msaSignature && !msaUploadUrl) || isSubmitting} disabled={(!msaSignature && !msaUploadUrl) || isSubmitting}
@ -505,15 +444,11 @@ export const OnboardingPage: React.FC = () => {
<div className="p-6 bg-ink-50 rounded-2xl border border-ink-200 max-w-sm w-full"> <div className="p-6 bg-ink-50 rounded-2xl border border-ink-200 max-w-sm w-full">
<div className="flex justify-between items-center mb-3"> <div className="flex justify-between items-center mb-3">
<span className="text-sm font-medium text-ink-500">NDA Status</span> <span className="text-sm font-medium text-ink-500">NDA Status</span>
<span className="text-xs font-bold text-ink-900 bg-ink-100 px-2 py-1 rounded-md"> <span className="text-xs font-bold text-ink-900 bg-ink-100 px-2 py-1 rounded-md">Signed</span>
{ndaSkipped ? 'Not Required' : 'Signed'}
</span>
</div> </div>
<div className="flex justify-between items-center mb-3"> <div className="flex justify-between items-center mb-3">
<span className="text-sm font-medium text-ink-500">MSA Status</span> <span className="text-sm font-medium text-ink-500">MSA Status</span>
<span className="text-xs font-bold text-ink-900 bg-ink-100 px-2 py-1 rounded-md"> <span className="text-xs font-bold text-ink-900 bg-ink-100 px-2 py-1 rounded-md">Signed</span>
{msaSkipped ? 'Not Required' : 'Signed'}
</span>
</div> </div>
<div className="flex justify-between items-center pt-3 border-t border-ink-200"> <div className="flex justify-between items-center pt-3 border-t border-ink-200">
<span className="text-sm font-medium text-ink-500">Account Access</span> <span className="text-sm font-medium text-ink-500">Account Access</span>

View File

@ -1,518 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
Play,
X,
ExternalLink,
Video,
Maximize2,
Tv,
Monitor,
Sparkles
} from 'lucide-react';
import { PageHeader } from '../components/ui/PageHeader';
import { PageLayout } from '../components/layout/PageLayout';
import { getShowcaseContent } from '../services/ecosystem-api';
import type { ContentShowcase } from '../services/ecosystem-api';
// ── Helper: Extract YouTube video ID ──
const extractYouTubeVideoId = (url: string): string | null => {
try {
const urlObj = new URL(url);
if (urlObj.hostname.includes('youtu.be')) {
return urlObj.pathname.slice(1).split(/[?#]/)[0];
}
if (urlObj.pathname.includes('/shorts/') || urlObj.pathname.includes('/embed/')) {
const parts = urlObj.pathname.split('/');
return parts.pop()?.split(/[?#]/)[0] || null;
}
if (urlObj.searchParams.has('v')) {
return urlObj.searchParams.get('v');
}
} catch (e) {
// Fallback to regex
}
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
/(?:youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) return match[1];
}
return null;
};
// ── Helper: Extract Instagram post/reel ID ──
const extractInstagramId = (url: string): string | null => {
const patterns = [
/instagram\.com\/p\/([a-zA-Z0-9_-]+)/,
/instagram\.com\/reel\/([a-zA-Z0-9_-]+)/,
/instagram\.com\/tv\/([a-zA-Z0-9_-]+)/
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) return match[1];
}
return null;
};
// ── YouTube Player Embed Component ──
const YouTubeEmbed: React.FC<{ videoId: string }> = ({ videoId }) => {
return (
<div className="w-full aspect-video bg-black rounded-2xl overflow-hidden shadow-2xl relative">
<iframe
src={`https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&rel=0&modestbranding=1`}
className="w-full h-full border-0 absolute inset-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
</div>
);
};
// ── Instagram Player Embed Component ──
const InstagramEmbed: React.FC<{ postId: string; size: 'compact' | 'theater' | 'cinema' }> = ({ postId, size }) => {
const maxWidthClass = size === 'compact' ? 'max-w-[360px]' : size === 'theater' ? 'max-w-[420px]' : 'max-w-[480px]';
return (
<div className={`w-full ${maxWidthClass} aspect-[9/16] max-h-full mx-auto bg-ink-950 rounded-2xl overflow-hidden shadow-2xl flex justify-center items-center`}>
<iframe
src={`https://www.instagram.com/p/${postId}/embed`}
className="w-full h-full border-0"
allowFullScreen
scrolling="no"
allow="autoplay; clipboard-write; encrypted-media; picture-in-picture"
/>
</div>
);
};
// ── Twitter/X Embed Component ──
const TwitterEmbed: React.FC<{ url: string }> = ({ url }) => {
useEffect(() => {
if (!document.getElementById('twitter-wjs')) {
const script = document.createElement('script');
script.id = 'twitter-wjs';
script.src = 'https://platform.twitter.com/widgets.js';
script.async = true;
script.charset = 'utf-8';
document.body.appendChild(script);
} else {
try {
(window as any).twttr?.widgets?.load();
} catch (err) {
console.error('Failed to reload twitter widgets:', err);
}
}
}, [url]);
return (
<div className="w-full max-h-full overflow-y-auto flex justify-center bg-white p-4 sm:p-6 rounded-2xl">
<blockquote className="twitter-tweet" data-align="center">
<a href={url}>Loading Tweet...</a>
</blockquote>
</div>
);
};
// ── Collapsible Video Description Component ──
interface VideoDescriptionProps {
text: string;
isExpanded: boolean;
onToggleExpand: () => void;
}
const VideoDescription: React.FC<VideoDescriptionProps> = ({ text, isExpanded, onToggleExpand }) => {
const shouldCollapse = text.length > 120 || text.includes('\n');
return (
<div className="space-y-1">
<p
className={`text-[11px] font-medium text-ink-500 leading-relaxed overflow-hidden ${isExpanded ? '' : 'line-clamp-2'
}`}
style={{ whiteSpace: isExpanded ? 'pre-wrap' : 'normal' }}
>
{text}
</p>
{shouldCollapse && (
<button
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
className="inline-flex items-center text-[10px] font-black uppercase tracking-wider text-blue-600 hover:text-blue-800 transition-colors focus:outline-none cursor-pointer"
>
{isExpanded ? 'Show Less' : 'Show More'}
</button>
)}
</div>
);
};
export const ShowcasePage: React.FC = () => {
const location = useLocation();
const [items, setItems] = useState<ContentShowcase[]>([]);
const [loading, setLoading] = useState(true);
const [playingVideoId, setPlayingVideoId] = useState<string | null>(null);
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const targetId = urlParams.get('highlight') || location.state?.highlightAssetId;
if (targetId && !loading) {
setTimeout(() => {
const el = document.getElementById(`asset-card-${targetId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl', 'transition-all', 'duration-500');
setTimeout(() => {
el.classList.remove('ring-4', 'ring-emerald-500', 'scale-[1.03]', 'shadow-2xl');
}, 5000);
}
}, 300);
}
}, [location.state, location.search, loading]);
// Resizable Lightbox state: compact | theater | cinema
const [lightboxSize, setLightboxSize] = useState<'compact' | 'theater' | 'cinema'>('compact');
useEffect(() => {
const fetchItems = async () => {
try {
const data = await getShowcaseContent();
setItems(data);
} catch (err) {
console.error('Failed to load showcase content:', err);
} finally {
setLoading(false);
}
};
fetchItems();
}, []);
const activeItem = items.find(item => item.id === playingVideoId);
const headerNode = (
<PageHeader
title="Featured Content"
subtitle="Discover interactive walk-throughs, demo reels, and case study updates across all Tech4Biz channels."
/>
);
return (
<PageLayout header={headerNode}>
<div className="p-6 space-y-6">
<div className="flex justify-between items-center border-b border-ink-200 pb-4">
<div className="flex items-center gap-2">
<div className="p-2 rounded-xl bg-blue-500/10 text-blue-600">
<Video className="w-5 h-5" />
</div>
<div>
<h3 className="text-sm font-black uppercase tracking-wider text-ink-900">Media Showcase</h3>
<p className="text-[10px] font-bold text-ink-500 uppercase tracking-widest mt-0.5">YouTube, Instagram & Twitter/X Gallery</p>
</div>
</div>
<div className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">
{items.length} Videos Available
</div>
</div>
{loading ? (
<div className="flex flex-1 items-center justify-center py-20">
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin"></div>
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center border border-dashed border-ink-300 rounded-3xl p-8 bg-ink-50">
<Play className="w-12 h-12 text-ink-300 mb-3" />
<h4 className="text-sm font-black text-ink-900 uppercase tracking-wider">No Video Showcase Content</h4>
<p className="text-xs text-ink-500 mt-1 max-w-sm">There are no featured videos available in the showcase right now. Check back later!</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 animate-fadeIn">
{items.map((item) => {
const ytId = extractYouTubeVideoId(item.youtubeUrl);
const isIg = item.youtubeUrl.includes('instagram.com');
const isTw = item.youtubeUrl.includes('twitter.com') || item.youtubeUrl.includes('x.com');
const thumbnail = item.thumbnailUrl || (ytId ? `https://img.youtube.com/vi/${ytId}/hqdefault.jpg` : null);
const isExpanded = expandedItemId === item.id;
return (
<div key={item.id} id={`asset-card-${item.id}`} className="relative h-[410px] w-full flex flex-col">
<motion.div
layout
draggable={true}
onDragStart={(e: any) => {
const payload = {
id: item.id,
title: item.title,
type: 'case_study',
entityKind: 'SHOWCASE',
url: item.youtubeUrl,
description: item.description,
thumbnailUrl: item.thumbnailUrl,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.setData('text/plain', item.title);
}}
transition={{ type: "spring", stiffness: 320, damping: 28 }}
className={`group flex flex-col bg-ink-0 border rounded-2xl overflow-hidden transition-[border-color,box-shadow,background-color] duration-300 ${isExpanded
? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-350 bg-ink-0'
: 'relative w-full h-full border-ink-200 hover:border-ink-350 hover:shadow-xl'
}`}
>
{/* Video Player / Thumbnail */}
<div className="relative aspect-video bg-ink-900 overflow-hidden shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
const payload = {
id: item.id,
title: item.title,
type: 'case_study',
entityKind: 'SHOWCASE',
url: item.youtubeUrl,
description: item.description,
thumbnailUrl: item.thumbnailUrl,
};
window.dispatchEvent(new CustomEvent('attach-ai-entity', { detail: payload }));
}}
className="absolute top-2 right-2 z-20 px-2 py-1 rounded-md bg-ink-900/90 text-ink-0 hover:bg-ink-950 text-[10px] font-bold shadow-md transition-all hover:scale-105 flex items-center gap-1 border border-ink-700 cursor-pointer"
title="Inspect Reel with AI Advisor Workbench"
>
<Sparkles className="w-3 h-3 text-amber-400 fill-amber-400" />
<span>ask AI</span>
</button>
{thumbnail ? (
<img
src={thumbnail}
alt={item.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
/>
) : (
/* Platform Fallback Gradients */
<div className={`w-full h-full flex items-center justify-center ${isIg
? 'bg-gradient-to-tr from-yellow-500 via-pink-500 to-purple-600'
: isTw
? 'bg-ink-950'
: 'bg-ink-100'
}`}>
{isIg && (
<svg className="w-12 h-12 text-white/90 animate-pulse" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<rect x="2" y="2" width="20" height="20" rx="5" ry="5" />
<path d="M16 11.37A4 4 0 1112.63 8 4 4 0 0116 11.37z" />
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5" />
</svg>
)}
{isTw && (
<svg className="w-12 h-12 text-white/90 animate-pulse" fill="currentColor" viewBox="0 0 24 24">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
)}
{!isIg && !isTw && (
<Play className="w-12 h-12 text-ink-400" />
)}
</div>
)}
{/* Dark gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-ink-900/60 via-transparent to-transparent" />
{/* Play button overlay */}
<button
onClick={() => setPlayingVideoId(item.id)}
className="absolute inset-0 flex items-center justify-center cursor-pointer group/play"
>
<div className="w-14 h-14 rounded-full bg-ink-0/95 backdrop-blur-sm flex items-center justify-center shadow-2xl border border-ink-200/50 group-hover/play:scale-110 group-hover/play:bg-blue-600 group-hover/play:border-blue-500 transition-all duration-300">
<Play className="w-6 h-6 text-ink-900 group-hover/play:text-ink-0 ml-0.5 transition-colors" fill="currentColor" />
</div>
</button>
{/* Platform Tag */}
<div className="absolute bottom-2 right-2 px-2 py-0.5 rounded bg-ink-900/80 text-ink-0 text-[9px] font-bold backdrop-blur-sm">
{isIg ? 'Instagram' : isTw ? 'Twitter / X' : 'YouTube'}
</div>
</div>
{/* Content Info */}
<div className="p-5 flex-grow flex flex-col justify-between space-y-4">
<div className="space-y-2">
<h3 className={`text-sm font-black tracking-tight text-ink-900 leading-snug ${isExpanded ? '' : 'line-clamp-2'}`} title={item.title}>
{item.title}
</h3>
{item.description && (
<VideoDescription
text={item.description}
isExpanded={isExpanded}
onToggleExpand={() => setExpandedItemId(isExpanded ? null : item.id)}
/>
)}
</div>
{/* Redirect CTA */}
{item.redirectUrl && (
<div className="pt-2">
<a
href={item.redirectUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest text-ink-700 hover:text-ink-900 transition-colors"
>
<span>{item.redirectLabel || 'Learn More'}</span>
<ExternalLink className="w-3 h-3" />
</a>
</div>
)}
</div>
</motion.div>
</div>
);
})}
</div>
)}
{/* ── Immersive Lightbox Media Modal ── */}
<AnimatePresence>
{activeItem && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-ink-950/90 backdrop-blur-xl"
onClick={() => setPlayingVideoId(null)}
>
{/* Modal Container */}
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ type: 'spring', damping: 25, stiffness: 250 }}
className={`relative bg-ink-900 border border-ink-800 rounded-3xl overflow-hidden shadow-2xl flex flex-col transition-all duration-300 ${lightboxSize === 'cinema'
? 'w-[95vw] max-w-7xl md:flex-col h-[85vh] md:h-[90vh]'
: lightboxSize === 'theater'
? 'w-[95vw] md:w-[85vw] max-w-6xl md:flex-row h-[85vh] md:max-h-[85vh]'
: 'w-[95vw] md:w-full max-w-4xl md:flex-row h-[85vh] md:max-h-[80vh]'
}`}
onClick={(e) => e.stopPropagation()}
>
{/* Media Container */}
<div className="flex-1 bg-black flex items-center justify-center p-3 sm:p-4 overflow-hidden relative min-h-[180px] sm:min-h-[240px] md:min-h-[300px]">
{(() => {
const ytId = extractYouTubeVideoId(activeItem.youtubeUrl);
if (ytId) {
return <YouTubeEmbed videoId={ytId} />;
}
const igId = extractInstagramId(activeItem.youtubeUrl);
if (igId) {
return <InstagramEmbed postId={igId} size={lightboxSize} />;
}
if (activeItem.youtubeUrl.includes('twitter.com') || activeItem.youtubeUrl.includes('x.com')) {
return <TwitterEmbed url={activeItem.youtubeUrl} />;
}
return (
<div className="text-ink-400 text-xs font-bold p-10">
Unsupported media format. Please visit direct link.
</div>
);
})()}
</div>
{/* Info Container */}
<div className={`p-5 sm:p-6 flex flex-col justify-between border-ink-800 bg-ink-950 overflow-y-auto ${lightboxSize === 'cinema'
? 'w-full border-t h-[40%] md:h-[30%] shrink-0'
: 'w-full md:w-80 border-t md:border-t-0 md:border-l h-[45%] md:h-auto shrink-0'
}`}>
<div className="space-y-4">
{/* Toolbar with Resizer Preset Buttons */}
<div className="flex justify-between items-center pb-2 border-b border-ink-800/60">
<div className="hidden md:flex bg-ink-900 p-0.5 rounded-lg border border-ink-800 gap-0.5">
<button
onClick={() => setLightboxSize('compact')}
className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${lightboxSize === 'compact'
? 'bg-ink-800 text-ink-0'
: 'text-ink-500 hover:text-ink-300'
}`}
title="Compact View"
>
<Monitor className="w-3 h-3" />
<span>Compact</span>
</button>
<button
onClick={() => setLightboxSize('theater')}
className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${lightboxSize === 'theater'
? 'bg-ink-800 text-ink-0'
: 'text-ink-500 hover:text-ink-300'
}`}
title="Theater View"
>
<Tv className="w-3 h-3" />
<span>Theater</span>
</button>
<button
onClick={() => setLightboxSize('cinema')}
className={`p-1.5 rounded text-[9px] font-black uppercase tracking-wider flex items-center gap-1 transition-all cursor-pointer ${lightboxSize === 'cinema'
? 'bg-ink-800 text-ink-0'
: 'text-ink-500 hover:text-ink-300'
}`}
title="Cinema View"
>
<Maximize2 className="w-3 h-3" />
<span>Cinema</span>
</button>
</div>
<button
onClick={() => setPlayingVideoId(null)}
className="text-ink-400 hover:text-ink-0 transition-colors p-1.5 rounded-lg hover:bg-ink-850 cursor-pointer ml-auto md:ml-0"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex justify-between items-start pt-1">
<span className="px-2 py-0.5 rounded bg-ink-800 text-ink-300 text-[9px] font-black uppercase tracking-widest">
{(() => {
if (activeItem.youtubeUrl.includes('youtube.com') || activeItem.youtubeUrl.includes('youtu.be')) return 'YouTube';
if (activeItem.youtubeUrl.includes('instagram.com')) return 'Instagram';
if (activeItem.youtubeUrl.includes('twitter.com') || activeItem.youtubeUrl.includes('x.com')) return 'Twitter / X';
return 'Media';
})()}
</span>
</div>
<h3 className="text-base font-black tracking-tight text-ink-0 leading-snug">
{activeItem.title}
</h3>
{activeItem.description && (
<div className="max-h-60 md:max-h-none overflow-y-auto pr-1">
<p className="text-xs font-medium text-ink-400 leading-relaxed whitespace-pre-wrap">
{activeItem.description}
</p>
</div>
)}
</div>
{activeItem.redirectUrl && (
<div className="pt-6 border-t border-ink-800 mt-6">
<a
href={activeItem.redirectUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-xl text-xs font-black uppercase tracking-wider bg-ink-0 text-ink-900 hover:bg-ink-100 hover:shadow-lg transition-all duration-300 cursor-pointer"
>
<span>{activeItem.redirectLabel || 'Explore More'}</span>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
</PageLayout>
);
};
export default ShowcasePage;

View File

@ -1,72 +1,18 @@
import React, { useState } from "react"; import React from 'react';
import { CheckCircle, Clock, Search, XCircle, Eye } from "lucide-react"; import { CheckCircle, Clock, Search, XCircle } 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 { useToast } from "../../hooks/use-toast";
import { DocumentPreviewModal } from "../../components/ui/DocumentPreviewModal";
import { PageLayout } from "../../components/layout/PageLayout";
export const ApprovalsPage: React.FC = () => { export const ApprovalsPage: React.FC = () => {
const { success, error } = useToast();
const { data: partners = [], isLoading } = usePendingPartnersQuery(); const { data: partners = [], isLoading } = usePendingPartnersQuery();
const approveMutation = useApprovePartnerMutation(); const approveMutation = useApprovePartnerMutation();
const [searchTerm, setSearchTerm] = useState("");
// Preview Modal state const approvePartner = (partnerId: string) => {
const [selectedPartner, setSelectedPartner] = useState<any | null>(null); approveMutation.mutate(partnerId);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [verifiedDocs, setVerifiedDocs] = useState<
Record<string, { nda: boolean; msa: boolean }>
>({});
const approvePartner = (partnerId: string, email: string) => {
approveMutation.mutate(partnerId, {
onSuccess: () => {
success(
"Partner access approved",
`Access has been granted to ${email}.`,
);
setIsPreviewOpen(false);
setSelectedPartner(null);
},
onError: (err: any) => {
error(
"Approval failed",
err.response?.data?.error || "Could not approve partner access.",
);
},
});
}; };
const handleOpenPreview = (partner: any, _docType: "NDA" | "MSA") => {
setSelectedPartner(partner);
setIsPreviewOpen(true);
};
const handleVerify = (partnerId: string, docType: "NDA" | "MSA") => {
setVerifiedDocs((prev) => {
const partnerStatus = prev[partnerId] || { nda: false, msa: false };
return {
...prev,
[partnerId]: {
...partnerStatus,
[docType === "NDA" ? "nda" : "msa"]: true,
},
};
});
success(
`${docType} document signature verified.`,
"Verification status updated.",
);
};
const filteredPartners = partners.filter((p) =>
p.email.toLowerCase().includes(searchTerm.toLowerCase()),
);
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex-1 flex items-center justify-center min-h-[60vh]"> <div className="flex-1 flex items-center justify-center min-h-[60vh]">
@ -75,254 +21,133 @@ export const ApprovalsPage: React.FC = () => {
); );
} }
// Header component return (
const headerNode = ( <div className="w-full space-y-6 animate-fade-in text-ink-900">
<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>
}
actions={
<div className="relative">
<Search className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="Search pending partners..."
className="pl-9 pr-4 py-1.5 bg-ink-0 border border-ink-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 w-full sm:w-64 shadow-sm"
/>
</div>
}
/>
// Toolbar component {/* List */}
const toolbarNode = ( <div className="bg-ink-0 rounded-xl border border-ink-200 overflow-hidden shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3.5 p-3.5 bg-ink-0 border border-ink-200 rounded-xl shadow-sm"> <div className="overflow-x-auto">
<div className="flex items-center gap-2 flex-1 max-w-md"> <table className="w-full text-left text-sm whitespace-nowrap">
<div className="relative flex-1"> <thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs">
<Search className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" /> <tr>
<input <th className="px-4 py-2.5">Partner</th>
type="text" <th className="px-4 py-2.5">NDA Status</th>
value={searchTerm} <th className="px-4 py-2.5">MSA Status</th>
onChange={(e) => setSearchTerm(e.target.value)} <th className="px-4 py-2.5 text-right">Actions</th>
placeholder="Search pending partners by email..." </tr>
className="w-full pl-9 pr-4 py-1.5 bg-ink-50 border border-ink-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-semibold" </thead>
/> <tbody className="divide-y divide-ink-200">
<AnimatePresence>
{partners.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center">
<div className="w-12 h-12 rounded-full bg-ink-100 flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-6 h-6 text-ink-900" />
</div>
<p className="text-ink-900 font-bold text-sm">Queue is empty</p>
<p className="text-ink-500 text-xs mt-1">All partners have been reviewed.</p>
</td>
</tr>
) : (
partners.map(partner => {
const nda = partner.acceptances.find(a => a.document.type === 'NDA');
const msa = partner.acceptances.find(a => a.document.type === 'MSA');
return (
<motion.tr
key={partner.id}
initial={{ opacity: 1 }}
exit={{ opacity: 0, x: -20, backgroundColor: 'rgba(0, 0, 0, 0.02)' }}
className="hover:bg-ink-50 transition-colors group"
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 font-bold text-xs shadow-md">
{partner.email.charAt(0).toUpperCase()}
</div>
<div>
<p className="font-bold text-ink-900 text-sm">{partner.email}</p>
<p className="text-xs text-ink-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(partner.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</td>
<td className="px-4 py-3">
{nda ? (
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-ink-900" />
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
{nda.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
</span>
{nda.documentUrl && (
<a href={nda.documentUrl} target="_blank" rel="noreferrer" className="text-xs font-bold text-ink-600 hover:text-ink-900 underline ml-2">View</a>
)}
</div>
) : (
<div className="flex items-center gap-2 text-ink-400">
<XCircle className="w-4 h-4" />
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">Missing</span>
</div>
)}
</td>
<td className="px-4 py-3">
{msa ? (
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-ink-900" />
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
{msa.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
</span>
{msa.documentUrl && (
<a href={msa.documentUrl} target="_blank" rel="noreferrer" className="text-xs font-bold text-ink-600 hover:text-ink-900 underline ml-2">View</a>
)}
</div>
) : (
<div className="flex items-center gap-2 text-ink-400">
<XCircle className="w-4 h-4" />
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">Missing</span>
</div>
)}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => approvePartner(partner.id)}
disabled={!nda || !msa || (approveMutation.isPending && approveMutation.variables === partner.id)}
className="px-3 py-1.5 bg-ink-900 text-ink-0 text-xs font-bold rounded-lg hover:bg-ink-800 transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{approveMutation.isPending && approveMutation.variables === partner.id ? 'Approving...' : 'Approve Access'}
</button>
</td>
</motion.tr>
);
})
)}
</AnimatePresence>
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
); );
return (
<PageLayout header={headerNode} toolbar={toolbarNode}>
{/* List Container */}
<div className="flex-1 w-full overflow-x-auto">
<table className="w-full text-left text-sm whitespace-nowrap">
<thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs sticky top-0 z-10">
<tr>
<th className="px-5 py-3">Partner</th>
<th className="px-5 py-3">NDA Document</th>
<th className="px-5 py-3">MSA Document</th>
<th className="px-5 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-200 bg-ink-0">
<AnimatePresence>
{filteredPartners.length === 0 ? (
<tr>
<td colSpan={4} className="px-5 py-12 text-center">
<div className="w-12 h-12 rounded-full bg-ink-50 flex items-center justify-center mx-auto mb-4 border border-ink-200">
<CheckCircle className="w-6 h-6 text-ink-900" />
</div>
<p className="text-ink-900 font-bold text-sm">
Queue is empty
</p>
<p className="text-ink-500 text-xs mt-1">
All partners have been reviewed.
</p>
</td>
</tr>
) : (
filteredPartners.map((partner) => {
const nda = partner.acceptances.find(
(a) => a.document.type === "NDA",
);
const msa = partner.acceptances.find(
(a) => a.document.type === "MSA",
);
const partnerVerified = verifiedDocs[partner.id] || {
nda: false,
msa: false,
};
return (
<motion.tr
key={partner.id}
initial={{ opacity: 1 }}
exit={{
opacity: 0,
x: -20,
backgroundColor: "rgba(0, 0, 0, 0.02)",
}}
className="hover:bg-ink-50 transition-colors group"
>
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 font-bold text-xs shadow-md">
{partner.email.charAt(0).toUpperCase()}
</div>
<div>
<p className="font-bold text-ink-900 text-sm">
{partner.email}
</p>
<p className="text-xs text-ink-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(partner.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</td>
<td className="px-5 py-4">
{nda ? (
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-600" />
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
{nda.documentUrl
? "Uploaded PDF"
: "Digital Sign"}
</span>
{nda.signatureBase64 && (
<div className="w-12 h-6 border border-ink-200 rounded bg-white flex items-center justify-center p-0.5 shadow-sm overflow-hidden shrink-0 ml-1" title="Drawn Signature">
<img
src={nda.signatureBase64}
alt="NDA Signature"
className="max-w-full max-h-full object-contain"
style={{ filter: 'brightness(0)' }}
/>
</div>
)}
<button
onClick={() => handleOpenPreview(partner, "NDA")}
className="inline-flex items-center gap-1.5 text-xs font-bold text-ink-700 hover:text-ink-900 cursor-pointer ml-2 bg-ink-50 hover:bg-ink-100 border border-ink-200 px-2.5 py-1 rounded-lg transition-all"
>
<Eye className="w-3.5 h-3.5" />
<span>Preview</span>
</button>
{partnerVerified.nda && (
<span className="text-[10px] text-emerald-600 font-bold uppercase tracking-wider ml-1 bg-emerald-50 px-1.5 py-0.5 rounded border border-emerald-200">
Verified
</span>
)}
</div>
) : !partner.assignedNdaId ? (
<div className="flex items-center gap-2 text-ink-500">
<CheckCircle className="w-4 h-4 text-ink-400" />
<span className="text-xs font-semibold bg-ink-50 border border-ink-200 text-ink-500 px-2 py-0.5 rounded-md">
Not Required
</span>
</div>
) : (
<div className="flex items-center gap-2 text-ink-400">
<XCircle className="w-4 h-4" />
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">
Missing
</span>
</div>
)}
</td>
<td className="px-5 py-4">
{msa ? (
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-600" />
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
{msa.documentUrl
? "Uploaded PDF"
: "Digital Sign"}
</span>
{msa.signatureBase64 && (
<div className="w-12 h-6 border border-ink-200 rounded bg-white flex items-center justify-center p-0.5 shadow-sm overflow-hidden shrink-0 ml-1" title="Drawn Signature">
<img
src={msa.signatureBase64}
alt="MSA Signature"
className="max-w-full max-h-full object-contain"
style={{ filter: 'brightness(0)' }}
/>
</div>
)}
<button
onClick={() => handleOpenPreview(partner, "MSA")}
className="inline-flex items-center gap-1.5 text-xs font-bold text-ink-700 hover:text-ink-900 cursor-pointer ml-2 bg-ink-50 hover:bg-ink-100 border border-ink-200 px-2.5 py-1 rounded-lg transition-all"
>
<Eye className="w-3.5 h-3.5" />
<span>Preview</span>
</button>
{partnerVerified.msa && (
<span className="text-[10px] text-emerald-600 font-bold uppercase tracking-wider ml-1 bg-emerald-50 px-1.5 py-0.5 rounded border border-emerald-200">
Verified
</span>
)}
</div>
) : !partner.assignedMsaId ? (
<div className="flex items-center gap-2 text-ink-500">
<CheckCircle className="w-4 h-4 text-ink-400" />
<span className="text-xs font-semibold bg-ink-50 border border-ink-200 text-ink-500 px-2 py-0.5 rounded-md">
Not Required
</span>
</div>
) : (
<div className="flex items-center gap-2 text-ink-400">
<XCircle className="w-4 h-4" />
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">
Missing
</span>
</div>
)}
</td>
<td className="px-5 py-4 text-right">
<Button
onClick={() =>
approvePartner(partner.id, partner.email)
}
disabled={
approveMutation.isPending &&
approveMutation.variables === partner.id
}
variant="primary"
size="sm"
>
{approveMutation.isPending &&
approveMutation.variables === partner.id
? "Approving..."
: "Approve Access"}
</Button>
</td>
</motion.tr>
);
})
)}
</AnimatePresence>
</tbody>
</table>
</div>
{/* Document Preview Modal */}
{selectedPartner && (
<DocumentPreviewModal
isOpen={isPreviewOpen}
onClose={() => {
setIsPreviewOpen(false);
setSelectedPartner(null);
}}
partnerId={selectedPartner.id}
partnerEmail={selectedPartner.email}
partnerCreatedAt={selectedPartner.createdAt}
acceptances={selectedPartner.acceptances}
verifiedDocs={
verifiedDocs[selectedPartner.id] || { nda: false, msa: false }
}
onVerify={(docType) => handleVerify(selectedPartner.id, docType)}
onApprovePartner={() =>
approvePartner(selectedPartner.id, selectedPartner.email)
}
isApproving={
approveMutation.isPending &&
approveMutation.variables === selectedPartner.id
}
assignedNdaId={selectedPartner.assignedNdaId}
assignedMsaId={selectedPartner.assignedMsaId}
/>
)}
</PageLayout>
);
}; };
export default ApprovalsPage; export default ApprovalsPage;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,351 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Folder, File, Tag, Trash2, Plus, Search, CheckCircle } from 'lucide-react';
import { getAssetGroups, getAssets, addAssetsToGroup, removeAssetsFromGroup } from '../../services/assets-api';
import type { AssetGroup } from '../../services/assets-api';
import type { Asset } from '../../types/assets';
import PageHeader from '../../components/ui/PageHeader';
import { PageLayout } from '../../components/layout/PageLayout';
import Button from '../../components/ui/Button';
import Modal from '../../components/ui/Modal';
import { useToast } from '../../hooks/use-toast';
export const GroupDetailsPage: React.FC = () => {
const { groupId } = useParams<{ groupId: string }>();
const navigate = useNavigate();
const { success, error } = useToast();
const [group, setGroup] = useState<AssetGroup | null>(null);
const [allAssets, setAllAssets] = useState<Asset[]>([]);
const [loading, setLoading] = useState(true);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [assetToRemove, setAssetToRemove] = useState<{ id: string, title: string } | null>(null);
const loadData = async () => {
if (!groupId) return;
try {
const [groups, assets] = await Promise.all([
getAssetGroups(),
getAssets()
]);
const found = groups.find(g => g.id === groupId);
setGroup(found || null);
setAllAssets(assets);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
setLoading(true);
loadData();
}, [groupId]);
const handleRemoveAsset = (assetId: string, assetTitle: string) => {
setAssetToRemove({ id: assetId, title: assetTitle });
};
const handleAddAssets = async () => {
if (!groupId || selectedAssetIds.length === 0) return;
try {
await addAssetsToGroup(groupId, selectedAssetIds);
success('Assets added', `Successfully added ${selectedAssetIds.length} assets to the bundle.`);
setSelectedAssetIds([]);
setIsAddModalOpen(false);
loadData();
} catch (err: any) {
error('Failed to add assets', err.response?.data?.error || 'Something went wrong.');
}
};
const existingAssetIds = new Set(group?.assets.map(a => a.id) || []);
const addableAssets = allAssets.filter(a => !existingAssetIds.has(a.id));
const filteredAddableAssets = addableAssets.filter(a =>
a.title.toLowerCase().includes(searchQuery.toLowerCase())
);
const getFileIcon = (type: string, title: string) => {
const lowerType = type.toLowerCase();
const lowerTitle = title.toLowerCase();
if (lowerType.includes('pdf') || lowerTitle.endsWith('.pdf')) {
return (
<div className="w-10 h-10 rounded-xl bg-red-500/10 border border-red-500/20 flex items-center justify-center text-red-650 shrink-0">
<span className="text-[10px] font-bold">PDF</span>
</div>
);
}
if (lowerType.includes('word') || lowerTitle.endsWith('.docx') || lowerTitle.endsWith('.doc')) {
return (
<div className="w-10 h-10 rounded-xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-650 shrink-0">
<span className="text-[10px] font-bold">DOC</span>
</div>
);
}
if (lowerType.includes('presentation') || lowerTitle.endsWith('.pptx') || lowerTitle.endsWith('.ppt')) {
return (
<div className="w-10 h-10 rounded-xl bg-orange-500/10 border border-orange-500/20 flex items-center justify-center text-orange-650 shrink-0">
<span className="text-[10px] font-bold">PPT</span>
</div>
);
}
return (
<div className="w-10 h-10 rounded-xl bg-ink-500/10 border border-ink-500/20 flex items-center justify-center text-ink-600 shrink-0">
<span className="text-[10px] font-bold">URL</span>
</div>
);
};
const headerNode = (
<div className="flex flex-col gap-2">
<button
onClick={() => navigate('/admin/assets')}
className="flex items-center gap-1 text-[11px] font-bold text-ink-500 hover:text-ink-900 transition-colors uppercase tracking-wider mb-2 cursor-pointer w-fit"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Back to Asset Library</span>
</button>
<PageHeader
title={group ? `Bundle: ${group.name}` : 'Asset Bundle'}
subtitle={group?.description || 'View and inspect the assets stored in this bundle.'}
badge={
<div className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-ink-100 border border-ink-200 text-[10px] font-bold text-ink-900 tracking-wider uppercase">
<Folder className="w-3.5 h-3.5 text-ink-800" />
<span>{group?.assets?.length || 0} Assets</span>
</div>
}
/>
</div>
);
return (
<PageLayout header={headerNode}>
<div className="p-5 flex-1 min-h-0 overflow-y-auto">
{loading ? (
<div className="py-20 flex justify-center items-center">
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin" />
</div>
) : !group ? (
<div className="py-16 text-center bg-ink-0 border border-ink-200 rounded-xl">
<Folder className="w-12 h-12 text-ink-300 mx-auto mb-4" />
<h3 className="text-lg font-bold text-ink-900">Group not found</h3>
<p className="text-ink-500 text-sm mt-1">
The asset group you are looking for does not exist or has been deleted.
</p>
<Button
onClick={() => navigate('/admin/assets')}
variant="primary"
size="sm"
className="mt-4"
>
Return to Catalog
</Button>
</div>
) : (
<div className="space-y-6">
<div className="bg-ink-0 border border-ink-200 rounded-xl p-5 shadow-sm">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xs font-bold text-ink-500 uppercase tracking-wider">
Assets in this Bundle
</h3>
<Button
onClick={() => setIsAddModalOpen(true)}
variant="primary"
size="xs"
className="flex items-center gap-1"
>
<Plus className="w-3 h-3" />
<span>Add Assets</span>
</Button>
</div>
{group.assets.length === 0 ? (
<div className="py-12 text-center border border-dashed border-ink-200 rounded-xl bg-ink-50/50">
<File className="w-10 h-10 text-ink-300 mx-auto mb-2" />
<p className="text-xs font-bold text-ink-900">This bundle is currently empty</p>
<p className="text-[10px] text-ink-450 mt-1">Add assets to this group using the "Add Assets" button above.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{group.assets.map(asset => (
<div
key={asset.id}
className="p-4 bg-ink-50/40 border border-ink-200 rounded-xl shadow-sm hover:border-ink-350 transition-all flex items-start gap-4"
>
{getFileIcon(asset.type, asset.title)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-xs font-bold text-ink-900 truncate">{asset.title}</p>
{asset.isDownloadable && (
<span className="shrink-0 text-[8px] font-bold uppercase tracking-wider px-1.5 py-0.2 rounded bg-green-500/10 text-green-600 border border-green-500/20">
Downloadable
</span>
)}
</div>
{asset.description && (
<p className="text-[10px] text-ink-550 mt-1 line-clamp-2 leading-relaxed">
{asset.description}
</p>
)}
<div className="flex flex-wrap gap-2 mt-3 items-center text-[9px] text-ink-450 font-bold">
<div className="flex items-center gap-1 bg-ink-100 px-1.5 py-0.5 rounded border border-ink-200 text-ink-700">
<Tag className="w-3 h-3" />
<span className="uppercase">{asset.categoryId || 'General'}</span>
</div>
{asset.subcategory && (
<div className="bg-ink-50 px-1.5 py-0.5 rounded border border-ink-150">
{asset.subcategory}
</div>
)}
</div>
</div>
<button
onClick={() => handleRemoveAsset(asset.id, asset.title)}
className="p-1.5 text-red-500 hover:text-red-750 hover:bg-red-500/10 rounded-lg transition-colors cursor-pointer shrink-0"
title="Remove from Bundle"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
{/* Add Assets Modal */}
<Modal
isOpen={isAddModalOpen}
onClose={() => {
setIsAddModalOpen(false);
setSelectedAssetIds([]);
setSearchQuery('');
}}
title="Add Assets to Bundle"
subtitle={`Select assets to add to "${group?.name}"`}
size="md"
>
<div className="space-y-4">
<div className="relative">
<Search className="w-4 h-4 text-ink-450 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search catalog assets..."
className="w-full bg-ink-50 border border-ink-200 rounded-lg pl-9 pr-4 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-semibold"
/>
</div>
<div className="border border-ink-200 rounded-xl overflow-hidden max-h-[300px] overflow-y-auto divide-y divide-ink-200 bg-ink-0">
{filteredAddableAssets.length === 0 ? (
<div className="p-8 text-center text-xs text-ink-400 font-medium">
No new assets available to add
</div>
) : (
filteredAddableAssets.map(asset => {
const isSelected = selectedAssetIds.includes(asset.id);
return (
<div
key={asset.id}
onClick={() => {
setSelectedAssetIds(prev =>
prev.includes(asset.id) ? prev.filter(x => x !== asset.id) : [...prev, asset.id]
);
}}
className={`flex items-center gap-3 p-3 text-xs font-semibold cursor-pointer transition-all hover:bg-ink-50 ${
isSelected ? 'bg-ink-50/70' : ''
}`}
>
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-all ${
isSelected
? 'border-ink-900 bg-ink-900 text-ink-0'
: 'border-ink-200 bg-ink-50'
}`}>
{isSelected && <CheckCircle className="w-3 h-3 stroke-[3]" />}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-ink-900">{asset.title}</p>
<span className="text-[9px] text-ink-450 uppercase tracking-wider">
{asset.type === 'case_study' ? 'Case Study' : asset.type === 'url' ? 'External Link' : asset.categoryId || 'General'}
</span>
</div>
</div>
);
})
)}
</div>
<div className="flex gap-3 justify-end pt-2">
<Button
onClick={() => {
setIsAddModalOpen(false);
setSelectedAssetIds([]);
setSearchQuery('');
}}
variant="secondary"
size="sm"
>
Cancel
</Button>
<Button
onClick={handleAddAssets}
variant="primary"
size="sm"
disabled={selectedAssetIds.length === 0}
>
Add {selectedAssetIds.length} Asset{selectedAssetIds.length !== 1 ? 's' : ''}
</Button>
</div>
</div>
</Modal>
{/* Remove Confirmation Modal */}
<Modal
isOpen={assetToRemove !== null}
onClose={() => setAssetToRemove(null)}
title="Remove Asset from Bundle"
subtitle="Confirm you want to remove this resource."
size="sm"
>
<div className="space-y-4 font-sans">
<p className="text-xs text-ink-600 leading-relaxed">
Are you sure you want to remove <span className="font-bold text-ink-900">"{assetToRemove?.title}"</span> from this asset bundle?
</p>
<div className="flex gap-3 justify-end pt-2">
<Button
onClick={() => setAssetToRemove(null)}
variant="secondary"
size="sm"
>
Cancel
</Button>
<Button
onClick={async () => {
if (!assetToRemove || !groupId) return;
try {
await removeAssetsFromGroup(groupId, [assetToRemove.id]);
success('Asset removed', `"${assetToRemove.title}" was removed from the bundle.`);
setAssetToRemove(null);
loadData();
} catch (err: any) {
error('Failed to remove asset', err.response?.data?.error || 'Something went wrong.');
}
}}
variant="danger"
size="sm"
>
Remove Asset
</Button>
</div>
</div>
</Modal>
</PageLayout>
);
};

Some files were not shown because too many files have changed in this diff Show More