Compare commits

...

53 Commits

Author SHA1 Message Date
kenilkb
7136741686 Deployable_3 2026-07-30 12:31:37 +05:30
kenilkb
d79b691167 Added thumbnail 2026-07-30 12:10:53 +05:30
kenilkb
673d3d5c13 Added thumbnail 2026-07-30 11:34:37 +05:30
kenilkb
c298b557c6 cleaned_prod 2026-07-27 09:30:05 +05:30
kenilkb
61126a546a Production 2026-07-24 17:35:07 +05:30
kenilkb
73b1d35754 Chatbot_phase2_2407_improvised 2026-07-24 17:24:03 +05:30
kenilkb
4715e1b482 Chatbot_phase2_2407 2026-07-24 17:05:44 +05:30
kenilkb
4256143939 Chatbot_phase1_2307 2026-07-23 20:18:28 +05:30
kenilkb
67acafd245 Chatbot_highlight 2026-07-22 16:59:45 +05:30
kenilkb
a127b5ffc4 Valid_filters_and_texanomy 2026-07-22 12:33:24 +05:30
kenilkb
61f537057b Asset Management & seach, filter funationalities 2026-07-21 14:11:41 +05:30
kenilkb
b2853a952b fixed_discripancies_observed 2026-07-17 17:46:48 +05:30
kenilkb
9646309a0a Refactor showcase cards to use absolute grid overlay expansion system 2026-07-17 16:21:29 +05:30
kenilkb
a1626a5c20 Responsive mobile layout fix for showcase lightbox 2026-07-17 15:44:22 +05:30
kenilkb
29871cf834 Video_assets 2026-07-17 15:05:23 +05:30
kenilkb
9db9e14328 Fix unused imports to resolve production build errors 2026-07-17 10:28:52 +05:30
kenilkb
8687d72394 production V2 2026-07-17 10:18:36 +05:30
kenilkb
61f3bd8057 Explore_Ecosystem 2026-07-16 17:27:28 +05:30
kenilkb
9e4c9cc323 asset_recommendation 2026-07-16 15:08:21 +05:30
kenilkb
c0c0e1a327 Feature: Add Share-all-assets checkbox to admin dialogs, group tags, and visual Recommended flags on cards 2026-07-16 13:19:50 +05:30
kenilkb
9170b607fe Fix: Sync Apply Group buttons with text input and automatically authorize group assets in backend getAssets query 2026-07-16 12:52:26 +05:30
kenilkb
ed60948be4 Feat: Add multi-group support for partner recommendations, column asset counts, and detail modal lists 2026-07-16 12:36:17 +05:30
kenilkb
fa781d08f5 Feat: Auto-check all assets by default, show unified asset count in table, display read-only inherited assets in modal 2026-07-16 12:30:11 +05:30
kenilkb
d30d8d6b1e Feat: Implement curated recommendations engine matching partnerGroup to AssetGroups 2026-07-16 12:04:56 +05:30
kenilkb
5254e347a8 Docs: Untrack documentation markdown files and add them to gitignore 2026-07-16 10:57:29 +05:30
kenilkb
643bb1ce68 Docs: Create project rules.md, architecture.md, memory.md, and phases.md documentation 2026-07-16 10:54:19 +05:30
kenilkb
2d730c8389 Fix: Add CSV to card isRenderable preview check and resolve dark mode white text color inheritance in spreadsheet grids 2026-07-16 10:39:08 +05:30
kenilkb
cf21405c53 Fix: Make Docx and Spreadsheet thumbnails scale responsively to full bleed width 2026-07-16 10:26:38 +05:30
kenilkb
7c6eebb151 Fix: Exclude CSV files from text preview loader so they render as interactive spreadsheets 2026-07-16 10:09:29 +05:30
kenilkb
4da31c52c0 Feat: Implement S3 object deletion when deleting an asset record 2026-07-16 10:00:34 +05:30
kenilkb
6d694efeb0 Feat: Add client-side dynamic thumbnail generators for DOCX and Spreadsheet assets 2026-07-16 09:58:39 +05:30
kenilkb
63b3835ecf Feat: Implement dynamic, interactive client-side document previews for Word, Excel, and CSV in AssetViewerModal 2026-07-16 09:56:07 +05:30
kenilkb
6a22f954c7 Feat: Implement dynamic, full-bleed mockup previews for presentation, spreadsheet, and word documents 2026-07-16 09:48:52 +05:30
kenilkb
e6d3d07fd4 Feat: Redesign URL asset viewer preview with browser mockup & access guide 2026-07-16 09:41:22 +05:30
kenilkb
c32c4b9499 Feat: Implement dynamic webpage screenshot thumbnails for URL assets 2026-07-16 09:31:03 +05:30
kenilkb
5ec1faa1f9 Style: Anchor PDF thumbnail to the top using object-top 2026-07-16 09:29:12 +05:30
kenilkb
33e6a658f4 Feat: Add dynamic client-side PDF first-page thumbnail rendering using PDF.js 2026-07-16 09:25:11 +05:30
kenilkb
ea7d688f7c Refactor: Add Open in New Tab for PDFs/Docs, upgrade details modal to 2-column lg, and enhance document cover thumbnails 2026-07-15 18:23:19 +05:30
kenilkb
e7f50a6667 Refactor: Add metadata and thumbnail scraping to External URL tab 2026-07-15 14:57:45 +05:30
kenilkb
c9ec1296a6 Refactor: Add custom deletion confirmation modal in AssetsPage 2026-07-15 14:52:09 +05:30
kenilkb
9c19ca9dd7 Final_prod 2026-07-15 14:28:24 +05:30
kenilkb
765c469975 checkpoint 2026-07-13 11:33:39 +05:30
kenilkb
8bb20a99aa Final_changes 2026-07-10 20:11:26 +05:30
kenilkb
7bd5f9f0e4 mobile_responsiveness_adjustments_tables_grids_and_sidebars 2026-07-10 19:47:46 +05:30
kenilkb
dd4ffff3cc dashboard_remove_client_stats_cards_and_reflow_grid 2026-07-10 19:44:53 +05:30
kenilkb
8ae446eadc typography_increase_heading_subheading_card_font_sizes_by_30_percent 2026-07-10 19:44:35 +05:30
kenilkb
0ef06cf755 fonts_use_self_hosted_fontsource_inter_variable 2026-07-10 19:41:43 +05:30
kenilkb
4efbd49c72 configure_vite_proxy_and_relative_api_urls_for_ngrok_compatibility 2026-07-10 19:05:29 +05:30
kenilkb
741705f90a update_peacock_branding_logo 2026-07-10 19:01:26 +05:30
kenilkb
9281a9d384 implement_bulk_asset_sharing 2026-07-10 18:13:08 +05:30
kenilkb
b7efb22ed1 integrate_minio_seeding_and_docs 2026-07-10 17:47:48 +05:30
a87fd01ec8 implement blog management system with CRUD operations, API routes, and markdown viewer component 2026-07-10 16:57:33 +05:30
51c7d280b0 implement global UI components including Toast notification system, PageLayout, and Modal utilities while migrating existing pages to the new layout architecture 2026-07-10 09:16:01 +05:30
115 changed files with 21139 additions and 3646 deletions

13
.gitignore vendored
View File

@ -29,3 +29,16 @@ 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

@ -1,95 +0,0 @@
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,22 +6,29 @@
"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": {
@ -32,6 +39,8 @@
"@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,6 +24,19 @@ 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 {
@ -36,14 +49,28 @@ 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 {
@ -62,23 +89,104 @@ model Asset {
githubUrl String? githubUrl String?
status String @default("published") status String @default("published")
isDownloadable Boolean @default(true) isDownloadable Boolean @default(true)
thumbnailUrl String?
problemStatement String? @db.Text
solution String? @db.Text
contentType String?
includeInKnowledgeBase Boolean @default(true)
knowledgeScope String? @default("CATALOG_SHARED")
folderId String? folderId String?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) 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[] sharedWith SharedAsset[]
downloadRequests DownloadRequest[] downloadRequests DownloadRequest[]
assetGroups AssetGroup[]
embeddings AssetEmbedding[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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 {
id String @id @default(uuid()) id String @id @default(uuid())
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])
} }
@ -114,6 +222,8 @@ model LegalDocument {
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 {
@ -123,6 +233,7 @@ model LegalAcceptance {
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])
@ -137,3 +248,80 @@ 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

@ -0,0 +1,148 @@
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

@ -0,0 +1,87 @@
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);
});

View File

@ -1,26 +0,0 @@
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,6 +2,7 @@ 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...');
@ -73,67 +74,7 @@ async function seed() {
console.log(`Admin User already exists: ${adminEmail}`); console.log(`Admin User already exists: ${adminEmail}`);
} }
// 4. Seed Partner 1: Awaiting onboarding // 4. Seed Partner: Fully active & approved
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) {
@ -174,6 +115,92 @@ 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,8 +15,14 @@ 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;
@ -27,7 +33,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"], "frame-ancestors": ["'self'", "http://localhost:5173", "http://localhost:5000", "https://*.ngrok-free.dev", "https://*.ngrok.io"],
}, },
}, },
frameguard: false, frameguard: false,
@ -37,6 +43,30 @@ 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 {
@ -80,8 +110,13 @@ 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

@ -0,0 +1,29 @@
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,21 +3,43 @@ 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 isUrlAsset = req.body.isUrlAsset === 'true' || req.body.isUrlAsset === true || req.body.type === 'url'; const files = req.files as { [fieldname: string]: Express.Multer.File[] } | undefined;
const assetFile = files?.file?.[0] || req.file;
const thumbnailFile = files?.thumbnail?.[0];
if (!isUrlAsset && !req.file) { const isUrlAsset = req.body.isUrlAsset === 'true' || req.body.isUrlAsset === true || req.body.type === 'url' || req.body.type === 'case_study';
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()) {
@ -25,15 +47,15 @@ export class AssetController {
} }
let fileUrl = ''; let fileUrl = '';
if (!isUrlAsset && req.file) { if (!isUrlAsset && assetFile) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const filename = uniqueSuffix + path.extname(req.file.originalname); const filename = uniqueSuffix + path.extname(assetFile.originalname);
await s3Client.send(new PutObjectCommand({ await s3Client.send(new PutObjectCommand({
Bucket: BUCKET_NAME, Bucket: BUCKET_NAME,
Key: filename, Key: filename,
Body: req.file.buffer, Body: assetFile.buffer,
ContentType: req.file.mimetype, ContentType: assetFile.mimetype,
})); }));
fileUrl = `/uploads/${filename}`; fileUrl = `/uploads/${filename}`;
@ -41,10 +63,42 @@ 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 || (req.file ? req.file.originalname : 'URL Asset'), title: req.body.title || (assetFile ? assetFile.originalname : 'URL Asset'),
type: isUrlAsset ? 'url' : req.file!.mimetype, type: req.body.type || (isUrlAsset ? 'url' : assetFile!.mimetype),
size: isUrlAsset ? 0 : req.file!.size, size: isUrlAsset ? 0 : assetFile!.size,
url: fileUrl, url: fileUrl,
uploadedBy: uploaderId, uploadedBy: uploaderId,
description: req.body.description || null, description: req.body.description || null,
@ -54,6 +108,13 @@ 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,
}; };
@ -63,10 +124,48 @@ 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 assets = await this.assetService.getAssets(userContext); const filters = {
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); }
} }
@ -88,6 +187,20 @@ 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;
@ -146,4 +259,51 @@ 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,12 +25,62 @@ 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 } = z.object({ email: z.string().email(), organizationId: z.string().uuid().optional() }).parse(req.body); const { email, organizationId, partnerGroup, assignedNdaId, assignedMsaId, sharedAssetIds, mfaEnabled, showEcosystemTab } = z.object({
const result = await this.authService.invitePartner(email, organizationId); email: z.string().email(),
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;
@ -107,5 +157,24 @@ 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

@ -0,0 +1,39 @@
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

@ -0,0 +1,103 @@
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

@ -0,0 +1,541 @@
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,8 +25,12 @@ 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 doc = await this.legalService.getActiveDocument(type); const userId = (req as any).user?.userId;
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);
@ -56,7 +60,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); const activeDoc = await this.legalService.getActiveDocument(type, userId);
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' });
@ -69,7 +73,14 @@ 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(activeDoc.id, userId, ipAddress, signatureHash || undefined, documentUrl); const acceptance = await this.legalService.recordAcceptance(
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);
@ -100,6 +111,13 @@ 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

@ -0,0 +1,72 @@
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

@ -0,0 +1,207 @@
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,8 +8,19 @@ const assetController = new AssetController();
router.use(authenticate); router.use(authenticate);
router.post('/upload', requireRole('ADMIN'), upload.single('file'), assetController.uploadAsset); router.post('/upload', requireRole('ADMIN'), upload.fields([{ name: 'file', maxCount: 1 }, { name: 'thumbnail', maxCount: 1 }]), 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,9 +12,12 @@ 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

@ -0,0 +1,15 @@
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

@ -0,0 +1,16 @@
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

@ -0,0 +1,22 @@
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,6 +10,7 @@ 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

@ -0,0 +1,11 @@
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

@ -0,0 +1,24 @@
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, '../../uploads'); const localUploadsDir = path.join(__dirname, '../../minio-seed');
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

@ -0,0 +1,209 @@
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,8 +1,61 @@
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, ...rest } = data; const { sharedOrgIds, shares, tags, verticalIds, techStackIds, engagementTypeIds, complianceIds, ...rest } = data;
// Parse tags // Parse tags
let parsedTags: string[] = []; let parsedTags: string[] = [];
@ -16,14 +69,43 @@ 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)) {
@ -45,6 +127,10 @@ 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[] = [];
@ -67,42 +153,38 @@ 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(userContext?: { role: string; userId: string }) { public async getAssets(
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 [];
} }
if (userContext.role === 'ADMIN') { const whereClause: any = {};
return await prisma.asset.findMany({
include: {
sharedWith: {
include: {
organization: {
select: { id: true, name: true }
},
user: {
select: { id: true, email: true }
}
}
},
downloadRequests: {
include: {
user: {
select: { id: true, email: true }
}
}
}
},
orderBy: { createdAt: 'desc' }
});
}
if (userContext.role !== 'ADMIN') {
// For clients/partners, find user organization first // For clients/partners, find user organization first
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: userContext.userId } where: { id: userContext.userId }
@ -112,9 +194,14 @@ export class AssetService {
return []; return [];
} }
return await prisma.asset.findMany({ // Support comma-separated multiple groups
where: { const userGroups = user.partnerGroup
status: 'published', ? user.partnerGroup.split(',').map(s => s.trim().toLowerCase())
: [];
whereClause.status = 'published';
whereClause.OR = [
{
sharedWith: { sharedWith: {
some: { some: {
organizationId: user.organizationId, organizationId: user.organizationId,
@ -124,8 +211,107 @@ export class AssetService {
] ]
} }
} }
}, }
];
if (userGroups.length > 0) {
whereClause.OR.push({
assetGroups: {
some: {
name: {
in: userGroups,
mode: 'insensitive'
}
}
}
});
}
}
// 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) {
andConditions.push({
verticals: {
some: { id: { in: filters.verticalIds } }
}
});
}
if (filters?.techStackIds && filters.techStackIds.length > 0) {
andConditions.push({
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({
where: whereClause,
include: { include: {
verticals: true,
techStacks: true,
engagementTypes: true,
complianceStandards: true,
sharedWith: { sharedWith: {
include: { include: {
organization: { organization: {
@ -136,11 +322,15 @@ export class AssetService {
} }
} }
}, },
downloadRequests: { downloadRequests: userContext.role === 'ADMIN' ? {
include: {
user: { select: { id: true, email: true } }
}
} : {
where: { userId: userContext.userId } where: { userId: userContext.userId }
} }
}, },
orderBy: { createdAt: 'desc' } orderBy,
}); });
} }
@ -148,6 +338,10 @@ 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: {
@ -170,10 +364,26 @@ export class AssetService {
} }
public async updateAsset(id: string, data: any) { public async updateAsset(id: string, data: any) {
const { sharedOrgIds, shares, tags, ...rest } = data; const { shares, sharedOrgIds, tags, verticalIds, techStackIds, engagementTypeIds, complianceIds, ...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)) {
@ -188,11 +398,36 @@ 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)) {
@ -215,6 +450,10 @@ 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[] = [];
@ -238,9 +477,14 @@ 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);
} }
@ -253,6 +497,9 @@ export class AssetService {
})), })),
skipDuplicates: true skipDuplicates: true
}); });
await this.triggerOnboardingForEligiblePartners(organizationIds, []);
return this.getAssetById(assetId); return this.getAssetById(assetId);
} }
@ -267,6 +514,28 @@ 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 },
@ -277,6 +546,33 @@ 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 } });
} }
@ -313,4 +609,62 @@ 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,14 +2,17 @@ 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;
// Ignore generic/public emails or treat them as their own organization const name = domain.toUpperCase();
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({
@ -47,20 +50,50 @@ export class AuthService {
return this.getUserById(user.id); return this.getUserById(user.id);
} }
public async invitePartner(email: string, organizationId?: string) { public async invitePartner(email: string, options: { organizationId?: string, partnerGroup?: string, assignedNdaId?: string, assignedMsaId?: string, sharedAssetIds?: string[], mfaEnabled?: boolean, showEcosystemTab?: boolean } = {}) {
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 = organizationId || null; let orgId = options.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
await prisma.user.create({ const hasAssets = options.sharedAssetIds && options.sharedAssetIds.length > 0;
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
@ -68,13 +101,204 @@ export class AuthService {
organizationId: orgId, organizationId: orgId,
inviteToken, inviteToken,
inviteTokenExp, inviteTokenExp,
onboardingStatus: 'PENDING_ONBOARDING', onboardingStatus,
mfaEnabled: true, mfaEnabled: options.mfaEnabled !== undefined ? options.mfaEnabled : true,
showEcosystemTab: options.showEcosystemTab !== undefined ? options.showEcosystemTab : true,
partnerGroup: options.partnerGroup || null,
assignedNdaId,
assignedMsaId,
} }
}); });
// In a real app, send email here if (hasAssets && orgId) {
return { inviteToken }; await prisma.sharedAsset.createMany({
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) {
@ -92,12 +316,16 @@ 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,
} }
}); });
@ -144,19 +372,66 @@ export class AuthService {
} }
public async listPartners() { public async listPartners() {
return await prisma.user.findMany({ const partners = 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) {
@ -169,8 +444,17 @@ 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,
@ -198,8 +482,17 @@ 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,
@ -212,6 +505,102 @@ 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

@ -0,0 +1,38 @@
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

@ -0,0 +1,710 @@
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

@ -0,0 +1,313 @@
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,7 +21,25 @@ export class LegalService {
}); });
} }
public async getActiveDocument(type: DocumentType) { public async getActiveDocument(type: DocumentType, userId?: string) {
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 },
}); });
@ -33,27 +51,32 @@ 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 }, data: { docId, userId, ipAddress, signatureHash, documentUrl, signatureBase64 },
}); });
return acceptance; return acceptance;
} }
public async checkOnboardingCompletion(userId: string) { public async checkOnboardingCompletion(userId: string) {
// Check if both NDA and MSA have been accepted const user = await prisma.user.findUnique({
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 = acceptances.some(a => a.document.type === 'NDA'); const hasNDA = !user?.assignedNdaId || acceptances.some(a => a.document.type === 'NDA');
const hasMSA = acceptances.some(a => a.document.type === 'MSA'); const hasMSA = !user?.assignedMsaId || acceptances.some(a => a.document.type === 'MSA');
if (hasNDA && hasMSA) { if (hasNDA && hasMSA) {
// Both signed, ready for admin approval. We don't automatically set to APPROVED. // Both signed or skipped, 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 },
@ -67,7 +90,15 @@ 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 },
include: { document: true }, select: {
id: true,
signatureHash: true,
documentUrl: true,
signatureBase64: true,
ipAddress: true,
acceptedAt: true,
document: true,
},
}); });
} }
@ -78,10 +109,26 @@ export class LegalService {
id: true, id: true,
email: true, email: true,
createdAt: true, createdAt: true,
assignedNdaId: true,
assignedMsaId: true,
acceptances: { acceptances: {
include: { document: true } select: {
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' }
}); });
} }

View File

@ -0,0 +1,106 @@
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

@ -0,0 +1,229 @@
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

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

View File

@ -0,0 +1,212 @@
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,13 +2,12 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<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" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<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>
<!-- Premium Fonts --> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
<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,16 +10,19 @@
"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.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

View File

@ -5,6 +5,8 @@ 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 }
@ -39,7 +41,9 @@ export const App = () => {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ToastProvider>
<RouterProvider router={router} /> <RouterProvider router={router} />
</ToastProvider>
</QueryClientProvider> </QueryClientProvider>
); );
}; };

View File

@ -1,9 +1,23 @@
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 { ShieldCheck, BarChart3, ClipboardCheck, FolderGit2, BookCopy, Users, LogOut, Menu, X, Sun, Moon, ChevronRight, ChevronLeft } from 'lucide-react'; import {
import { motion, AnimatePresence } from 'framer-motion'; ShieldCheck,
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();
@ -15,51 +29,62 @@ 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: 'Analytics', path: '/admin/analytics', icon: BarChart3 }, { name: "Ecosystem Manager", path: "/admin/ecosystem", icon: Globe },
{ 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 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]'}`}> <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]"}`}
>
{/* 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 ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />} {isCollapsed ? (
<ChevronRight className="w-3.5 h-3.5" />
) : (
<ChevronLeft className="w-3.5 h-3.5" />
)}
</button> </button>
{/* 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="/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-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300"> <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">
<ShieldCheck className="w-5 h-5 text-ink-0" /> <img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" />
</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">Tech4Biz</span> <span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Admin Console</span> Tech4Biz
</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 className={`flex-1 py-8 space-y-2 overflow-y-auto transition-all duration-300 ${isCollapsed ? 'px-2' : 'px-4'}`}> <nav
{navItems.map(item => { className={`flex-1 py-8 space-y-2 overflow-y-auto transition-all duration-300 ${isCollapsed ? "px-2" : "px-4"}`}
>
{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 (
@ -67,31 +92,47 @@ 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 className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? 'text-ink-950' : 'text-ink-400 group-hover:text-ink-900'}`} /> <Icon
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 && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />} {!isCollapsed && isActive && (
<ChevronRight className="w-4 h-4 ml-auto opacity-50" />
)}
</Link> </Link>
); );
})} })}
</nav> </nav>
{/* Footer */} {/* Footer */}
<div className={`border-t border-ink-200 bg-ink-50 transition-all duration-300 ${isCollapsed ? 'p-3' : 'p-5'}`}> <div
<div className={`flex items-center mb-4 transition-all duration-300 ${isCollapsed ? 'justify-center' : 'justify-between'}`}> className={`border-t border-ink-200 bg-ink-50 transition-all duration-300 ${isCollapsed ? "p-3" : "p-5"}`}
{!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' ? <Sun className="w-4 h-4 group-hover:rotate-90 transition-transform" /> : <Moon className="w-4 h-4 group-hover:-rotate-12 transition-transform" />} {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" />
)}
</button> </button>
</div> </div>
@ -101,19 +142,26 @@ 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">{user?.email}</p> <p className="text-sm font-bold text-ink-900 truncate">
<p className="text-[10px] uppercase font-bold text-ink-500 tracking-wider truncate">Administrator</p> {user?.email}
</p>
<p className="text-[10px] uppercase font-bold text-ink-500 tracking-wider truncate">
Administrator
</p>
</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 || ''}> <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 || ""}
>
{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" />
@ -123,19 +171,24 @@ export const AdminLayout: React.FC = () => {
</aside> </aside>
{/* ── Main Content Area ── */} {/* ── Main Content Area ── */}
<main className="flex-1 flex flex-col relative w-full overflow-y-auto bg-ink-50"> <main className="flex-1 flex flex-col relative w-full h-screen overflow-hidden 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"> <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">
<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-gradient-to-br from-ink-900 to-ink-800"> <div className="w-8 h-8 rounded-lg flex items-center justify-center bg-ink-0 border border-ink-200 p-1 shadow-sm">
<ShieldCheck className="w-4 h-4 text-ink-0" /> <img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" />
</div> </div>
<span className="text-sm font-extrabold tracking-tight text-ink-900">Admin Console</span> <span className="text-sm font-extrabold tracking-tight text-ink-900">
Tech4Biz
</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" />
</button> </button>
</header> </header>
@ -144,48 +197,94 @@ export const AdminLayout: React.FC = () => {
<AnimatePresence> <AnimatePresence>
{mobileOpen && ( {mobileOpen && (
<> <>
<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
<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"> 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"
>
<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">
<span className="font-extrabold text-ink-900">Menu</span> <div className="flex items-center gap-2">
<button onClick={() => setMobileOpen(false)} className="p-2 rounded-lg bg-ink-100"> <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"
>
<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 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'}`}> <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"}`}
>
<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 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"> <button
Theme {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />} 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"
>
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-6 md:px-8 mx-auto relative z-10"> <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">
<Outlet /> <Outlet />
</div> </div>
{/* Footer */} {/* Footer */}
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md mt-auto"> <footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md shrink-0">
<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"> <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">
<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">Security Compliance</span> <span className="hover:text-ink-900 cursor-pointer transition-colors">
<span className="hover:text-ink-900 cursor-pointer transition-colors">System Status</span> Security Compliance
</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,13 +2,9 @@ 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 { ShieldCheck, Cpu, BookOpen, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft } from 'lucide-react'; import { Cpu, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft, Settings, Globe, Video } 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();
@ -18,6 +14,72 @@ 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');
@ -42,8 +104,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-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300"> <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">
<ShieldCheck className="w-5 h-5 text-ink-0" /> <img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" />
</div> </div>
{!isCollapsed && ( {!isCollapsed && (
<div className="flex flex-col animate-fade-in"> <div className="flex flex-col animate-fade-in">
@ -56,7 +118,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'}`}>
{navItems.map(item => { {dynamicNavItems.map(item => {
const Icon = item.icon; const Icon = item.icon;
const isActive = location.pathname === item.path; const isActive = location.pathname === item.path;
return ( return (
@ -72,12 +134,23 @@ 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-950' : 'text-ink-400 group-hover:text-ink-900'}`} /> <Icon 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.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 */}
@ -110,6 +183,8 @@ 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'}`}
@ -119,21 +194,19 @@ export const ClientLayout: React.FC = () => {
{!isCollapsed && <span>Sign Out</span>} {!isCollapsed && <span>Sign Out</span>}
</button> </button>
</div> </div>
</aside> </aside> {/* ── Main Content Area ── */}
<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"> <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">
<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-gradient-to-br from-ink-900 to-ink-800"> <div className="w-8 h-8 rounded-lg flex items-center justify-center bg-ink-0 border border-ink-200 p-1 shadow-sm">
<ShieldCheck className="w-4 h-4 text-ink-0" /> <img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" />
</div> </div>
<span className="text-sm font-extrabold tracking-tight text-ink-900">Client Portal</span> <span className="text-sm font-extrabold tracking-tight text-ink-900">Tech4Biz</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" />
@ -147,21 +220,33 @@ 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">
<span className="font-extrabold text-ink-900">Menu</span> <div className="flex items-center gap-2">
<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">
{navItems.map(item => ( {dynamicNavItems.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-600"> <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">
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>
@ -171,13 +256,13 @@ export const ClientLayout: React.FC = () => {
)} )}
</AnimatePresence> </AnimatePresence>
<div className="flex-1 w-full max-w-[1600px] px-4 py-6 md:px-8 mx-auto relative z-10"> <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">
<Outlet /> <Outlet />
</div> </div>
{/* Footer */} {/* Footer */}
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md mt-auto"> <footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md shrink-0">
<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"> <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">
<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>
@ -186,6 +271,132 @@ 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,20 +1,72 @@
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(() => import('../../pages/LoginPage').then(m => ({ default: m.LoginPage }))); const LoginPage = React.lazy(() =>
const InvitePage = React.lazy(() => import('../../pages/InvitePage').then(m => ({ default: m.InvitePage }))); import("../../pages/LoginPage").then((m) => ({ default: m.LoginPage })),
const DashboardPage = React.lazy(() => import('../../pages/DashboardPage').then(m => ({ default: m.DashboardPage }))); );
const AssetsPage = React.lazy(() => import('../../pages/AssetsPage').then(m => ({ default: m.AssetsPage }))); const InvitePage = React.lazy(() =>
const ApprovalsPage = React.lazy(() => import('../../pages/admin/ApprovalsPage').then(m => ({ default: m.ApprovalsPage }))); import("../../pages/InvitePage").then((m) => ({ default: m.InvitePage })),
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 DashboardPage = React.lazy(() =>
const LegalTemplatesPage = React.lazy(() => import('../../pages/admin/LegalTemplatesPage').then(m => ({ default: m.LegalTemplatesPage }))); import("../../pages/DashboardPage").then((m) => ({
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 = () => (
@ -23,17 +75,13 @@ 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 />
@ -41,7 +89,7 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: '/invite', path: "/invite",
element: ( element: (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<InvitePage /> <InvitePage />
@ -49,7 +97,7 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: '/onboarding', path: "/onboarding",
element: ( element: (
<RequireAuth> <RequireAuth>
<RequireRole role="PARTNER_USER"> <RequireRole role="PARTNER_USER">
@ -61,7 +109,7 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: '/client', path: "/client",
element: ( element: (
<RequireAuth> <RequireAuth>
<RequireRole role="PARTNER_USER"> <RequireRole role="PARTNER_USER">
@ -83,7 +131,7 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: 'assets', path: "assets",
element: ( element: (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<AssetsPage /> <AssetsPage />
@ -91,17 +139,33 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: 'agreements', path: "agreements",
element: <LegalPage />, element: (
<Suspense fallback={<LoadingFallback />}>
<ClientAgreementsPage />
</Suspense>
),
}, },
{ {
path: 'blog', path: "ecosystem",
element: <BlogPage />, element: (
} <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">
@ -117,7 +181,7 @@ export const router = createBrowserRouter([
element: <DirectoryPage />, element: <DirectoryPage />,
}, },
{ {
path: 'assets', path: "assets",
element: ( element: (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<AssetsPage /> <AssetsPage />
@ -125,7 +189,7 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: 'legal', path: "legal",
element: ( element: (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<LegalTemplatesPage /> <LegalTemplatesPage />
@ -133,29 +197,37 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: 'analytics', path: "approvals",
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, Hexagon, Sun, Moon } from 'lucide-react'; import { LayoutDashboard, FolderKanban, FileSignature, Users, LogOut, 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-gradient-to-tr from-ink-900 to-ink-800 shadow-lg"> <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">
<Hexagon className="text-ink-0 w-6 h-6 absolute" /> <img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" />
</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

@ -0,0 +1,47 @@
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

@ -0,0 +1,45 @@
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

@ -0,0 +1,687 @@
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

@ -0,0 +1,836 @@
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

@ -0,0 +1,549 @@
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

@ -0,0 +1,97 @@
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 font-bold tracking-tight text-ink-900 truncate"> <h1 className="text-xl page-title 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 font-medium text-ink-500 truncate max-w-3xl"> <p className="text-xs page-subtitle font-medium text-ink-500 max-w-3xl leading-relaxed">
{subtitle} {subtitle}
</p> </p>
)} )}

View File

@ -0,0 +1,122 @@
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

@ -1,313 +0,0 @@
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-950"> <div className="text-center mb-10 pb-6 border-b-2 border-ink-900">
<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-950 select-none pb-1"> <span className="font-serif italic text-base text-ink-900 select-none pb-1">
Yasha Khandelwal{" "} Yasha Khandelwal{" "}
</span> </span>
</div> </div>

View File

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

View File

@ -0,0 +1,909 @@
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,8 +1,7 @@
import React from 'react'; import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { import {
Globe,
FileText, FileText,
Image as ImageIcon,
File, File,
Eye, Eye,
MoreVertical, MoreVertical,
@ -13,10 +12,25 @@ 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;
@ -30,6 +44,11 @@ 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> = ({
@ -43,7 +62,12 @@ 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';
@ -58,49 +82,185 @@ 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;
if (type.includes('pdf')) return FileText; const isRenderable = (type: string, url: string) => {
if (type.includes('image') || type.includes('png') || type.includes('jpg')) return ImageIcon; const isOffice = type.includes('word') || type.includes('presentation') || type.includes('sheet') ||
return File; url.toLowerCase().endsWith('.docx') || url.toLowerCase().endsWith('.doc') ||
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 isRenderable = (type: string) => { const getFullAssetUrl = (url: string) => {
return type === 'url' || type.includes('pdf') || type.includes('image') || type.includes('png') || type.includes('jpg'); let resolvedUrl = url;
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 (
<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"> <motion.div
<div> id={`asset-card-${asset.id}`}
<div className="flex justify-between items-start mb-3 relative"> layout
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-ink-900 bg-ink-100 border border-ink-200"> draggable={true}
<Icon className="w-5 h-5" /> onDragStart={(e: any) => {
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="relative flex items-center gap-1.5"> <div className="absolute top-2 right-2 z-10 flex items-center gap-1">
{isRenderable(asset.type) && ( <button
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-lg text-ink-500 hover:text-ink-900 hover:bg-ink-100 transition-colors" 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"
title="Preview Online" title="Preview Online"
> >
<Eye className="w-4 h-4" /> <Eye className="w-3.5 h-3.5" />
</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-4 h-4" /> <MoreVertical className="w-3.5 h-3.5" />
</button> </button>
{isMenuOpen && ( {isMenuOpen && (
<> <>
<div className="fixed inset-0 z-10" onClick={() => setActiveMenuId(null)} /> <div className="fixed inset-0 z-10" onClick={() => setActiveMenuId(null)} />
<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 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 <button
onClick={() => { onClick={() => {
onViewDetails(asset); onViewDetails(asset);
@ -151,45 +311,411 @@ export const AssetCard: React.FC<AssetCardProps> = ({
</div> </div>
</div> </div>
<div className="space-y-1 mb-4"> {/* Thumbnail Preview Area Content */}
<div className="flex items-center gap-1.5 mb-2"> {hasBanner ? (
<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"> <img
{asset.categoryId || 'General'} 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>
{!asset.isDownloadable && ( <div className="space-y-1.5 mt-6 flex-grow pt-1">
<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"> <div className="h-1 bg-ink-200 rounded w-11/12" />
<Lock className="w-2.5 h-2.5" /> <div className="h-1 bg-ink-200 rounded w-full" />
<span>Strict View Only</span> <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 ? (
<div className="w-full h-full flex bg-slate-900 border border-slate-800 text-left select-none relative overflow-hidden">
{/* 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">
<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="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>
{/* Main slide content area */}
<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">
<div className="space-y-1.5 mt-2 relative z-10">
<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
</span>
<h4 className="text-[11px] font-extrabold text-white leading-tight font-sans line-clamp-2 mt-1">
{asset.title}
</h4>
<div className="space-y-1 pt-1.5">
<div className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-amber-500" />
<div className="h-1 bg-white/20 rounded w-5/6" />
</div>
<div className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-amber-500" />
<div className="h-1 bg-white/20 rounded w-3/4" />
</div>
<div className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-amber-500" />
<div className="h-1 bg-white/10 rounded w-4/6" />
</div>
</div>
</div>
{/* Slide Footer */}
<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>
<span className="font-extrabold text-amber-550 text-[6px]">PPTX</span>
</div>
</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 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>
<h3 className="font-bold text-ink-900 text-sm leading-snug line-clamp-2 group-hover:text-ink-950 transition-colors" title={asset.title}> <div className="space-y-1 mb-4 flex-grow flex flex-col">
{!asset.isDownloadable && (
<div className="flex items-center gap-1.5 mb-2 overflow-hidden">
<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">
<Lock className="w-2.5 h-2.5" />
<span>Strict View Only</span>
</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} {asset.title}
</h3> </h3>
<p className="text-[11px] font-medium text-ink-400 mt-1"> {asset.description && (
{asset.type === 'url' ? 'External Link' : formatBytes(asset.size)} {new Date(asset.createdAt).toLocaleDateString()} <p className="text-xs text-ink-500 line-clamp-2 mt-1.5 leading-relaxed" title={asset.description}>
{asset.description}
</p> </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>
<AnimatePresence initial={false}>
{asset.type === 'case_study' && isExpanded && (asset.problemStatement || asset.solution) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
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' ? 'Type' : 'Downloads'} {asset.type === 'url' || asset.type === 'case_study' ? '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 === 'url' ? 'URL Link' : asset.downloadsCount} {asset.type === 'case_study' ? 'Case Study' : asset.type === 'url' ? 'URL Link' : asset.downloadsCount}
</span> </span>
</div> </div>
{asset.type === 'url' ? ( {asset.type === 'url' || asset.type === 'case_study' ? (
<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="Open External URL" title={asset.type === 'case_study' ? 'Open Case Study' : 'Open External URL'}
> >
<ExternalLink className="w-3.5 h-3.5" /> <ExternalLink className="w-3.5 h-3.5" />
</a> </a>
@ -224,6 +750,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
</button> </button>
)} )}
</div> </div>
</div> </motion.div>
); );
}; };

View File

@ -1,7 +1,8 @@
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;
@ -26,73 +27,181 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
}; };
return ( return (
<AnimatePresence> <Modal
{isOpen && asset && ( isOpen={isOpen && !!asset}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> onClose={onClose}
<motion.div title="Asset Details"
initial={{ opacity: 0 }} size="2xl"
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 }}
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"
> >
<div className="flex justify-between items-center pb-3 border-b border-ink-100"> Close
<h3 className="text-base font-bold text-ink-900">Asset Details</h3> </Button>
<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" /> {asset && (
</button> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 text-xs font-medium text-ink-900">
</div> {/* Left Column */}
<div className="space-y-4">
<div className="space-y-4 text-xs font-medium">
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Title</h4> <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Title</h4>
<p className="text-sm font-extrabold text-ink-900 mt-1">{asset.title}</p> <p className="text-sm font-extrabold text-ink-900 mt-1 font-sans">{asset.title}</p>
</div> </div>
{asset.description && ( {asset.description && (
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Description</h4> <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Description</h4>
<p className="text-ink-700 mt-1 leading-relaxed">{asset.description}</p> <p className="text-ink-700 mt-1 leading-relaxed font-sans">{asset.description}</p>
</div>
)}
{(asset.type === 'case_study' || asset.thumbnailUrl) && asset.thumbnailUrl && (
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans mb-1.5">Banner Image</h4>
<div className="w-full h-44 rounded-lg overflow-hidden border border-ink-200 shadow-sm bg-ink-50">
<img
src={getFullAssetUrl(asset.thumbnailUrl)}
alt="Asset Banner"
className="w-full h-full object-cover"
/>
</div>
</div> </div>
)} )}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Category</h4> <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">{asset.categoryId || 'General'}</p> <p className="text-ink-900 mt-1 font-bold font-sans">{asset.categoryId || 'General'}</p>
</div> </div>
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Subcategory</h4> <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">{asset.subcategory || '-'}</p> <p className="text-ink-900 mt-1 font-bold font-sans">{asset.subcategory || '-'}</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">File Size</h4> <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">{asset.type === 'url' ? 'N/A' : formatBytes(asset.size)}</p> <p className="text-ink-900 mt-1 font-bold font-sans">{asset.type === 'url' ? 'N/A' : formatBytes(asset.size)}</p>
</div> </div>
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">File Type</h4> <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">{asset.type}</p> <p className="text-ink-900 mt-1 font-bold font-sans">{asset.type}</p>
</div> </div>
</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>
<span className="text-[10px] font-bold text-ink-600 block mb-1">Industry Verticals / Domains:</span>
<div className="flex flex-wrap gap-1.5">
{asset.verticals.map(v => (
<span
key={v.id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-bold border"
style={{
backgroundColor: `${v.color || '#3b82f6'}15`,
borderColor: `${v.color || '#3b82f6'}40`,
color: v.color || '#3b82f6',
}}
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: v.color || '#3b82f6' }} />
{v.name}
</span>
))}
</div>
</div>
)}
{/* Tech Stacks */}
{asset.techStacks && asset.techStacks.length > 0 && (
<div>
<span className="text-[10px] font-bold text-ink-600 block mb-1">Tech Stack & Capabilities:</span>
<div className="flex flex-wrap gap-1.5">
{asset.techStacks.map(t => (
<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"
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: t.color || '#64748b' }} />
{t.name}
</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>
{asset.tags.length > 0 && ( {asset.tags.length > 0 && (
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Tags</h4> <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Tags</h4>
<div className="flex flex-wrap gap-1.5 mt-1.5"> <div className="flex flex-wrap gap-1.5 mt-1.5">
{asset.tags.map(tag => ( {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"> <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">
{tag} {tag}
</span> </span>
))} ))}
@ -102,13 +211,13 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
{userRole === 'ADMIN' && asset.sharedWith && ( {userRole === 'ADMIN' && asset.sharedWith && (
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Shared With</h4> <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"> <div className="flex flex-wrap gap-1.5 mt-1.5">
{asset.sharedWith.length === 0 ? ( {asset.sharedWith.length === 0 ? (
<span className="text-ink-500 font-semibold italic">Not shared with any organization</span> <span className="text-ink-500 font-semibold italic font-sans">Not shared with any organization</span>
) : ( ) : (
asset.sharedWith.map(sw => ( 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 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)'} {sw.organization?.name || 'Unknown Organization'} {sw.user ? `(${sw.user.email})` : '(Entire Org)'}
</span> </span>
)) ))
@ -117,18 +226,8 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
</div> </div>
)} )}
</div> </div>
<div className="pt-3.5 border-t border-ink-100 flex justify-end">
<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> </div>
)} )}
</AnimatePresence> </Modal>
); );
}; };

View File

@ -1,7 +1,9 @@
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
@ -19,6 +21,7 @@ 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);
@ -31,9 +34,6 @@ 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,13 +70,8 @@ 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) => {
triggerToast(`Starting download: ${asset.title}`); success('Starting download', `Downloading ${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);
@ -87,6 +82,7 @@ export const AssetExplorer: React.FC = () => {
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
error('Download tracking failed', 'Unable to record download statistics.');
} }
}; };
@ -104,19 +100,10 @@ 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 font-bold text-ink-900 md:text-3xl">Tech4Biz Asset Explorer</h1> <h1 className="text-2xl explorer-title font-bold text-ink-900 md:text-3xl">Tech4Biz Asset Explorer</h1>
<p className="text-sm text-ink-700 max-w-xl"> <p className="text-sm explorer-desc 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>
@ -220,7 +207,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={asset.thumbnailUrl} src={getFullAssetUrl(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"
/> />
@ -255,7 +242,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-950 transition-colors" className="text-xs font-bold text-ink-800 hover:text-ink-900 transition-colors"
> >
View Details View Details
</button> </button>
@ -289,7 +276,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-950/20 backdrop-blur-sm p-4 animate-fade-in"> <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="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)}
@ -301,7 +288,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={selectedAsset.thumbnailUrl} src={getFullAssetUrl(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">Tags</th> <th className="px-5 py-3.5 hidden md:table-cell">Tags</th>
<th className="px-5 py-3.5">Downloads</th> <th className="px-5 py-3.5 hidden sm:table-cell">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"> <td className="px-5 py-4 hidden md:table-cell">
<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"> <td className="px-5 py-4 font-mono font-semibold text-ink-700 hidden sm:table-cell">
{asset.downloadsCount} {asset.downloadsCount}
</td> </td>
<td className="px-5 py-4"> <td className="px-5 py-4">

View File

@ -0,0 +1,224 @@
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,9 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { useNavigate } from 'react-router-dom';
import { X, Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download } from 'lucide-react'; import { Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download, Compass, Sparkles } from 'lucide-react';
import { axiosInstance } from '../../../services/axios'; import { axiosInstance } from '../../../services/axios';
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 Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button';
import MarkdownViewer from '../../../components/ui/MarkdownViewer';
import * as XLSX from 'xlsx';
interface AssetViewerModalProps { interface AssetViewerModalProps {
isOpen: boolean; isOpen: boolean;
@ -20,18 +24,68 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
user, user,
onDownload onDownload
}) => { }) => {
const navigate = useNavigate();
const [isMaximized, setIsMaximized] = useState(false); const [isMaximized, setIsMaximized] = useState(false);
const [isLoadingText, setIsLoadingText] = useState(false); const [isLoadingText, setIsLoadingText] = useState(false);
const [textPreviewContent, setTextPreviewContent] = useState(''); const [textPreviewContent, setTextPreviewContent] = useState('');
const [docBlob, setDocBlob] = useState<ArrayBuffer | null>(null);
const isLocalUrl = (url: string) => { const [excelHtml, setExcelHtml] = useState<string>('');
return url.includes('localhost') || url.includes('127.0.0.1'); const [isLoadingDoc, setIsLoadingDoc] = useState(false);
const [docLoadError, setDocLoadError] = useState(false);
const wordContainerRef = React.useRef<HTMLDivElement>(null);
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) {}
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) return match[1];
}
return null;
}; };
const getFullAssetUrl = (url: string) => { const getFullAssetUrl = (url: string) => {
return url.startsWith('http') let resolvedUrl = url;
? url if (!url.startsWith('http')) {
: `${axiosInstance.defaults.baseURL?.replace('/api/v1', '')}${url}`; const backendBase = axiosInstance.defaults.baseURL || '/api/v1';
const relativeHost = backendBase.replace('/api/v1', '');
resolvedUrl = relativeHost.startsWith('/') || relativeHost === ''
? `${window.location.origin}${relativeHost}${url}`
: `${relativeHost}${url}`;
}
// If current origin is a public domain (like ngrok) but the resolved URL is local,
// rewrite the local URL host to match the public origin so that external viewers can fetch it.
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 isLocalUrl = (url: string) => {
const resolved = getFullAssetUrl(url);
return (
resolved.includes('localhost') ||
resolved.includes('127.0.0.1') ||
resolved.includes('ngrok-free.dev') ||
resolved.includes('ngrok.io')
);
}; };
const getGithubRawUrl = (url: string) => { const getGithubRawUrl = (url: string) => {
@ -61,19 +115,70 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
return null; return null;
}; };
const isTextOrCodeAsset = (url: string, type: string) => {
const assetUrl = url.toLowerCase();
if (assetUrl.endsWith('.csv') || type.includes('csv')) {
return false;
}
return (
assetUrl.endsWith('.md') ||
assetUrl.endsWith('.txt') ||
assetUrl.endsWith('.json') ||
assetUrl.endsWith('.js') ||
assetUrl.endsWith('.ts') ||
assetUrl.endsWith('.tsx') ||
assetUrl.endsWith('.jsx') ||
assetUrl.endsWith('.py') ||
assetUrl.endsWith('.yaml') ||
assetUrl.endsWith('.yml') ||
assetUrl.endsWith('.css') ||
assetUrl.endsWith('.html') ||
assetUrl.endsWith('.sh') ||
type.includes('text') ||
type.includes('markdown') ||
type.includes('json') ||
type.includes('javascript')
);
};
const isAudioAsset = (url: string, type: string) => {
const assetUrl = url.toLowerCase();
return (
assetUrl.endsWith('.mp3') ||
assetUrl.endsWith('.wav') ||
assetUrl.endsWith('.ogg') ||
assetUrl.endsWith('.m4a') ||
assetUrl.endsWith('.webm') ||
type.includes('audio/')
);
};
const isVideoAsset = (url: string, type: string) => {
const assetUrl = url.toLowerCase();
return (
assetUrl.endsWith('.mp4') ||
assetUrl.endsWith('.webm') ||
assetUrl.endsWith('.ogg') ||
assetUrl.endsWith('.mov') ||
type.includes('video/')
);
};
const isWord = asset ? (((asset.type || '').includes('word') || (asset.url || '').toLowerCase().endsWith('.docx') || (asset.url || '').toLowerCase().endsWith('.doc'))) : false;
const isSpreadsheet = asset ? (((asset.type || '').includes('sheet') || (asset.url || '').toLowerCase().endsWith('.xlsx') || (asset.url || '').toLowerCase().endsWith('.xls') || (asset.url || '').toLowerCase().endsWith('.csv'))) : false;
useEffect(() => { useEffect(() => {
if (isOpen && asset) { if (isOpen && asset) {
setTextPreviewContent(''); setTextPreviewContent('');
setIsLoadingText(false); setIsLoadingText(false);
setDocBlob(null);
const assetUrl = asset.url.toLowerCase(); setExcelHtml('');
setIsLoadingDoc(false);
setDocLoadError(false);
if ( if (
asset.type !== 'url' && asset.type !== 'url' &&
(assetUrl.endsWith('.md') || isTextOrCodeAsset(asset.url, asset.type)
assetUrl.endsWith('.txt') ||
asset.type.includes('text') ||
asset.type.includes('markdown'))
) { ) {
setIsLoadingText(true); setIsLoadingText(true);
axiosInstance.get(getFullAssetUrl(asset.url), { responseType: 'text' }) axiosInstance.get(getFullAssetUrl(asset.url), { responseType: 'text' })
@ -115,11 +220,51 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
}); });
} }
} }
else if (isWord || isSpreadsheet) {
setIsLoadingDoc(true);
axiosInstance.get(getFullAssetUrl(asset.url), { responseType: 'arraybuffer' })
.then(res => {
const data = res.data;
setDocBlob(data);
if (isSpreadsheet) {
try {
const workbook = XLSX.read(data, { type: 'array' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const html = XLSX.utils.sheet_to_html(worksheet, { header: '', footer: '' });
setExcelHtml(html);
} catch (err) {
console.error("SheetJS Parsing error", err);
setDocLoadError(true);
}
}
setIsLoadingDoc(false);
})
.catch(err => {
console.error(err);
setDocLoadError(true);
setIsLoadingDoc(false);
});
}
} }
}, [isOpen, asset]); }, [isOpen, asset]);
const formatBytes = (bytes: number, decimals = 2) => { useEffect(() => {
if (bytes === 0) return '0 Bytes'; if (isWord && docBlob && wordContainerRef.current) {
wordContainerRef.current.innerHTML = '';
import('docx-preview').then(({ renderAsync }) => {
renderAsync(docBlob, wordContainerRef.current!)
.catch(err => {
console.error("Docx Preview error:", err);
wordContainerRef.current!.innerHTML = '<div class="p-4 text-red-550 font-sans">Failed to render Word document preview. Please download the file to inspect.</div>';
});
});
}
}, [docBlob, isWord]);
const formatBytes = (bytes?: number, decimals = 2) => {
if (!bytes || isNaN(bytes) || bytes === 0) return '';
const k = 1024; const k = 1024;
const dm = decimals < 0 ? 0 : decimals; const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB']; const sizes = ['Bytes', 'KB', 'MB', 'GB'];
@ -132,113 +277,344 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
onClose(); onClose();
}; };
const handleLocateInCatalog = () => {
onClose();
if (!asset) return;
const typeLower = (asset.type || '').toLowerCase();
const titleLower = (asset.title || '').toLowerCase();
const isCurrentAdmin = location.pathname.startsWith('/admin') || user?.role === 'ADMIN';
let targetBasePath = '';
if (typeLower.includes('case_study') || titleLower.includes('case study') || typeLower.includes('video')) {
targetBasePath = isCurrentAdmin ? '/admin/showcase' : '/client/showcase';
} else if (titleLower.includes('nda') || titleLower.includes('msa') || titleLower.includes('agreement') || typeLower.includes('legal')) {
targetBasePath = isCurrentAdmin ? '/admin/legal' : '/client/agreements';
} else if (typeLower.includes('ecosystem') || titleLower.includes('offering')) {
targetBasePath = isCurrentAdmin ? '/admin/ecosystem' : '/client/ecosystem';
} else {
targetBasePath = isCurrentAdmin ? '/admin/assets' : '/client/assets';
}
const fullUrl = `${targetBasePath}?highlight=${asset.id}`;
navigate(fullUrl, { state: { highlightAssetId: asset.id } });
};
return ( return (
<AnimatePresence> <Modal
{isOpen && asset && ( isOpen={isOpen && !!asset}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> onClose={handleClose}
<motion.div title={
initial={{ opacity: 0 }} <div className="flex items-center justify-between w-full">
animate={{ opacity: 1 }} <div className="text-left">
exit={{ opacity: 0 }} <span className="text-base font-bold text-ink-900 block font-sans">{asset?.title}</span>
onClick={handleClose} <span className="text-xs text-ink-500 font-sans block mt-0.5 font-normal">
className="absolute inset-0 bg-ink-950/60 backdrop-blur-md" {asset?.type === 'url' ? 'External Web Link' : asset?.size ? `${asset.type}${formatBytes(asset.size)}` : asset?.type}
/> </span>
<motion.div
initial={{ opacity: 0, scale: 0.98, y: 15 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98, y: 15 }}
className={`relative bg-ink-0 border border-ink-250 rounded-2xl p-6 shadow-xl z-10 flex flex-col overflow-hidden gap-4 transition-all duration-300 ${
isMaximized ? 'w-[96vw] h-[92vh] max-h-[92vh] max-w-none' : 'max-w-5xl w-full max-h-[85vh]'
}`}
>
<div className="flex justify-between items-center pb-3 border-b border-ink-100 flex-shrink-0">
<div>
<h3 className="text-base font-bold text-ink-900">{asset.title}</h3>
<p className="text-xs text-ink-500">
{asset.type === 'url' ? 'External Web Link' : `${asset.type}${formatBytes(asset.size)}`}
</p>
</div> </div>
<div className="flex items-center gap-1.5">
<button <button
onClick={() => setIsMaximized(!isMaximized)} onClick={() => setIsMaximized(!isMaximized)}
className="p-1.5 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors" className="p-1.5 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors ml-4 cursor-pointer inline-flex items-center"
title={isMaximized ? "Collapse view" : "Expand view"} title={isMaximized ? "Collapse view" : "Expand view"}
> >
{isMaximized ? <Minimize2 className="w-5 h-5" /> : <Maximize2 className="w-5 h-5" />} {isMaximized ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button> </button>
<button </div>
}
size="full"
className={isMaximized
? '!fixed !inset-0 !z-[10001] !max-w-none !max-h-none !w-screen !h-screen !rounded-none !border-none !m-0'
: '!max-w-[85vw] md:!max-w-[80vw] xl:!max-w-[75vw] !h-[75vh] !max-h-[75vh] !w-full'
}
footer={
<>
<Button
onClick={handleClose} onClick={handleClose}
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors" variant="ghost"
size="sm"
> >
<X className="w-5 h-5" /> Close Preview
</button> </Button>
</div>
</div>
<div className="flex-1 w-full bg-ink-50 rounded-xl flex flex-col items-center justify-center overflow-hidden border border-ink-200 min-h-0"> {asset && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && (
<Button
onClick={() => {
const url = getFullAssetUrl(asset.url);
const isOffice = asset.type.includes('word') || asset.type.includes('presentation') || asset.type.includes('sheet') ||
asset.url.toLowerCase().endsWith('.docx') || asset.url.toLowerCase().endsWith('.doc') ||
asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt') ||
asset.url.toLowerCase().endsWith('.xlsx') || asset.url.toLowerCase().endsWith('.xls');
if (isOffice && !isLocalUrl(asset.url)) {
window.open(`https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(url)}`, '_blank');
} else {
window.open(url, '_blank');
}
}}
variant="secondary"
size="sm"
className="flex items-center gap-2"
>
<ExternalLink className="w-4 h-4" />
<span>Open in New Tab</span>
</Button>
)}
{asset && (
<Button
onClick={handleLocateInCatalog}
variant="secondary"
size="sm"
className="flex items-center gap-2 text-emerald-600 dark:text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/10"
>
<Compass className="w-4 h-4" />
<span>Locate in Portal Catalog</span>
</Button>
)}
{asset && asset.type !== 'url' && asset.type !== 'case_study' && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && (
<Button
onClick={() => onDownload(asset)}
variant="primary"
size="sm"
className="flex items-center gap-2"
>
<Download className="w-4 h-4" />
<span>Download File</span>
</Button>
)}
</>
}
>
{asset && (
<div className="flex-1 w-full bg-ink-50 rounded-xl flex flex-col items-center justify-center overflow-hidden border border-ink-200 min-h-0 h-full">
{asset.type === 'url' && asset.url.includes('github.com') ? ( {asset.type === 'url' && asset.url.includes('github.com') ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0"> <div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none"> <div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Globe className="w-4 h-4 text-ink-950" /> <Globe className="w-4 h-4 text-ink-900" />
<span>Embedded GitHub Document</span> <span className="font-sans">Embedded GitHub Document</span>
</span> </span>
</div> </div>
<div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed"> <div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed">
{isLoadingText ? ( {isLoadingText ? (
<div className="flex flex-col items-center justify-center h-full space-y-3"> <div className="flex flex-col items-center justify-center h-full space-y-3">
<div className="w-6 h-6 rounded-full border-4 border-ink-900 border-t-transparent animate-spin" /> <div className="w-6 h-6 rounded-full border-4 border-ink-900 border-t-transparent animate-spin" />
<span className="text-xs text-ink-500 font-medium">Fetching README.md...</span> <span className="text-xs text-ink-500 font-medium font-sans">Fetching README.md...</span>
</div> </div>
) : ( ) : (
<div className="max-w-3xl mx-auto space-y-4"> <div className="max-w-3xl mx-auto space-y-4">
<div className="border-b border-ink-200 pb-4 mb-6"> <div className="border-b border-ink-200 pb-4 mb-6">
<h1 className="text-xl font-extrabold text-ink-950">{asset.title}</h1> <h1 className="text-xl font-extrabold text-ink-900 font-sans">{asset.title}</h1>
<p className="text-xs text-ink-500 mt-1 font-mono">{asset.url}</p> <p className="text-xs text-ink-500 mt-1 font-mono">{asset.url}</p>
</div> </div>
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed"> <MarkdownViewer markdown={textPreviewContent} />
{textPreviewContent}
</pre>
</div> </div>
)} )}
</div> </div>
</div> </div>
) : asset.type === 'url' ? ( ) : asset.type === 'url' ? (
<div className="text-center p-12 max-w-md space-y-4 flex flex-col justify-center items-center"> <div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
<div className="w-16 h-16 rounded-2xl bg-ink-100 border border-ink-200 flex items-center justify-center text-ink-900 shadow-sm"> <div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none shrink-0">
<Globe className="w-8 h-8" /> <span className="flex items-center gap-1.5">
<Globe className="w-4 h-4 text-ink-900" />
<span className="font-sans">External Web Link Portal Guide</span>
</span>
<span className="text-[10px] text-ink-400 font-mono font-normal truncate max-w-xs">{asset.url}</span>
</div> </div>
<div>
<h4 className="text-sm font-bold text-ink-900">External Resource Portal</h4> <div className="flex-1 overflow-auto p-4 md:p-8 flex flex-col md:flex-row items-center justify-center gap-8 bg-ink-50">
<p className="text-xs text-ink-500 mt-2 leading-relaxed"> {/* Visual Section: Browser Mockup */}
This asset points to an external destination outside of the local CDN container. <div className="w-full max-w-md aspect-video md:aspect-[4/3] rounded-2xl overflow-hidden shadow-lg border border-ink-200 bg-slate-900 flex flex-col shrink-0">
</p> {/* Browser Header Bar */}
<div className="bg-ink-100 border border-ink-200 rounded-xl px-4 py-2 text-xs font-mono text-ink-600 truncate mt-3 max-w-sm"> <div className="h-7 bg-slate-800 border-b border-slate-700/60 flex items-center px-4 gap-2 shrink-0 select-none">
{asset.url} <div className="flex gap-1.5 shrink-0">
<span className="w-3 h-3 rounded-full bg-red-500/80" />
<span className="w-3 h-3 rounded-full bg-yellow-500/80" />
<span className="w-3 h-3 rounded-full bg-green-500/80" />
</div>
<div className="flex-1 max-w-[200px] mx-auto bg-slate-900/60 border border-slate-700/30 rounded-md px-3 py-0.5 flex items-center gap-1.5 text-[10px] text-slate-400 font-mono select-none truncate">
<Globe className="w-3 h-3 text-slate-500 shrink-0" />
<span className="truncate">{(() => {
try {
return new URL(asset.url).hostname.replace('www.', '');
} catch {
return 'website.com';
}
})()}</span>
</div> </div>
</div> </div>
{/* Browser Content */}
<div className="flex-1 bg-slate-950 p-6 flex flex-col justify-between relative overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808008_1px,transparent_1px),linear-gradient(to_bottom,#80808008_1px,transparent_1px)] bg-[size:16px_16px]" />
<div className="space-y-3 relative z-10 text-left">
<div className="flex justify-between items-center bg-white/[0.02] border border-white/5 rounded-lg px-3 py-1.5">
<div className="w-16 h-2 bg-slate-700 rounded" />
<div className="flex gap-1.5">
<div className="w-6 h-1.5 bg-white/20 rounded" />
<div className="w-6 h-1.5 bg-white/20 rounded" />
</div>
</div>
<div className="space-y-2 pt-2">
<div className="h-5 bg-slate-700 rounded-lg w-5/6" />
<div className="h-2 bg-white/20 rounded w-11/12" />
<div className="h-2 bg-white/15 rounded w-3/4" />
</div>
</div>
<div className="relative z-10 bg-slate-900/80 border border-slate-800/80 rounded-xl p-3.5 flex items-center gap-3 select-none">
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center text-sm font-black text-white shrink-0 shadow-md">
{(() => {
try {
return new URL(asset.url).hostname.replace('www.', '').charAt(0).toUpperCase();
} catch {
return 'W';
}
})()}
</div>
<div className="flex-1 min-w-0 text-left">
<div className="text-xs font-bold text-white font-mono truncate leading-none">
{(() => {
try {
return new URL(asset.url).hostname.replace('www.', '');
} catch {
return 'website.com';
}
})()}
</div>
<div className="text-[8px] text-slate-500 font-mono mt-1 leading-none tracking-wider">
SECURE PORTAL LINK
</div>
</div>
</div>
</div>
</div>
{/* Info & Guide Section */}
<div className="flex-1 bg-ink-0 border border-ink-200 rounded-2xl p-6 md:p-8 shadow-lg max-w-lg w-full flex flex-col justify-between min-h-[350px]">
<div className="space-y-4 text-left">
<div>
<span className="inline-flex px-2 py-0.5 rounded text-[9px] font-extrabold uppercase tracking-wider text-slate-800 bg-slate-100 border border-slate-200">
External Portal Link
</span>
<h3 className="text-lg font-extrabold text-ink-900 font-sans leading-snug mt-1.5">{asset.title}</h3>
{asset.description ? (
<p className="text-xs text-ink-500 font-sans mt-2 leading-relaxed">{asset.description}</p>
) : (
<p className="text-xs text-ink-400 font-sans mt-2 italic">No description provided for this external portal link.</p>
)}
</div>
{/* Step-by-Step Access Guide */}
<div className="bg-ink-50 border border-ink-200 rounded-xl p-4 space-y-3">
<span className="text-[9px] font-bold uppercase tracking-wider text-ink-400 block">Access Guide</span>
<ul className="space-y-2 text-xs text-ink-700 leading-relaxed font-sans">
<li className="flex items-start gap-2">
<span className="w-4 h-4 rounded-full bg-ink-200 text-ink-800 text-[10px] font-bold flex items-center justify-center shrink-0 mt-0.5">1</span>
<span>Click the primary button below or select <strong>Open in New Tab</strong> from the footer to access this asset.</span>
</li>
<li className="flex items-start gap-2">
<span className="w-4 h-4 rounded-full bg-ink-200 text-ink-800 text-[10px] font-bold flex items-center justify-center shrink-0 mt-0.5">2</span>
<span>If the partner resource requires authorization, use your partner account credentials to sign in.</span>
</li>
</ul>
</div>
</div>
<div className="pt-6 border-t border-ink-100 w-full mt-4">
{(user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') ? ( {(user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') ? (
<div className="space-y-2 w-full">
<a <a
href={asset.url} href={asset.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 px-6 py-2.5 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-all shadow-sm w-full" className="inline-flex items-center justify-center gap-2 px-6 py-2.5 rounded-xl bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-all hover:scale-[1.02] active:scale-95 shadow-md w-full font-sans cursor-pointer"
> >
<span>Open Link in New Tab</span> <span>Open Website in New Tab</span>
<ExternalLink className="w-4 h-4" /> <ExternalLink className="w-4 h-4" />
</a> </a>
<button
onClick={() => {
navigator.clipboard.writeText(asset.url);
alert('Link copied to clipboard!');
}}
className="w-full text-center text-xs font-bold text-ink-600 hover:text-ink-900 bg-ink-50 hover:bg-ink-100 border border-ink-200 py-2 rounded-xl transition-all cursor-pointer"
>
Copy Link Address
</button>
</div>
) : ( ) : (
<div className="bg-amber-50 border border-amber-205 text-amber-900 rounded-xl p-4 text-xs text-center font-medium max-w-sm"> <div className="bg-amber-50 border border-amber-200 text-amber-900 rounded-xl p-4 text-xs font-medium text-center font-sans">
Access Restricted: You must request and receive download approval from the Administrator to open this resource link. Access Restricted: You must request and receive approval from the Administrator to open this external link.
</div> </div>
)} )}
</div> </div>
</div>
</div>
</div>
) : asset.type === 'case_study' || asset.type.includes('video') || asset.type === 'showcase' ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none shrink-0">
<span className="flex items-center gap-1.5">
<Sparkles className="w-4 h-4 text-emerald-600" />
<span className="font-sans">Executive Case Study & Video Showcase</span>
</span>
<span className="text-[10px] text-ink-400 font-mono font-normal truncate max-w-xs">{asset.title}</span>
</div>
<div className="flex-1 overflow-auto p-6 md:p-8 flex flex-col items-center justify-start gap-6 bg-ink-50">
{asset.url && (asset.url.includes('youtube.com') || asset.url.includes('youtu.be')) ? (
<div className="w-full max-w-3xl aspect-video rounded-2xl overflow-hidden shadow-2xl bg-black border border-ink-300 shrink-0">
<iframe
src={`https://www.youtube-nocookie.com/embed/${extractYouTubeVideoId(asset.url)}?autoplay=1&rel=0`}
className="w-full h-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
) : (
<div className="w-full max-w-3xl p-6 bg-slate-900 text-white rounded-2xl shadow-xl border border-slate-800 space-y-3">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded text-[10px] font-extrabold uppercase bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
Verified Showcase Reel
</span>
</div>
<h2 className="text-lg font-bold">{asset.title}</h2>
{asset.description && <p className="text-xs text-slate-300 leading-relaxed whitespace-pre-wrap">{asset.description}</p>}
</div>
)}
<div className="w-full max-w-3xl bg-ink-0 border border-ink-200 rounded-2xl p-6 shadow-md space-y-4 text-left">
<h3 className="text-base font-extrabold text-ink-900">{asset.title}</h3>
{asset.problemStatement && (
<div className="space-y-1">
<span className="text-xs font-bold text-red-600 uppercase tracking-wider">Problem Statement</span>
<p className="text-xs text-ink-700 leading-relaxed">{asset.problemStatement}</p>
</div>
)}
{asset.solution && (
<div className="space-y-1 pt-2 border-t border-ink-150">
<span className="text-xs font-bold text-emerald-600 uppercase tracking-wider">Implemented Technical Solution</span>
<p className="text-xs text-ink-700 leading-relaxed">{asset.solution}</p>
</div>
)}
{asset.description && !asset.problemStatement && (
<div className="space-y-1 pt-2 border-t border-ink-150">
<span className="text-xs font-bold text-ink-600 uppercase tracking-wider">Executive Overview</span>
<p className="text-xs text-ink-700 leading-relaxed whitespace-pre-wrap">{asset.description}</p>
</div>
)}
</div>
</div>
</div>
) : asset.type.includes('pdf') ? ( ) : asset.type.includes('pdf') ? (
<div className="w-full h-full flex flex-col min-h-0"> <div className="w-full h-full flex flex-col min-h-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none"> <div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
<FileText className="w-4 h-4 text-ink-950 mr-1.5" /> <FileText className="w-4 h-4 text-ink-900 mr-1.5" />
<span>Interactive PDF Preview</span> <span className="font-sans">Interactive PDF Preview</span>
</div> </div>
<iframe <iframe
src={getFullAssetUrl(asset.url)} src={getFullAssetUrl(asset.url)}
@ -246,49 +622,170 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
title={asset.title} title={asset.title}
/> />
</div> </div>
) : (asset.url.toLowerCase().endsWith('.md') || asset.url.toLowerCase().endsWith('.txt') || asset.type.includes('text') || asset.type.includes('markdown')) ? ( ) : isTextOrCodeAsset(asset.url, asset.type) ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0"> <div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none"> <div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
<FileText className="w-4 h-4 text-ink-950 mr-1.5" /> <FileText className="w-4 h-4 text-ink-900 mr-1.5" />
<span>Document Reader</span> <span className="font-sans">Document Reader</span>
</div> </div>
<div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed"> <div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed">
{isLoadingText ? ( {isLoadingText ? (
<div className="flex flex-col items-center justify-center h-full space-y-3"> <div className="flex flex-col items-center justify-center h-full space-y-3">
<div className="w-6 h-6 rounded-full border-4 border-ink-900 border-t-transparent animate-spin" /> <div className="w-6 h-6 rounded-full border-4 border-ink-900 border-t-transparent animate-spin" />
<span className="text-xs text-ink-500 font-medium">Loading content...</span> <span className="text-xs text-ink-500 font-medium font-sans">Loading content...</span>
</div> </div>
) : ( ) : (
<div className="max-w-3xl mx-auto space-y-4"> <div className="max-w-3xl mx-auto space-y-4">
<div className="border-b border-ink-200 pb-4 mb-6"> <div className="border-b border-ink-200 pb-4 mb-6">
<h1 className="text-xl font-extrabold text-ink-950">{asset.title}</h1> <h1 className="text-xl font-extrabold text-ink-900 font-sans">{asset.title}</h1>
<p className="text-xs text-ink-500 mt-1">Plain Text / Markdown Format</p> <p className="text-xs text-ink-500 mt-1 font-sans">Plain Text / Source Code Format</p>
</div> </div>
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed"> {asset.url.toLowerCase().endsWith('.md') || asset.type.includes('markdown') ? (
<MarkdownViewer markdown={textPreviewContent} />
) : (
<pre className="whitespace-pre-wrap font-mono text-xs text-ink-800 bg-ink-50 border border-ink-200 rounded-lg p-4 break-words leading-relaxed overflow-x-auto">
{textPreviewContent} {textPreviewContent}
</pre> </pre>
)}
</div> </div>
)} )}
</div> </div>
</div> </div>
) : (asset.type.includes('word') || asset.type.includes('presentation') || asset.type.includes('sheet') || asset.url.toLowerCase().endsWith('.docx') || asset.url.toLowerCase().endsWith('.doc') || asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt') || asset.url.toLowerCase().endsWith('.xlsx') || asset.url.toLowerCase().endsWith('.xls')) ? ( ) : isAudioAsset(asset.url, asset.type) ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0"> <div className="w-full h-full flex flex-col justify-center items-center bg-ink-0 p-8 space-y-4">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none"> <div className="w-16 h-16 rounded-full bg-ink-50 border border-ink-200 flex items-center justify-center text-ink-900 shadow-sm">
<FileText className="w-4 h-4 text-ink-950 mr-1.5" /> <span className="text-2xl">🎵</span>
<span>Office Document Preview</span>
</div> </div>
{isLocalUrl(asset.url) ? ( <div className="text-center max-w-sm">
<h4 className="text-sm font-bold text-ink-900 font-sans">Audio Player</h4>
<p className="text-xs text-ink-500 mt-1 font-sans">You can listen to this audio asset directly in the browser.</p>
</div>
<audio
src={getFullAssetUrl(asset.url)}
controls
className="w-full max-w-md mt-4 focus:outline-none"
/>
</div>
) : isVideoAsset(asset.url, asset.type) ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-900">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none w-full shrink-0">
<FileText className="w-4 h-4 text-ink-900 mr-1.5" />
<span className="font-sans text-ink-900">Video Player</span>
</div>
<div className="flex-1 flex items-center justify-center min-h-0 relative p-4">
<video
src={getFullAssetUrl(asset.url)}
controls
className="max-w-full max-h-full rounded-xl shadow-lg border border-ink-800"
style={{ outline: 'none' }}
/>
</div>
</div>
) : (isWord || isSpreadsheet || asset.type.includes('presentation') || asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt')) ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none shrink-0">
<span className="flex items-center gap-1.5">
<FileText className="w-4 h-4 text-ink-900" />
<span className="font-sans">
{isWord ? 'Interactive Word Document Viewer' : isSpreadsheet ? 'Interactive Spreadsheet Grid Viewer' : 'Interactive Presentation Viewer'}
</span>
</span>
<span className="text-[10px] text-ink-400 font-mono font-normal truncate max-w-xs">{asset.title}</span>
</div>
{isLoadingDoc ? (
<div className="flex-1 flex flex-col items-center justify-center space-y-3 bg-ink-50">
<div className="w-8 h-8 rounded-full border-4 border-ink-900 border-t-transparent animate-spin" />
<span className="text-xs text-ink-500 font-medium font-sans">Loading and rendering document...</span>
</div>
) : docLoadError ? (
<div className="flex-1 p-8 text-center flex flex-col justify-center items-center max-w-lg mx-auto space-y-4 bg-ink-0"> <div className="flex-1 p-8 text-center flex flex-col justify-center items-center max-w-lg mx-auto space-y-4 bg-ink-0">
<div className="w-16 h-16 rounded-2xl bg-ink-55 border border-ink-100 flex items-center justify-center text-ink-900 shadow-sm"> <div className="w-16 h-16 rounded-2xl bg-red-50 border border-red-100 flex items-center justify-center text-red-650 shadow-sm shrink-0">
<FileText className="w-8 h-8" /> <FileText className="w-8 h-8" />
</div> </div>
<div> <div>
<h4 className="text-sm font-bold text-ink-900">Office Document Preview</h4> <h4 className="text-sm font-bold text-red-950 font-sans">Failed to Render Document</h4>
<p className="text-xs text-ink-500 mt-2 leading-relaxed"> <p className="text-xs text-slate-500 mt-2 leading-relaxed font-sans">
This asset is a Microsoft Office document ({asset.type.split('/').pop()?.toUpperCase() || 'DOCX'}). We encountered an error loading or rendering this document ({asset.type.split('/').pop()?.toUpperCase() || 'FILE'}).
</p> </p>
<p className="text-xs text-ink-500 mt-2 leading-relaxed bg-ink-50 border border-ink-100 rounded-xl p-3 text-left"> <p className="text-xs text-slate-500 mt-2 leading-relaxed bg-slate-50 border border-slate-100 rounded-xl p-3 text-left font-sans">
<strong>Note:</strong> Microsoft Office Online Viewer is optimized for staging/production environments. In development (localhost), external services cannot fetch local files. Please download this asset using the button below to view it locally. Please download the asset directly to view it on your device.
</p>
</div>
</div>
) : isSpreadsheet ? (
<div className="flex-1 overflow-auto bg-slate-100 p-4 flex flex-col">
<div className="excel-preview-container flex-1 bg-white border border-slate-200 shadow-sm rounded-xl overflow-auto p-4">
<style>{`
.excel-preview-container table {
border-collapse: collapse;
width: max-content;
min-width: 100%;
font-size: 11px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
color: #0f172a;
}
.excel-preview-container th, .excel-preview-container td {
border: 1px solid #e2e8f0;
padding: 6px 12px;
text-align: left;
white-space: nowrap;
color: #0f172a;
}
.excel-preview-container tr:nth-child(even) {
background-color: #f8fafc;
}
.excel-preview-container tr:hover {
background-color: #f1f5f9;
}
`}</style>
<div dangerouslySetInnerHTML={{ __html: excelHtml || '<div class="p-4 text-slate-400 font-sans italic">No data found in this spreadsheet.</div>' }} />
</div>
</div>
) : isWord ? (
<div className="flex-1 overflow-auto bg-slate-100 p-2 sm:p-6 md:p-8">
<style>{`
.docx-wrapper {
background: transparent !important;
padding: 0 !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
}
.docx {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1) !important;
border: 1px solid #e2e8f0 !important;
border-radius: 12px !important;
max-width: 100% !important;
margin: 0 auto 24px auto !important;
background-color: white !important;
}
@media (max-width: 640px) {
.docx {
padding: 16px !important;
margin-bottom: 12px !important;
}
}
`}</style>
<div
ref={wordContainerRef}
className="w-full max-w-4xl mx-auto"
/>
</div>
) : (
/* PowerPoint / Presentation */
isLocalUrl(asset.url) ? (
<div className="flex-1 p-8 text-center flex flex-col justify-center items-center max-w-lg mx-auto space-y-4 bg-ink-0">
<div className="w-16 h-16 rounded-2xl bg-amber-50 border border-amber-200 flex items-center justify-center text-amber-950 shadow-sm shrink-0">
<FileText className="w-8 h-8" />
</div>
<div>
<h4 className="text-sm font-bold text-amber-950 font-sans">Presentation Slide Viewer</h4>
<p className="text-xs text-amber-900/80 mt-2 leading-relaxed font-sans">
This asset is a PowerPoint Presentation ({asset.type.split('/').pop()?.toUpperCase() || 'PPTX'}).
</p>
<p className="text-xs text-slate-500 mt-2 leading-relaxed bg-slate-50 border border-slate-100 rounded-xl p-3 text-left font-sans">
<strong>Note:</strong> Microsoft Office Online Viewer is optimized for staging/production environments. In development (localhost or ngrok tunnel), external Microsoft services cannot fetch your local files. Please download this asset using the button below to view it locally.
</p> </p>
</div> </div>
</div> </div>
@ -298,6 +795,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
className="w-full flex-1 border-0 min-h-0" className="w-full flex-1 border-0 min-h-0"
title={asset.title} title={asset.title}
/> />
)
)} )}
</div> </div>
) : asset.type.includes('image') || asset.type.includes('png') || asset.type.includes('jpg') ? ( ) : asset.type.includes('image') || asset.type.includes('png') || asset.type.includes('jpg') ? (
@ -308,38 +806,111 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
className="max-w-full max-h-full object-contain rounded-xl shadow-sm border border-ink-100" className="max-w-full max-h-full object-contain rounded-xl shadow-sm border border-ink-100"
/> />
</div> </div>
) : (asset.type === 'case_study' || asset.problemStatement || asset.solution) ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0 overflow-y-auto p-6 text-left select-text">
<div className="max-w-4xl mx-auto w-full space-y-6">
{asset.thumbnailUrl && (
<div className="w-full h-48 rounded-2xl overflow-hidden border border-ink-200 shadow-sm relative bg-ink-100">
<img src={getFullAssetUrl(asset.thumbnailUrl)} alt={asset.title} className="w-full h-full object-cover" />
</div>
)}
<div className="border-b border-ink-200 pb-4">
<div className="flex items-center gap-2 mb-2">
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-emerald-500/10 text-emerald-600 border border-emerald-500/20">
Executive Case Study Showcase
</span>
{asset.type && (
<span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-ink-100 text-ink-700 capitalize">
{asset.type}
</span>
)}
</div>
<h1 className="text-xl sm:text-2xl font-extrabold text-ink-900 leading-tight">{asset.title}</h1>
{asset.description && <p className="text-xs text-ink-600 mt-2 leading-relaxed">{asset.description}</p>}
</div>
{/* Problem Statement Block */}
{asset.problemStatement && (
<div className="p-4 rounded-xl bg-amber-500/5 border border-amber-500/20 space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-amber-700 dark:text-amber-400 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-amber-500" />
Problem Statement & Challenge
</h3>
<div className="text-xs text-ink-800 dark:text-slate-200 leading-relaxed font-sans">
<MarkdownViewer markdown={asset.problemStatement} />
</div>
</div>
)}
{/* Proposed Solution Block */}
{asset.solution && (
<div className="p-4 rounded-xl bg-emerald-500/5 border border-emerald-500/20 space-y-2">
<h3 className="text-xs font-bold uppercase tracking-wider text-emerald-700 dark:text-emerald-400 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-emerald-500" />
Implemented Technical Solution
</h3>
<div className="text-xs text-ink-800 dark:text-slate-200 leading-relaxed font-sans">
<MarkdownViewer markdown={asset.solution} />
</div>
</div>
)}
{asset.url && asset.url.startsWith('http') && (
<div className="pt-2 flex justify-end">
<a
href={asset.url}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-ink-900 text-ink-0 hover:bg-ink-800 rounded-xl text-xs font-bold flex items-center gap-2 transition-colors"
>
<ExternalLink className="w-4 h-4" />
Visit Showcase Webpage
</a>
</div>
)}
</div>
</div>
) : asset.type === 'url' ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0 overflow-y-auto p-6 text-left select-text">
<div className="max-w-3xl mx-auto w-full space-y-6 text-center flex flex-col items-center justify-center h-full">
<div className="w-16 h-16 rounded-2xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-600">
<Globe className="w-8 h-8" />
</div>
<div>
<span className="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-blue-500/10 text-blue-600 border border-blue-500/20">
External Web Resource
</span>
<h2 className="text-xl font-extrabold text-ink-900 mt-3">{asset.title}</h2>
{asset.description && <p className="text-xs text-ink-600 mt-2 max-w-lg mx-auto">{asset.description}</p>}
</div>
<div className="p-4 bg-ink-50 border border-ink-200 rounded-xl w-full max-w-md text-left">
<p className="text-[10px] font-mono text-ink-500 truncate">{asset.url}</p>
</div>
<a
href={asset.url}
target="_blank"
rel="noopener noreferrer"
className="px-6 py-3 bg-emerald-600 hover:bg-emerald-700 text-white font-bold rounded-xl text-xs flex items-center gap-2 shadow-md transition-all cursor-pointer"
>
<ExternalLink className="w-4 h-4" />
Launch Web Document
</a>
</div>
</div>
) : ( ) : (
<div className="text-center p-8 flex flex-col justify-center items-center"> <div className="text-center p-8 flex flex-col justify-center items-center">
<File className="w-12 h-12 text-ink-300 mb-3" /> <File className="w-12 h-12 text-ink-300 mb-3" />
<h4 className="text-sm font-bold text-ink-900">Direct Preview Unsupported</h4> <h4 className="text-sm font-bold text-ink-900 font-sans">Direct Preview Unsupported</h4>
<p className="text-xs text-ink-500 mt-1 max-w-sm"> <p className="text-xs text-ink-500 mt-1 max-w-sm font-sans">
This file format cannot be rendered directly in the browser. Please download the file to inspect its contents. This file format cannot be rendered directly in the browser. Please download the file to inspect its contents.
</p> </p>
</div> </div>
)} )}
</div> </div>
<div className="pt-3 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0">
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
>
Close Preview
</button>
{asset.type !== 'url' && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && (
<button
onClick={() => onDownload(asset)}
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 flex items-center gap-2"
>
<Download className="w-4 h-4" />
<span>Download File</span>
</button>
)} )}
</div> </Modal>
</motion.div>
</div>
)}
</AnimatePresence>
); );
}; };

View File

@ -0,0 +1,84 @@
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,7 +1,8 @@
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;
@ -29,82 +30,59 @@ export const DownloadRequestsModal: React.FC<DownloadRequestsModalProps> = ({
); );
return ( return (
<AnimatePresence> <Modal
{isOpen && ( isOpen={isOpen}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> onClose={onClose}
<motion.div title="Pending Download Requests"
initial={{ opacity: 0 }} subtitle="Review and approve download access for protected secret assets."
animate={{ opacity: 1 }} size="lg"
exit={{ opacity: 0 }} footer={
<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 }}
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-2xl w-full shadow-xl z-10 flex flex-col max-h-[80vh] overflow-hidden"
> >
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0"> Close
<div> </Button>
<h3 className="text-lg font-bold text-ink-900">Pending Download Requests</h3> }
<p className="text-xs text-ink-500 mt-0.5">Review and approve download access for protected secret assets.</p>
</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" /> <div className="space-y-3">
</button>
</div>
<div className="flex-1 overflow-y-auto mt-4 space-y-3 pr-1 scrollbar-thin">
{pendingRequests.length === 0 ? ( {pendingRequests.length === 0 ? (
<div className="text-center py-8"> <div className="text-center py-8">
<Check className="w-8 h-8 text-ink-400 mx-auto mb-2" /> <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-xs font-bold text-ink-900 font-sans">All caught up!</p>
<p className="text-[10px] text-ink-500">There are no pending download authorization requests.</p> <p className="text-[10px] text-ink-500 font-sans">There are no pending download authorization requests.</p>
</div> </div>
) : ( ) : (
pendingRequests.map(req => ( 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 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">{req.user?.email}</p> <p className="text-xs font-bold text-ink-900 font-sans">{req.user?.email}</p>
<p className="text-[10px] text-ink-500 mt-0.5 font-medium"> <p className="text-[10px] text-ink-500 mt-0.5 font-medium font-sans">
Requested download for: <span className="text-ink-900 font-bold">{req.assetTitle}</span> Requested download for: <span className="text-ink-900 font-bold">{req.assetTitle}</span>
</p> </p>
</div> </div>
<div className="flex items-center gap-2 self-end sm:self-center"> <div className="flex items-center gap-2 self-end sm:self-center">
<button <Button
onClick={() => onReject(req.assetId, req.id)} 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" variant="danger"
size="xs"
> >
Reject Reject
</button> </Button>
<button <Button
onClick={() => onApprove(req.assetId, req.id)} 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" variant="primary"
size="xs"
> >
Approve Access Approve Access
</button> </Button>
</div> </div>
</div> </div>
)) ))
)} )}
</div> </div>
</Modal>
<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,8 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { UploadCloud, X, Trash2 } from 'lucide-react';
import { X } from 'lucide-react'; import { updateAsset, uploadThumbnail, getTaxonomyMeta } from '../../../services/assets-api';
import { updateAsset } from '../../../services/assets-api'; import { getFullAssetUrl } from '../../../utils/asset-url';
import type { Asset } from '../../../types/assets'; import type { Asset, TaxonomyMeta } 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;
@ -17,6 +20,14 @@ 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');
@ -24,8 +35,16 @@ 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);
@ -35,62 +54,110 @@ 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) { } catch (err: any) {
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 (
<AnimatePresence> <Modal
{isOpen && asset && ( isOpen={isOpen && !!asset}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> onClose={onClose}
<motion.div title="Edit Asset Details"
initial={{ opacity: 0 }} size="lg"
animate={{ opacity: 1 }} footer={
exit={{ opacity: 0 }} <>
<Button
type="button"
onClick={onClose} onClick={onClose}
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm" variant="ghost"
/> 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"
> >
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0"> Cancel
<h3 className="text-lg font-bold text-ink-900">Edit Asset Details</h3> </Button>
<button <Button
onClick={onClose} type="submit"
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors" form="edit-asset-form"
disabled={isSavingEdit}
variant="primary"
size="sm"
> >
<X className="w-5 h-5" /> {isSavingEdit ? 'Saving...' : 'Save Changes'}
</button> </Button>
</div> </>
}
<form onSubmit={handleEditSubmit} className="flex-1 min-h-0 flex flex-col mt-4"> >
<div className="flex-1 overflow-y-auto pr-1 space-y-4 scrollbar-thin"> {asset && (
<form id="edit-asset-form" onSubmit={handleEditSubmit} className="space-y-4">
<div> <div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Asset Title</label> <label className="text-xs font-semibold text-ink-500 mb-1.5 block">Asset Title</label>
<input <input
@ -102,30 +169,231 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label> <div className="flex items-center justify-between mb-1.5">
<select <label className="text-xs font-semibold text-ink-500">Thumbnail Banner (Optional)</label>
value={editCategory} <div className="flex bg-ink-100 p-0.5 rounded-lg border border-ink-200 text-[11px]">
onChange={(e) => setEditCategory(e.target.value)} <button
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" 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'}`}
> >
<option value="Marketing">Marketing</option> Image URL
<option value="Presentations">Presentations</option> </button>
<option value="Branding">Branding</option> <button
<option value="Resources">Resources</option> type="button"
<option value="Technical">Technical</option> onClick={() => { setEditThumbnailMode('file'); setEditThumbnailUrl(''); }}
</select> 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> </div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Subcategory</label>
{editThumbnailMode === 'url' ? (
<div className="flex gap-2">
<input <input
type="text" type="url"
value={editSubcategory} value={editThumbnailUrl}
onChange={(e) => setEditSubcategory(e.target.value)} onChange={(e) => setEditThumbnailUrl(e.target.value)}
placeholder="e.g. Slide Deck" placeholder="https://example.com/image.png"
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" 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"
/> />
{editThumbnailUrl && (
<button
type="button"
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="mt-2">
<label className="text-[11px] font-semibold text-ink-500 mb-1 block">Thumbnail Preview</label>
<div className="w-full h-28 border border-ink-200 rounded-lg overflow-hidden relative bg-ink-50">
<img
src={getFullAssetUrl(editThumbnailUrl)}
alt="Thumbnail Preview"
className="w-full h-full object-cover"
/>
</div>
</div>
)}
</div>
{/* 4-Group Taxonomy Demarcation Selection */}
<div className="space-y-4 pt-3 pb-3 border-t border-b border-slate-200 dark:border-slate-800">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Taxonomy Classification (Strict Admin-Managed)
</h4>
{/* Group 1: Industry Verticals */}
<div>
<label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
1. Industry Verticals / Domains
</label>
<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">
{(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>
{/* Group 2: Tech Stack */}
<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> </div>
@ -181,27 +449,8 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
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" 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 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={isSavingEdit}
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"
>
{isSavingEdit ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form> </form>
</motion.div>
</div>
)} )}
</AnimatePresence> </Modal>
); );
}; };

View File

@ -0,0 +1,451 @@
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

@ -0,0 +1,293 @@
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

@ -0,0 +1,129 @@
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,13 +1,18 @@
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 { X, ChevronDown, ChevronUp } from 'lucide-react'; import { updateAsset, bulkShareAssets, getAssetGroups, addAssetsToGroup } from '../../../services/assets-api';
import { updateAsset } from '../../../services/assets-api'; import type { AssetGroup } 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;
} }
@ -16,23 +21,44 @@ 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) {
setSharesList( const existingShares = asset.sharedWith?.map(s => ({
asset.sharedWith?.map(s => ({
organizationId: s.organizationId, organizationId: s.organizationId,
userId: s.userId 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]); }, [asset, isOpen, organizations]);
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);
@ -68,61 +94,123 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
const handleShareSubmit = async (e: React.FormEvent) => { const handleShareSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!asset) return; if (!asset && (!assetIds || assetIds.length === 0)) return;
setIsSavingShare(true); setIsSavingShare(true);
try { try {
const targetShares = shareMode === 'ALL'
? organizations.map(org => ({ organizationId: org.id, userId: null }))
: sharesList;
if (asset) {
await updateAsset(asset.id, { await updateAsset(asset.id, {
shares: sharesList 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) { } catch (err: any) {
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 (
<AnimatePresence> <Modal
{isOpen && asset && ( isOpen={isOpen && (!!asset || (!!assetIds && assetIds.length > 0))}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> onClose={onClose}
<motion.div title="Share & Partner Access Settings"
initial={{ opacity: 0 }} subtitle={asset ? asset.title : `${assetIds?.length || 0} selected assets`}
animate={{ opacity: 1 }} size="md"
exit={{ opacity: 0 }} footer={
<>
<Button
type="button"
onClick={onClose} onClick={onClose}
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm" variant="ghost"
/> 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"
> >
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0"> Cancel
<div> </Button>
<h3 className="text-lg font-bold text-ink-900">Share Settings</h3> <Button
<p className="text-xs text-ink-500 mt-0.5">{asset.title}</p> type="submit"
</div> form="share-asset-form"
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
onClick={onClose} type="button"
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors" onClick={() => setShareMode('ALL')}
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'
}`}
> >
<X className="w-5 h-5" /> <span>🌐 Share with ALL Partners</span>
</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>
<form onSubmit={handleShareSubmit} className="flex-1 min-h-0 flex flex-col mt-4"> {shareMode === 'ALL' ? (
<div className="flex-1 overflow-y-auto pr-1 space-y-4 scrollbar-thin"> <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">
<p className="text-xs text-ink-600 leading-relaxed"> <span className="font-extrabold block">🌐 Global Access Mode Enabled</span>
Select organizations or expand to specify exact users that can access this asset: <p className="leading-relaxed text-[11px]">
This asset will be automatically accessible to <strong>ALL registered partner organizations</strong> and visible under the "All Assets" catalog tab.
</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> </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">No partner organizations registered yet.</p> <p className="p-4 text-xs text-ink-500 text-center font-medium font-sans">No partner organizations registered yet.</p>
) : ( ) : (
organizations.map(org => { organizations.map(org => {
const isEntireShared = isOrgSharedEntirely(org.id); const isEntireShared = isOrgSharedEntirely(org.id);
@ -141,9 +229,9 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
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">{org.name}</span> <span className="text-xs font-bold text-ink-900 font-sans">{org.name}</span>
{specificSharedCount > 0 && !isEntireShared && ( {specificSharedCount > 0 && !isEntireShared && (
<span className="text-[10px] text-ink-500 font-semibold"> <span className="text-[10px] text-ink-500 font-semibold font-sans">
Shared with {specificSharedCount} specific {specificSharedCount === 1 ? 'user' : 'users'} Shared with {specificSharedCount} specific {specificSharedCount === 1 ? 'user' : 'users'}
</span> </span>
)} )}
@ -153,7 +241,7 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
<button <button
type="button" type="button"
onClick={() => setExpandedOrgId(isExpanded ? null : org.id)} 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" 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> <span>Users</span>
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />} {isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
@ -169,7 +257,7 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
className="bg-ink-100 border-t border-b border-ink-200 overflow-hidden divide-y divide-ink-150" className="bg-ink-100 border-t border-b border-ink-200 overflow-hidden divide-y divide-ink-150"
> >
{activeUsers.length === 0 ? ( {activeUsers.length === 0 ? (
<p className="p-3 text-[10px] text-ink-500 italic">No users found in this organization.</p> <p className="p-3 text-[10px] text-ink-500 italic font-sans">No users found in this organization.</p>
) : ( ) : (
activeUsers.map(userItem => { activeUsers.map(userItem => {
const isUserShared = isUserSharedSpecifically(org.id, userItem.id); const isUserShared = isUserSharedSpecifically(org.id, userItem.id);
@ -183,7 +271,7 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
onChange={() => handleToggleUser(org.id, userItem.id)} 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" 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'}`}> <span className={`text-[11px] font-semibold font-sans ${isEntireShared ? 'text-ink-400' : 'text-ink-800'}`}>
{userItem.email} {userItem.email}
</span> </span>
</label> </label>
@ -199,27 +287,32 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
)} )}
</div> </div>
</div> </div>
)}
<div className="pt-3.5 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0 mt-4"> {groups.length > 0 && (
<button <div className="pt-2 border-t border-ink-200">
type="button" <label className="text-[10px] font-bold text-ink-500 uppercase tracking-wide mb-1 block">
onClick={onClose} Add to Asset Group / Bundle (Optional)
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors" </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"
> >
Cancel <option value="">-- Select Group --</option>
</button> {groups.map((g) => (
<button <option key={g.id} value={g.id}>
type="submit" {g.name}
disabled={isSavingShare} </option>
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" ))}
> </select>
{isSavingShare ? 'Saving...' : 'Update Shares'} <p className="text-[9px] text-ink-450 mt-1 font-medium">
</button> This will automatically register the shared asset(s) as part of the selected bundle.
</div> </p>
</form>
</motion.div>
</div> </div>
)} )}
</AnimatePresence> </form>
)}
</Modal>
); );
}; };

View File

@ -0,0 +1,110 @@
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

@ -1,7 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { X, UploadCloud, Eye, FileText, File, Sparkles, Globe, Users } from 'lucide-react';
import { X, UploadCloud, Eye, FileText, File } from 'lucide-react'; import { uploadAsset, scrapeCaseStudy, getTaxonomyMeta, getOrganizations } from '../../../services/assets-api';
import { uploadAsset } from '../../../services/assets-api'; import { getFullAssetUrl } from '../../../utils/asset-url';
import type { TaxonomyMeta, Organization } from '../../../types/assets';
import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button';
import { useToast } from '../../../hooks/use-toast';
interface UploadAssetModalProps { interface UploadAssetModalProps {
isOpen: boolean; isOpen: boolean;
@ -14,11 +18,30 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
onClose, onClose,
onSuccess onSuccess
}) => { }) => {
const [uploadTab, setUploadTab] = useState<'file' | 'url'>('file'); const { success, error } = useToast();
const [taxonomyMeta, setTaxonomyMeta] = useState<TaxonomyMeta | null>(null);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [shareScope, setShareScope] = useState<'ALL' | 'SELECTED'>('ALL');
const [selectedOrgIds, setSelectedOrgIds] = useState<string[]>([]);
const [selectedVerticalIds, setSelectedVerticalIds] = useState<string[]>([]);
const [selectedTechStackIds, setSelectedTechStackIds] = useState<string[]>([]);
const [selectedEngagementTypeIds, setSelectedEngagementTypeIds] = useState<string[]>([]);
const [selectedComplianceIds, setSelectedComplianceIds] = useState<string[]>([]);
const [uploadTab, setUploadTab] = useState<'file' | 'url' | 'case_study'>('file');
const [uploadFile, setUploadFile] = useState<File | null>(null); const [uploadFile, setUploadFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [fullImagePreviewUrl, setFullImagePreviewUrl] = useState<string | null>(null); const [fullImagePreviewUrl, setFullImagePreviewUrl] = useState<string | null>(null);
const [uploadUrl, setUploadUrl] = useState(''); const [uploadUrl, setUploadUrl] = useState('');
const [caseStudyUrl, setCaseStudyUrl] = useState('');
const [thumbnailMode, setThumbnailMode] = useState<'url' | 'file'>('url');
const [thumbnailUrl, setThumbnailUrl] = useState('');
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnailFilePreview, setThumbnailFilePreview] = useState<string | null>(null);
const [problemStatement, setProblemStatement] = useState('');
const [solution, setSolution] = useState('');
const [isScraping, setIsScraping] = useState(false);
const [uploadTitle, setUploadTitle] = useState(''); const [uploadTitle, setUploadTitle] = useState('');
const [uploadDescription, setUploadDescription] = useState(''); const [uploadDescription, setUploadDescription] = useState('');
const [uploadCategory, setUploadCategory] = useState('Marketing'); const [uploadCategory, setUploadCategory] = useState('Marketing');
@ -26,8 +49,14 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
const [uploadTags, setUploadTags] = useState(''); const [uploadTags, setUploadTags] = useState('');
const [uploadGithubUrl, setUploadGithubUrl] = useState(''); const [uploadGithubUrl, setUploadGithubUrl] = useState('');
const [uploadIsDownloadable, setUploadIsDownloadable] = useState(true); const [uploadIsDownloadable, setUploadIsDownloadable] = useState(true);
const [includeInKnowledgeBase, setIncludeInKnowledgeBase] = useState(true);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
useEffect(() => {
getTaxonomyMeta().then(setTaxonomyMeta).catch(console.error);
getOrganizations().then(setOrganizations).catch(console.error);
}, []);
useEffect(() => { useEffect(() => {
if (!uploadFile) { if (!uploadFile) {
setPreviewUrl(null); setPreviewUrl(null);
@ -47,6 +76,82 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
} }
}, [uploadFile]); }, [uploadFile]);
useEffect(() => {
if (!thumbnailFile) {
setThumbnailFilePreview(null);
return;
}
const url = URL.createObjectURL(thumbnailFile);
setThumbnailFilePreview(url);
return () => {
URL.revokeObjectURL(url);
};
}, [thumbnailFile]);
const handleFetchCaseStudy = async () => {
if (!caseStudyUrl) {
error('URL Required', 'Please enter a case study URL first.');
return;
}
if (!caseStudyUrl.startsWith('http')) {
error('Invalid URL', 'Please enter a valid URL starting with http:// or https://');
return;
}
setIsScraping(true);
try {
const data = await scrapeCaseStudy(caseStudyUrl);
setUploadTitle(data.title);
setThumbnailUrl(data.thumbnailUrl);
setProblemStatement(data.problemStatement);
setSolution(data.solution);
// Auto-set tag as case-study
setUploadTags(prev => {
const tags = prev.split(',').map(t => t.trim()).filter(Boolean);
if (!tags.includes('case-study')) {
tags.push('case-study');
}
return tags.join(', ');
});
// Auto-set category and subcategory
setUploadCategory('Resources');
setUploadSubcategory('Case Study');
success('Case Study Details Fetched', 'Title, banner, problem, and solution successfully retrieved.');
} catch (err: any) {
console.error('Failed to scrape case study', err);
error('Failed to retrieve case study details', err.response?.data?.error || 'Make sure the URL is a valid showcase case study.');
} finally {
setIsScraping(false);
}
};
const handleFetchUrlDetails = async () => {
if (!uploadUrl) {
error('URL Required', 'Please enter a URL first.');
return;
}
if (!uploadUrl.startsWith('http')) {
error('Invalid URL', 'Please enter a valid URL starting with http:// or https://');
return;
}
setIsScraping(true);
try {
const data = await scrapeCaseStudy(uploadUrl);
setUploadTitle(data.title);
setThumbnailUrl(data.thumbnailUrl || '');
success('Page Details Fetched', 'Title and banner image successfully retrieved.');
} catch (err: any) {
console.error('Failed to scrape URL details', err);
error('Failed to retrieve page details', err.response?.data?.error || 'Make sure the URL is accessible.');
} finally {
setIsScraping(false);
}
};
const formatBytes = (bytes: number, decimals = 2) => { const formatBytes = (bytes: number, decimals = 2) => {
if (bytes === 0) return '0 Bytes'; if (bytes === 0) return '0 Bytes';
const k = 1024; const k = 1024;
@ -60,6 +165,7 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
e.preventDefault(); e.preventDefault();
if (uploadTab === 'file' && !uploadFile) return; if (uploadTab === 'file' && !uploadFile) return;
if (uploadTab === 'url' && !uploadUrl) return; if (uploadTab === 'url' && !uploadUrl) return;
if (uploadTab === 'case_study' && !caseStudyUrl) return;
setIsUploading(true); setIsUploading(true);
const formData = new FormData(); const formData = new FormData();
@ -67,84 +173,139 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
formData.append('file', uploadFile); formData.append('file', uploadFile);
} else { } else {
formData.append('isUrlAsset', 'true'); formData.append('isUrlAsset', 'true');
formData.append('url', uploadUrl); formData.append('url', uploadTab === 'case_study' ? caseStudyUrl : uploadUrl);
} }
formData.append('title', uploadTitle || (uploadFile ? uploadFile.name : uploadUrl)); formData.append('type', uploadTab === 'case_study' ? 'case_study' : (uploadTab === 'url' ? 'url' : ''));
formData.append('title', uploadTitle || (uploadFile ? uploadFile.name : (uploadTab === 'case_study' ? caseStudyUrl : uploadUrl)));
formData.append('description', uploadDescription); formData.append('description', uploadDescription);
formData.append('categoryId', uploadCategory); formData.append('categoryId', uploadCategory);
formData.append('subcategory', uploadSubcategory); formData.append('subcategory', uploadSubcategory);
formData.append('tags', JSON.stringify(uploadTags.split(',').map(t => t.trim()).filter(Boolean))); formData.append('tags', JSON.stringify(uploadTags.split(',').map(t => t.trim()).filter(Boolean)));
formData.append('githubUrl', uploadGithubUrl); formData.append('githubUrl', uploadGithubUrl);
formData.append('isDownloadable', String(uploadIsDownloadable)); formData.append('isDownloadable', String(uploadIsDownloadable));
if (selectedVerticalIds.length) {
formData.append('verticalIds', JSON.stringify(selectedVerticalIds));
}
if (selectedTechStackIds.length) {
formData.append('techStackIds', JSON.stringify(selectedTechStackIds));
}
if (selectedEngagementTypeIds.length) {
formData.append('engagementTypeIds', JSON.stringify(selectedEngagementTypeIds));
}
if (selectedComplianceIds.length) {
formData.append('complianceIds', JSON.stringify(selectedComplianceIds));
}
if (thumbnailMode === 'file' && thumbnailFile) {
formData.append('thumbnail', thumbnailFile);
} else if (thumbnailUrl) {
formData.append('thumbnailUrl', thumbnailUrl);
}
if (uploadTab === 'case_study') {
formData.append('problemStatement', problemStatement);
formData.append('solution', solution);
}
formData.append('includeInKnowledgeBase', String(includeInKnowledgeBase));
if (organizations.length > 0) {
const targetShares = shareScope === 'ALL'
? organizations.map(org => ({ organizationId: org.id, userId: null }))
: selectedOrgIds.map(orgId => ({ organizationId: orgId, userId: null }));
formData.append('shares', JSON.stringify(targetShares));
}
try { try {
await uploadAsset(formData); await uploadAsset(formData);
setUploadFile(null); setUploadFile(null);
setUploadUrl(''); setUploadUrl('');
setCaseStudyUrl('');
setThumbnailUrl('');
setThumbnailFile(null);
setThumbnailFilePreview(null);
setThumbnailMode('url');
setProblemStatement('');
setSolution('');
setUploadTitle(''); setUploadTitle('');
setUploadDescription(''); setUploadDescription('');
setUploadCategory('Marketing'); setUploadCategory('Marketing');
setUploadSubcategory(''); setUploadSubcategory('');
setUploadTags(''); setUploadTags('');
setUploadGithubUrl(''); setUploadGithubUrl('');
setSelectedVerticalIds([]);
setSelectedTechStackIds([]);
setSelectedEngagementTypeIds([]);
setSelectedComplianceIds([]);
setUploadIsDownloadable(true); setUploadIsDownloadable(true);
success('Asset published successfully', 'The asset has been added to the catalog.');
onSuccess(); onSuccess();
onClose(); onClose();
} catch (err) { } catch (err: any) {
console.error('Failed to upload asset', err); console.error('Failed to upload asset', err);
error('Failed to publish asset', err.response?.data?.error || 'Something went wrong.');
} finally { } finally {
setIsUploading(false); setIsUploading(false);
} }
}; };
const toggleSelection = (list: string[], item: string) => {
return list.includes(item) ? list.filter(i => i !== item) : [...list, item];
};
return ( return (
<> <>
<AnimatePresence> <Modal
{isOpen && ( isOpen={isOpen}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> onClose={onClose}
<motion.div title="Upload / Link Asset"
initial={{ opacity: 0 }} size="lg"
animate={{ opacity: 1 }} footer={
exit={{ opacity: 0 }} <>
<Button
type="button"
onClick={onClose} onClick={onClose}
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm" variant="ghost"
/> 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"
> >
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0"> Cancel
<h3 className="text-lg font-bold text-ink-900">Upload / Link Asset</h3> </Button>
<button <Button
onClick={onClose} type="submit"
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors" form="upload-asset-form"
disabled={isUploading}
variant="primary"
size="sm"
>
{isUploading ? 'Publishing...' : 'Publish Asset'}
</Button>
</>
}
> >
<X className="w-5 h-5" />
</button>
</div>
{/* Toggle upload tabs */} {/* Toggle upload tabs */}
<div className="flex bg-ink-50 p-1 rounded-xl border border-ink-200 flex-shrink-0 mt-4"> <div className="flex bg-ink-50 p-1 rounded-xl border border-ink-200 mt-2">
<button <button
type="button" type="button"
onClick={() => setUploadTab('file')} onClick={() => setUploadTab('file')}
className={`flex-1 py-1.5 text-xs font-semibold rounded-lg transition-all ${uploadTab === 'file' ? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`} className={`flex-1 py-1.5 text-xs font-semibold rounded-lg transition-all cursor-pointer ${uploadTab === 'file' ? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`}
> >
Secure File Upload Secure File Upload
</button> </button>
<button <button
type="button" type="button"
onClick={() => setUploadTab('url')} onClick={() => setUploadTab('url')}
className={`flex-1 py-1.5 text-xs font-semibold rounded-lg transition-all ${uploadTab === 'url' ? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`} className={`flex-1 py-1.5 text-xs font-semibold rounded-lg transition-all cursor-pointer ${uploadTab === 'url' ? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`}
> >
External Web URL External Web URL
</button> </button>
<button
type="button"
onClick={() => setUploadTab('case_study')}
className={`flex-1 py-1.5 text-xs font-semibold rounded-lg transition-all cursor-pointer ${uploadTab === 'case_study' ? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`}
>
Showcase Case Study
</button>
</div> </div>
<form onSubmit={handleUploadSubmit} className="flex-1 min-h-0 flex flex-col mt-4"> <form id="upload-asset-form" onSubmit={handleUploadSubmit} className="space-y-4 mt-4">
<div className="flex-1 overflow-y-auto pr-1 space-y-4 scrollbar-thin">
{uploadTab === 'file' ? ( {uploadTab === 'file' ? (
<div className={`border-2 border-dashed border-ink-200 hover:border-ink-400 rounded-xl p-5 text-center transition-colors relative bg-ink-50 max-h-48 overflow-y-auto scrollbar-thin ${!uploadFile ? 'cursor-pointer' : ''}`}> <div className={`border-2 border-dashed border-ink-200 hover:border-ink-400 rounded-xl p-5 text-center transition-colors relative bg-ink-50 max-h-48 overflow-y-auto scrollbar-thin ${!uploadFile ? 'cursor-pointer' : ''}`}>
{!uploadFile && ( {!uploadFile && (
@ -168,7 +329,7 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
e.preventDefault(); e.preventDefault();
setUploadFile(null); setUploadFile(null);
}} }}
className="absolute top-2 right-2 p-1 rounded-lg bg-ink-0 hover:bg-ink-100 text-ink-500 hover:text-ink-900 border border-ink-200 transition-colors z-20 shadow-sm" className="absolute top-2 right-2 p-1 rounded-lg bg-ink-0 hover:bg-ink-100 text-ink-500 hover:text-ink-900 border border-ink-200 transition-colors z-20 shadow-sm cursor-pointer"
title="Remove file" title="Remove file"
> >
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
@ -195,7 +356,7 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
e.preventDefault(); e.preventDefault();
setFullImagePreviewUrl(previewUrl); setFullImagePreviewUrl(previewUrl);
}} }}
className="p-1 rounded-lg bg-ink-55 hover:bg-ink-100 text-ink-600 hover:text-ink-950 border border-ink-200 transition-all duration-200 hover:scale-105 active:scale-95 flex items-center justify-center" className="p-1 rounded-lg bg-ink-55 hover:bg-ink-100 text-ink-600 hover:text-ink-900 border border-ink-200 transition-all duration-200 hover:scale-105 active:scale-95 flex items-center justify-center cursor-pointer"
title="Preview Image" title="Preview Image"
> >
<Eye className="w-3 h-3" /> <Eye className="w-3 h-3" />
@ -207,7 +368,7 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
e.preventDefault(); e.preventDefault();
setUploadFile(null); setUploadFile(null);
}} }}
className="p-1 rounded-lg bg-ink-55 hover:bg-red-500/10 text-ink-500 hover:text-red-650 border border-ink-200 transition-all duration-200 hover:scale-105 active:scale-95 flex items-center justify-center" className="p-1 rounded-lg bg-ink-55 hover:bg-red-500/10 text-ink-500 hover:text-red-655 border border-ink-200 transition-all duration-200 hover:scale-105 active:scale-95 flex items-center justify-center cursor-pointer"
title="Remove File" title="Remove File"
> >
<X className="w-3 h-3" /> <X className="w-3 h-3" />
@ -244,20 +405,175 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
</div> </div>
)} )}
</div> </div>
) : ( ) : uploadTab === 'url' ? (
<div> <div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">External Asset URL</label> <label className="text-xs font-semibold text-ink-500 mb-1.5 block">External Asset URL</label>
<div className="flex gap-2">
<input <input
type="url" type="url"
value={uploadUrl} value={uploadUrl}
onChange={(e) => setUploadUrl(e.target.value)} onChange={(e) => setUploadUrl(e.target.value)}
placeholder="https://example.com/partner-docs" placeholder="https://example.com/partner-docs"
required={uploadTab === 'url'} required={uploadTab === 'url'}
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" 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"
/>
<Button
type="button"
onClick={handleFetchUrlDetails}
disabled={isScraping || !uploadUrl}
variant="primary"
size="sm"
className="flex-shrink-0"
>
{isScraping ? 'Fetching...' : 'Fetch Details'}
</Button>
</div>
<span className="text-[10px] text-ink-450 mt-1 block">
Fetches title and thumbnail banner image from the target webpage automatically.
</span>
</div>
) : (
<div className="space-y-4">
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Case Study URL</label>
<div className="flex gap-2">
<input
type="url"
value={caseStudyUrl}
onChange={(e) => setCaseStudyUrl(e.target.value)}
placeholder="https://showcase.tech4bizsolutions.com/ai-forecasting-pricing-for-fmcg-tech4biz-case-study/"
required={uploadTab === 'case_study'}
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"
/>
<Button
type="button"
onClick={handleFetchCaseStudy}
disabled={isScraping || !caseStudyUrl}
variant="primary"
size="sm"
className="flex-shrink-0"
>
{isScraping ? 'Fetching...' : 'Fetch Details'}
</Button>
</div>
<span className="text-[10px] text-ink-450 mt-1 block">
Fetches title, banner image, problem statement, and solution from the showcase platform.
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Scraped Problem Statement</label>
<textarea
value={problemStatement}
onChange={(e) => setProblemStatement(e.target.value)}
placeholder="Problem Statement will appear here..."
rows={4}
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 resize-y font-sans leading-relaxed"
/> />
</div> </div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Scraped Solution</label>
<textarea
value={solution}
onChange={(e) => setSolution(e.target.value)}
placeholder="Solution details will appear here..."
rows={4}
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 resize-y font-sans leading-relaxed"
/>
</div>
</div>
</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={() => { setThumbnailMode('url'); setThumbnailFile(null); }}
className={`px-2 py-0.5 font-medium rounded-md transition-all cursor-pointer ${thumbnailMode === '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={() => { setThumbnailMode('file'); setThumbnailUrl(''); }}
className={`px-2 py-0.5 font-medium rounded-md transition-all cursor-pointer ${thumbnailMode === '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>
{thumbnailMode === 'url' ? (
<input
type="url"
value={thumbnailUrl}
onChange={(e) => setThumbnailUrl(e.target.value)}
placeholder="https://example.com/image.png"
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 className="border border-dashed border-ink-200 hover:border-ink-400 rounded-xl p-3 text-center transition-colors relative bg-ink-50">
{!thumbnailFile ? (
<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;
}
setThumbnailFile(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">
{thumbnailFilePreview && (
<img src={thumbnailFilePreview} 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">{thumbnailFile.name}</p>
<p className="text-[10px] text-ink-500">{formatBytes(thumbnailFile.size)} Upload ready</p>
</div>
<button
type="button"
onClick={() => setThumbnailFile(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>
)}
{thumbnailMode === 'url' && thumbnailUrl && (
<div className="mt-2">
<label className="text-[11px] font-semibold text-ink-500 mb-1 block">Thumbnail Preview</label>
<div className="w-full h-28 border border-ink-200 rounded-lg overflow-hidden relative bg-ink-50">
<img
src={getFullAssetUrl(thumbnailUrl)}
alt="Thumbnail Preview"
className="w-full h-full object-cover"
/>
</div>
</div>
)}
</div>
<div> <div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Asset Title</label> <label className="text-xs font-semibold text-ink-500 mb-1.5 block">Asset Title</label>
<input <input
@ -269,40 +585,141 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> {/* 4-Group Taxonomy Demarcation Selection */}
<div className="space-y-4 pt-3 pb-3 border-t border-b border-slate-200 dark:border-slate-800">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Taxonomy Classification (Strict Admin-Managed)
</h4>
{/* Group 1: Industry Verticals */}
<div> <div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label> <label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
<select 1. Industry Verticals / Domains
value={uploadCategory} </label>
onChange={(e) => setUploadCategory(e.target.value)} <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">
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" {(taxonomyMeta?.verticals || []).map(v => {
const isSelected = selectedVerticalIds.includes(v.id);
return (
<button
key={v.id}
type="button"
onClick={() => setSelectedVerticalIds(toggleSelection(selectedVerticalIds, 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'
}`}
> >
<option value="Marketing">Marketing</option> {v.name}
<option value="Presentations">Presentations</option> </button>
<option value="Branding">Branding</option> );
<option value="Resources">Resources</option> })}
<option value="Technical">Technical</option>
</select>
</div> </div>
</div>
{/* Group 2: Tech Stack */}
<div> <div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Subcategory</label> <label className="text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 block">
<input 2. Technology Stack & Capabilities
type="text" </label>
value={uploadSubcategory} <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">
onChange={(e) => setUploadSubcategory(e.target.value)} {['Languages & Frameworks', 'AI & ML', 'Data & Backend', 'Cloud & Infra'].map(catName => {
placeholder="e.g. Slide Deck" const items = (taxonomyMeta?.techStacks || []).filter(t => t.category === catName || (!t.category && catName === 'Languages & Frameworks'));
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" 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 = selectedTechStackIds.includes(t.id);
return (
<button
key={t.id}
type="button"
onClick={() => setSelectedTechStackIds(toggleSelection(selectedTechStackIds, 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 = selectedEngagementTypeIds.includes(e.id);
return (
<button
key={e.id}
type="button"
onClick={() => setSelectedEngagementTypeIds(toggleSelection(selectedEngagementTypeIds, 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 = selectedComplianceIds.includes(c.id);
return (
<button
key={c.id}
type="button"
onClick={() => setSelectedComplianceIds(toggleSelection(selectedComplianceIds, 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> </div>
<div> <div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Description</label> <label className="text-xs font-semibold text-ink-500 mb-1.5 block">Description <span className="text-red-500">*</span></label>
<textarea <textarea
value={uploadDescription} value={uploadDescription}
onChange={(e) => setUploadDescription(e.target.value)} onChange={(e) => setUploadDescription(e.target.value)}
placeholder="Enter short description about this asset..." placeholder="Enter short description about this asset..."
rows={3} rows={3}
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 resize-none" 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> </div>
@ -327,6 +744,87 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
</div> </div>
)} )}
<div className="flex items-start gap-3 p-3 bg-emerald-500/5 dark:bg-emerald-500/10 border border-emerald-500/20 rounded-xl">
<input
type="checkbox"
id="includeInKnowledgeBase"
checked={includeInKnowledgeBase}
onChange={(e) => setIncludeInKnowledgeBase(e.target.checked)}
className="w-4 h-4 rounded border-emerald-300 text-emerald-600 focus:ring-emerald-500 cursor-pointer mt-0.5"
/>
<div>
<label htmlFor="includeInKnowledgeBase" className="text-xs font-bold text-ink-900 dark:text-emerald-300 cursor-pointer flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5 text-emerald-500" />
Include in AI Advisor Knowledge Base (RAG Search)
</label>
<span className="text-[10px] text-ink-500 dark:text-slate-400 block mt-0.5">
If checked, the AI Advisor indexes this content to answer partner questions with OKF standard citations.
</span>
</div>
</div>
{/* Partner Sharing & Visibility Scope */}
<div className="space-y-3 pt-3 border-t border-slate-200 dark:border-slate-800">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Partner Visibility & Access Scope (Admin Control)
</h4>
<div className="grid grid-cols-2 gap-2 p-1 bg-slate-100 dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<button
type="button"
onClick={() => setShareScope('ALL')}
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 ${
shareScope === 'ALL'
? 'bg-slate-900 text-white dark:bg-blue-600 shadow-sm'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700'
}`}
>
<Globe className="w-3.5 h-3.5" />
<span>Share with ALL Partners</span>
</button>
<button
type="button"
onClick={() => setShareScope('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 ${
shareScope === 'SELECTED'
? 'bg-slate-900 text-white dark:bg-blue-600 shadow-sm'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700'
}`}
>
<Users className="w-3.5 h-3.5" />
<span>Selected Partners Only</span>
</button>
</div>
{shareScope === 'ALL' ? (
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium p-2 bg-emerald-500/10 rounded-lg border border-emerald-500/20">
🌐 This asset will be automatically shared with all registered partner organizations and accessible under the "All Assets" catalog tab.
</p>
) : (
<div className="space-y-1.5 max-h-36 overflow-y-auto p-2 bg-slate-50 dark:bg-slate-950/60 rounded-xl border border-slate-200 dark:border-slate-800">
<span className="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase block">Select Target Organizations:</span>
{organizations.length === 0 ? (
<p className="text-xs text-slate-400 italic">No partner organizations registered yet.</p>
) : (
organizations.map(org => {
const isSelected = selectedOrgIds.includes(org.id);
return (
<label key={org.id} className="flex items-center gap-2 text-xs font-medium text-slate-800 dark:text-slate-200 cursor-pointer py-1 select-none">
<input
type="checkbox"
checked={isSelected}
onChange={() => setSelectedOrgIds(toggleSelection(selectedOrgIds, org.id))}
className="w-3.5 h-3.5 rounded border-slate-300 dark:border-slate-700"
/>
<span>{org.name}</span>
</label>
);
})
)}
</div>
)}
</div>
<div> <div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Tags (comma-separated)</label> <label className="text-xs font-semibold text-ink-500 mb-1.5 block">Tags (comma-separated)</label>
<input <input
@ -348,63 +846,24 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
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" 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 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={isUploading}
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"
>
{isUploading ? 'Publishing...' : 'Publish Asset'}
</button>
</div>
</form> </form>
</motion.div> </Modal>
</div>
)}
</AnimatePresence>
{/* Full Local Image Preview Modal */} {/* Full Local Image Preview Modal */}
<AnimatePresence> <Modal
{fullImagePreviewUrl && ( isOpen={!!fullImagePreviewUrl}
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4"> onClose={() => setFullImagePreviewUrl(null)}
<motion.div title="Asset Image Preview"
initial={{ opacity: 0 }} size="lg"
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setFullImagePreviewUrl(null)}
className="absolute inset-0 bg-ink-950/85 backdrop-blur-md"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="relative max-w-[95vw] max-h-[90vh] z-10 flex flex-col items-center justify-center"
> >
<button <div className="flex flex-col items-center justify-center p-1">
onClick={() => setFullImagePreviewUrl(null)}
className="absolute -top-10 right-0 p-1.5 rounded-lg bg-ink-0 hover:bg-ink-100 text-ink-900 border border-ink-200 shadow-lg transition-colors z-20"
title="Close"
>
<X className="w-4 h-4" />
</button>
<img <img
src={fullImagePreviewUrl} src={fullImagePreviewUrl || ''}
alt="Full preview" alt="Full preview"
className="max-w-full max-h-[80vh] object-contain rounded-xl shadow-2xl border border-ink-200 bg-ink-50" className="max-w-full max-h-[60vh] object-contain rounded-xl shadow-md border border-ink-200 bg-ink-50"
/> />
</motion.div>
</div> </div>
)} </Modal>
</AnimatePresence>
</> </>
); );
}; };

View File

@ -95,14 +95,12 @@ 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" 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"
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)',
}} }}
> >
<ShieldCheck className="w-8 h-8" style={{ color: 'var(--color-ink-800)' }} /> <img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" />
</div> </div>
</div> </div>
@ -260,10 +258,10 @@ export const LoginForm: React.FC = () => {
<OrbitingRing /> <OrbitingRing />
<div> <div>
<h1 className="text-2xl font-extrabold tracking-tight" style={{ color: 'var(--color-ink-800)' }}> <h1 className="text-2xl login-form-title 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 mt-1.5" style={{ color: 'var(--color-ink-600)' }}> <p className="text-sm login-form-subtitle 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

@ -1,286 +0,0 @@
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;

1
Channel-Frontend/src/fontsource.d.ts vendored Normal file
View File

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

View File

@ -3,6 +3,7 @@ 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;
@ -21,12 +22,17 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: false, isAuthenticated: false,
accessToken: null, accessToken: null,
isInitializing: true, isInitializing: true,
setAuth: (data) => set({ setAuth: (data) => {
if (data.user?.defaultTheme) {
useThemeStore.getState().setTheme(data.user.defaultTheme as any);
}
set({
user: data.user, user: data.user,
accessToken: data.accessToken, accessToken: data.accessToken,
isAuthenticated: true, isAuthenticated: true,
isInitializing: false, isInitializing: false,
}), });
},
logout: () => set({ logout: () => set({
user: null, user: null,
accessToken: null, accessToken: null,
@ -39,6 +45,9 @@ 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,
@ -58,6 +67,9 @@ 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,10 +1,11 @@
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 } from "../services/legal-api"; import { getPendingPartners, getMyAcceptances } from "../services/legal-api";
import type { PendingPartner } from "../services/legal-api"; import type { PendingPartner, LegalAcceptance } 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 = (
@ -17,3 +18,14 @@ 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,18 +1,52 @@
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 } from "../services/auth-api"; import { invitePartner, updatePartner, resendInvitePartner } from "../services/auth-api";
import type { InviteResponse } from "../services/auth-api"; import type { InviteResponse, InviteParams, UpdatePartnerParams, Partner } 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,
string InviteParams
> => { > => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<InviteResponse, Error, string>({ return useMutation<InviteResponse, Error, InviteParams>({
mutationFn: (email: string) => invitePartner(email), 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
> => {
const queryClient = useQueryClient();
return useMutation<{ success: boolean }, Error, string>({
mutationFn: (partnerId: string) => resendInvitePartner(partnerId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: PARTNERS_QUERY_KEYS.list(), queryKey: PARTNERS_QUERY_KEYS.list(),

View File

@ -6,6 +6,7 @@ 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) => ({
@ -22,8 +23,7 @@ export const useThemeStore = create<ThemeState>((set) => ({
}), }),
initTheme: () => set(() => { initTheme: () => set(() => {
const saved = localStorage.getItem('theme-preference'); const saved = localStorage.getItem('theme-preference');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const initialTheme = saved ? (saved as Theme) : 'dark';
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,5 +32,14 @@ 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

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

View File

@ -40,7 +40,7 @@
--color-info: #3b82f6; --color-info: #3b82f6;
/* ── Typography ── */ /* ── Typography ── */
--font-sans: 'Outfit', 'Inter', ui-sans-serif, system-ui, sans-serif; --font-sans: 'Poppins', '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,3 +594,71 @@ 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, BlogPost, Announcement } from '../types'; import type { User, Asset, Category, 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,19 +90,7 @@ 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') {
@ -263,23 +251,7 @@ 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' };
}, },
@ -287,6 +259,30 @@ 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, BlogPost, Announcement } from "../types"; import type { User, Asset, Category, Announcement } from "../types";
// Pre-defined categories // Pre-defined categories
export const SEED_CATEGORIES: Category[] = [ export const SEED_CATEGORIES: Category[] = [
@ -149,35 +149,7 @@ 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[] = [
@ -283,9 +255,7 @@ 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,3 +1,4 @@
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,9 +1,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from "react";
import { motion } from 'framer-motion'; import { useLocation } from "react-router-dom";
import type { Variants } from 'framer-motion'; import { motion } from "framer-motion";
import { UploadCloud, Search, File, CheckCircle } from 'lucide-react'; import type { Variants } from "framer-motion";
import { useAuthStore } from '../hooks/use-auth'; import {
import { axiosInstance } from '../services/axios'; UploadCloud, Search, File, Share2, Folder, Sparkles, Filter, LayoutGrid,
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,
@ -11,41 +15,109 @@ import {
requestDownload, requestDownload,
approveDownloadRequest, approveDownloadRequest,
rejectDownloadRequest, rejectDownloadRequest,
downloadAssetFile downloadAssetFile,
} from '../services/assets-api'; getAssetGroups,
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 { PageHeader } from '../components/ui/PageHeader'; import { ManageGroupsModal } from "../features/assets/components/ManageGroupsModal";
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 } from '../types/assets'; import type { Asset, Organization, TaxonomyMeta, AssetQueryFilters } from "../types/assets";
const containerVariants: Variants = { const containerVariants: Variants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.05 } } show: { opacity: 1, transition: { staggerChildren: 0.04 } },
}; };
const itemVariants: Variants = { const itemVariants: Variants = {
hidden: { opacity: 0, y: 15, scale: 0.98 }, hidden: { opacity: 0, y: 12, scale: 0.98 },
show: { opacity: 1, y: 0, scale: 1, transition: { type: 'spring', stiffness: 350, damping: 25 } } show: {
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[]>([]);
// Search & Filter // Layout Density State
const [searchQuery, setSearchQuery] = useState(''); const [viewMode, setViewMode] = useState<'grid' | 'compact' | 'table'>('grid');
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);
@ -54,9 +126,12 @@ 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();
@ -65,26 +140,81 @@ export const AssetsPage = () => {
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
try { try {
const assetsData = await getAssets(); const [assetsData, metaData, groupsData] = await Promise.all([
setAssets(assetsData); getAssets({ ...filters, search: searchQuery, sortBy }),
getTaxonomyMeta(),
getAssetGroups(),
]);
if (user?.role === 'ADMIN') { setAssets(assetsData);
setTaxonomyMeta(metaData);
setGroups(groupsData);
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);
}; };
@ -99,179 +229,437 @@ export const AssetsPage = () => {
setIsDetailsOpen(true); setIsDetailsOpen(true);
}; };
const handleDeleteAsset = async (id: string) => { const handleDeleteAsset = (id: string) => {
if (!window.confirm('Are you sure you want to permanently delete this asset?')) return; const asset = assets.find(a => a.id === id);
if (asset) {
setAssetToDelete({ id: asset.id, title: asset.title });
}
};
const confirmDeleteAsset = async () => {
if (!assetToDelete) return;
try { try {
await deleteAsset(id); await deleteAsset(assetToDelete.id);
success("Asset deleted successfully", `"${assetToDelete.title}" has been permanently removed.`);
setAssetToDelete(null);
await fetchData(); await fetchData();
} catch (err) { } catch (err: any) {
console.error('Failed to delete asset', err); error("Failed to delete asset", err.response?.data?.error || "Something went wrong.");
} }
}; };
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) { } catch (err: any) {
console.error('Failed to request download access', err); error("Failed to submit request", err.response?.data?.error || "Something went wrong.");
} }
}; };
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) { } catch (err: any) {
console.error('Failed to approve request', err); error("Failed to approve request", err.response?.data?.error || "Something went wrong.");
} }
}; };
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) { } catch (err: any) {
console.error('Failed to reject request', err); error("Failed to reject request", err.response?.data?.error || "Something went wrong.");
} }
}; };
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")
const downloadUrl = asset.url.startsWith('http')
? asset.url ? asset.url
: `${axiosInstance.defaults.baseURL?.replace('/api/v1', '')}${asset.url}`; : `${axiosInstance.defaults.baseURL?.replace("/api/v1", "")}${asset.url}`;
const a = document.createElement('a'); 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 => prev.map(item => setAssets(prev =>
item.id === asset.id ? { ...item, downloadsCount: item.downloadsCount + 1 } : item prev.map(item => item.id === asset.id ? { ...item, downloadsCount: item.downloadsCount + 1 } : item)
)); );
} catch (err) { } catch (err: any) {
console.error('Failed to process download', err); error("Download failed", err.response?.data?.error || "Could not retrieve asset file.");
} }
}; };
const CATEGORIES = ['ALL', 'Marketing', 'Presentations', 'Branding', 'Resources', 'Technical']; // Instant Client-side filtering & sorting
const filteredAssets = useMemo(() => {
const filteredAssets = assets.filter(asset => { let result = assets.filter((asset) => {
const query = searchQuery.toLowerCase().trim(); if (selectedCategory === "RECOMMENDED") {
return recommendedAssets.some((r) => r.id === asset.id);
const matchesSearch = !query || }
asset.title.toLowerCase().includes(query) || return true;
(asset.description && asset.description.toLowerCase().includes(query)) ||
(asset.categoryId && asset.categoryId.toLowerCase().includes(query)) ||
(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 sorted = [...result];
if (sortBy === 'oldest') {
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 =
(filters.verticalIds?.length || 0) +
(filters.contentTypes?.length || 0) +
(filters.subcategories?.length || 0) +
(filters.tags?.length || 0);
const pendingRequestsCount = assets.reduce((acc, asset) => { const pendingRequestsCount = assets.reduce((acc, asset) => {
return acc + (asset.downloadRequests?.filter(r => r.status === 'PENDING').length || 0); return (
acc + (asset.downloadRequests?.filter((r) => r.status === "PENDING").length || 0)
);
}, 0); }, 0);
return ( // Header component
<motion.div variants={containerVariants} initial="hidden" animate="show" className="w-full space-y-6 text-ink-900 animate-fade-in"> const headerNode = (
<PageHeader <PageHeader
title="Asset Library" title="Asset Discovery Platform"
subtitle="Securely manage, distribute, and track marketing collateral and partner resources." subtitle="Enterprise asset catalog featuring vertical domain search, multi-view density, and partner access controls."
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>
)}
{user?.role === 'ADMIN' && (
<button
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 */} // Toolbar component
<motion.div variants={itemVariants} className="flex flex-col md:flex-row gap-4 items-center pt-2"> const toolbarNode = (
<div className="relative flex-1 w-full group"> <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">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div className="flex flex-wrap items-center gap-3 flex-1 min-w-0">
<Search className="w-4 h-4 text-ink-400 group-focus-within:text-ink-900 transition-colors" />
</div> {/* Search Bar */}
<div className="relative w-full sm:w-[280px] shrink-0">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<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-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" 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"
placeholder="Search by title, desc, tag, category, URL..." placeholder="Search catalog titles, tags..."
/> />
</div> </div>
<div className="flex gap-2 w-full md:w-auto overflow-x-auto pb-1 md:pb-0 scrollbar-none"> {/* Premium Custom Sorting Dropdown */}
{CATEGORIES.map((cat) => ( <div className="relative shrink-0">
<button <button
key={cat} type="button"
onClick={() => setSelectedCategory(cat)} onClick={() => setIsSortOpen(!isSortOpen)}
className={`px-3 py-1.5 rounded-lg border text-xs font-bold transition-all whitespace-nowrap shadow-sm cursor-pointer ${ 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"
selectedCategory === cat >
? 'bg-ink-900 text-ink-0 border-ink-900' <ArrowUpDown className="w-3.5 h-3.5 text-amber-500" />
: 'bg-ink-0 text-ink-700 border-ink-200 hover:bg-ink-50' <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'
}`} }`}
> >
{cat} <span>{opt.label}</span>
{sortBy === opt.id && <Check className="w-3.5 h-3.5 text-amber-500" />}
</button> </button>
))} ))}
</div> </div>
</motion.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
onClick={() => setSelectedCategory("ALL")}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all ${selectedCategory === "ALL"
? "bg-slate-900 text-white shadow-xs"
: "text-slate-600 dark:text-slate-300 hover:bg-slate-200"
}`}
>
All Assets
</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>
{/* Grid Content */}
{loading ? ( {loading ? (
<div className="py-20 flex justify-center items-center"> <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 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>
) : filteredAssets.length === 0 ? (
<div className="py-12 text-center bg-ink-0 border border-ink-200 rounded-xl">
<File className="w-12 h-12 text-ink-300 mx-auto mb-4" />
<h3 className="text-lg font-bold text-ink-900">No assets found</h3>
<p className="text-ink-500 text-sm mt-1">There are no assets matching your criteria.</p>
</div> </div>
) : ( ) : (
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 pt-2"> <>
{filteredAssets.length === 0 ? (
<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-slate-300 mx-auto mb-4" />
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">No assets match your query</h3>
<p className="text-slate-500 text-xs mt-1 max-w-sm mx-auto">
Try broadening your search keywords or clearing active domain filters.
</p>
<button
onClick={() => { setSearchQuery(''); setFilters({}); }}
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"
>
Clear All Filters
</button>
</div>
) : viewMode === 'table' ? (
/* TABULAR LIST VIEW */
<AssetTableView
assets={filteredAssets}
selectedIds={selectedAssetIds}
recommendedIds={recommendedAssets.map(r => r.id)}
onToggleSelect={handleToggleSelectAsset}
onSelectAll={handleSelectAll}
onOpenAsset={openViewerModal}
onRequestDownload={(id) => {
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) => ( {filteredAssets.map((asset) => (
<AssetCard <motion.div
key={asset.id} 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} asset={asset}
user={user} user={user}
activeMenuId={activeMenuId} activeMenuId={activeMenuId}
@ -283,10 +671,39 @@ export const AssetsPage = () => {
onOpenViewer={openViewerModal} onOpenViewer={openViewerModal}
onDownload={handleDownload} onDownload={handleDownload}
onRequestDownload={handleRequestDownload} 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> </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
@ -310,8 +727,10 @@ 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}
/> />
@ -345,7 +764,45 @@ export const AssetsPage = () => {
onReject={handleRejectRequest} onReject={handleRejectRequest}
/> />
</motion.div> <ManageGroupsModal
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

@ -0,0 +1,401 @@
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,9 +1,10 @@
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 } from 'lucide-react'; import { FolderKanban, FileSignature, Users, ArrowUpRight, Activity, Zap, ShieldCheck, Globe } 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 },
@ -54,14 +55,26 @@ 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'
} }
]; ];
return ( const isAdmin = user?.role === 'ADMIN';
<motion.div variants={containerVariants} initial="hidden" animate="show" className="w-full space-y-6 text-ink-900 animate-fade-in">
// Header component
const headerNode = (
<PageHeader <PageHeader
title={`Welcome back, ${user?.email?.split('@')[0]}`} title={`Welcome back, ${user?.email?.split('@')[0]}`}
subtitle={`You are authenticated as ${user?.role}. Manage your channel network, monitor compliance, and distribute assets globally.`} 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={ 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="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" /> <div className="w-1 h-1 rounded-full bg-ink-0 animate-pulse" />
@ -69,8 +82,18 @@ export const DashboardPage = () => {
</div> </div>
} }
/> />
);
return (
<PageLayout header={headerNode}>
<motion.div
variants={containerVariants}
initial="hidden"
animate="show"
className="p-5 space-y-6 flex flex-col min-h-0 flex-1 overflow-y-auto"
>
{/* Stats Grid */} {/* Stats Grid */}
{user?.role === 'ADMIN' && (
<motion.div variants={itemVariants} className="grid grid-cols-1 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">
{[ {[
{ label: 'Global Network Uptime', value: '99.99%', icon: Activity, trend: '+0.01%' }, { label: 'Global Network Uptime', value: '99.99%', icon: Activity, trend: '+0.01%' },
@ -89,9 +112,10 @@ export const DashboardPage = () => {
</div> </div>
))} ))}
</motion.div> </motion.div>
)}
{/* Main Action Cards */} {/* Main Action Cards */}
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2"> <motion.div variants={itemVariants} className={`grid grid-cols-1 ${CARDS.length === 2 ? 'md:grid-cols-2' : 'md:grid-cols-3'} gap-4 pt-2`}>
{CARDS.map((card, idx) => ( {CARDS.map((card, idx) => (
<Link key={idx} to={card.path} className="group relative block h-full"> <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="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">
@ -109,10 +133,10 @@ export const DashboardPage = () => {
<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 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} {card.metrics}
</div> </div>
<h3 className="text-lg font-bold text-ink-900 mb-2 tracking-tight"> <h3 className="text-lg action-card-title font-bold text-ink-900 mb-2 tracking-tight">
{card.title} {card.title}
</h3> </h3>
<p className="text-ink-500 leading-normal text-xs font-medium"> <p className="text-ink-500 action-card-desc leading-normal text-xs font-medium">
{card.description} {card.description}
</p> </p>
</div> </div>
@ -121,6 +145,7 @@ export const DashboardPage = () => {
))} ))}
</motion.div> </motion.div>
</motion.div> </motion.div>
</PageLayout>
); );
}; };
export default DashboardPage; export default DashboardPage;

View File

@ -0,0 +1,276 @@
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,12 +1,13 @@
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 } from 'lucide-react'; import { Shield, CheckCircle, AlertCircle, ChevronRight, KeyRound, Eye, EyeOff } 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();
@ -16,6 +17,8 @@ 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);
@ -25,9 +28,10 @@ export const InvitePage: React.FC = () => {
return; return;
} }
const validateToken = async () => { // verify token on mount
const verifyToken = async () => {
try { try {
const response = await axios.get(`${import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1'}/auth/invite/${token}`); const response = await axiosInstance.get(`/auth/invite/${token}`);
setEmail(response.data.email); setEmail(response.data.email);
setStatus('valid'); setStatus('valid');
} catch (err) { } catch (err) {
@ -35,7 +39,7 @@ export const InvitePage: React.FC = () => {
} }
}; };
validateToken(); verifyToken();
}, [token]); }, [token]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@ -47,8 +51,8 @@ export const InvitePage: React.FC = () => {
return; return;
} }
if (password.length < 6) { if (password.length < 8) {
setError('Password must be at least 6 characters'); setError('Password must be at least 8 characters long');
return; return;
} }
@ -59,10 +63,24 @@ export const InvitePage: React.FC = () => {
password password
}); });
setAuth(response.data); // Show success toast
success('Account activated successfully!');
// 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'); navigate('/onboarding');
}
}, 1000);
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.error || 'Failed to accept invite'); setError(err.response?.data?.detail || err.response?.data?.error || 'Activation failed');
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
@ -70,7 +88,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/30 border-t-ink-900 rounded-full animate-spin" /> <div className="w-8 h-8 border-4 border-ink-900/35 border-t-ink-900 rounded-full animate-spin" />
</div> </div>
); );
} }
@ -78,13 +96,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-3xl p-8 border border-ink-200 text-center shadow-2xl"> <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-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 font-bold text-ink-900 mb-2">Invalid or Expired Link</h2> <h2 className="text-2xl login-form-title font-bold text-ink-900 mb-2">Invalid or Expired Link</h2>
<p className="text-ink-500 text-sm mb-8"> <p className="text-sm login-form-subtitle text-ink-500 max-w-md mx-auto mb-6">
This invitation link is no longer valid. Please request a new invitation from your administrator. The onboarding invitation link is invalid, expired, or has already been used. 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
@ -100,12 +118,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-10"> <div className="text-center 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"> <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 font-extrabold tracking-tight mb-2">Welcome to Tech4Biz</h1> <h1 className="text-3xl login-title font-extrabold tracking-tight mb-2">Welcome to Tech4Biz</h1>
<p className="text-sm font-medium text-ink-500"> <p className="text-sm login-desc 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>
@ -130,13 +148,20 @@ 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="password" type={showPassword ? 'text' : 'password'}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
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" 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"
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>
@ -147,13 +172,20 @@ 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="password" type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
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" 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"
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,21 +1,25 @@
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 { Hexagon, Lock, Mail, ArrowRight, Eye, EyeOff } from 'lucide-react'; import { 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.string().min(6, { message: 'Password must be at least 6 characters' }), password: z
.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);
@ -32,106 +36,165 @@ export const LoginPage = () => {
const onSubmit = async (data: LoginFormValues) => { const onSubmit = async (data: LoginFormValues) => {
try { try {
setError(null); setError(null);
const response = await loginUser({ email: data.email, password: data.password }); const response = await loginUser({
email: data.email,
password: data.password,
});
setAuth(response); setAuth(response);
if (response.user.role === 'ADMIN') { success("Successfully signed in", `Welcome back, ${response.user.email}!`);
navigate('/admin'); if (response.user.role === "ADMIN") {
navigate("/admin");
} else { } else {
navigate('/client'); navigate("/client");
} }
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.error || 'Authentication failed. Verify credentials.'); const errMsg = 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-center items-center p-4 relative overflow-hidden font-sans transition-colors duration-500"> <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">
{/* Monochromatic Soft Background Blurs */} {/* Monochromatic Soft Background Blurs */}
<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 top-1/4 left-1/4 w-[600px] h-[600px] 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" /> <div className="absolute bottom-1/4 right-1/4 w-[700px] h-[700px] 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-md relative z-10" 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"
> >
<div className="bg-ink-0 border border-ink-200 rounded-[2rem] shadow-2xl p-12 relative overflow-hidden"> {/* Left Column - Partner Explanation */}
<div className="md:col-span-6 lg:col-span-7 flex flex-col justify-center text-left">
<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">
Enterprise Scale, Secure Delivery
</h1>
<p className="text-ink-500 text-sm login-desc mt-5 font-medium leading-relaxed max-w-lg">
Tech4Biz Channel Partner Portal facilitates authenticated, low-latency distribution of physical asset coordinates, design assets, and cryptographically verified legal agreements.
</p>
</div>
</div>
{/* Right Column - Login Component */}
<div className="md:col-span-6 lg:col-span-5 w-full max-w-sm justify-self-center md:justify-self-end">
<div className="bg-ink-0 border border-ink-200 rounded-2xl shadow-xl p-8 md:p-10 relative overflow-hidden">
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-ink-300 to-transparent" /> <div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-ink-300 to-transparent" />
<div className="flex flex-col items-center mb-12 text-center"> <div className="flex flex-col items-center mb-8 text-center">
<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"> <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">
<Hexagon className="text-ink-0 w-10 h-10 absolute" /> <img src="/logo.png" alt="Tech4Biz" className="w-full h-full object-contain" />
</div> </div>
<h2 className="text-3xl font-extrabold text-ink-900 tracking-tight">Channel Portal</h2> <h2 className="text-2xl login-form-title font-bold text-ink-900 tracking-tight">
<p className="text-ink-500 text-sm mt-3 font-medium uppercase tracking-widest">Authorized Access Only</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 && ( {error && (
<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"> <motion.div
<div className="w-2 h-2 rounded-full bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]" /> 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-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} {error}
</motion.div> </motion.div>
)} )}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<div className="space-y-2"> <div className="space-y-1.5">
<label className="block text-[11px] font-bold text-ink-500 uppercase tracking-widest">Work Email</label> <label className="block text-[10px] font-bold text-ink-500 uppercase tracking-wider">
Work Email
</label>
<div className="relative group"> <div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none"> <div className="absolute inset-y-0 left-0 pl-3.5 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'}`} /> <Mail
className={`w-4 h-4 transition-colors ${errors.email ? "text-red-400" : "text-ink-400 group-focus-within:text-ink-900"}`}
/>
</div> </div>
<input <input
type="email" type="email"
{...register('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`} 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="admin@tech4biz.com" placeholder="Enter Your Registered Email.."
/> />
</div> </div>
{errors.email && <p className="text-red-500 dark:text-red-400 text-xs mt-1.5 font-bold">{errors.email.message}</p>} {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="space-y-2"> <div className="space-y-1.5">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<label className="block text-[11px] font-bold text-ink-500 uppercase tracking-widest">Password</label> <label className="block text-[10px] font-bold text-ink-500 uppercase tracking-wider">
<a href="#" className="text-[11px] font-bold text-ink-500 hover:text-ink-900 transition-colors tracking-wider">RECOVERY?</a> 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"> <div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none"> <div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
<Lock className={`w-5 h-5 transition-colors ${errors.password ? 'text-red-400' : 'text-ink-400 group-focus-within:text-ink-900'}`} /> <Lock
className={`w-4 h-4 transition-colors ${errors.password ? "text-red-400" : "text-ink-400 group-focus-within:text-ink-900"}`}
/>
</div> </div>
<input <input
type={showPassword ? 'text' : 'password'} type={showPassword ? "text" : "password"}
{...register('password')} {...register("password")}
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`} 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`}
placeholder="••••••••" placeholder="Enter Your Password"
/> />
<button <button
type="button" type="button"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-4 flex items-center text-ink-400 hover:text-ink-950 focus:outline-none transition-colors" 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-5 h-5" /> : <Eye className="w-5 h-5" />} {showPassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button> </button>
</div> </div>
{errors.password && <p className="text-red-500 dark:text-red-400 text-xs mt-1.5 font-bold">{errors.password.message}</p>} {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>
<button <button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
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" 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"
> >
{isSubmitting ? 'AUTHENTICATING...' : 'SECURE SIGN IN'} {isSubmitting ? "AUTHENTICATING..." : "SECURE SIGN IN"}
{!isSubmitting && <ArrowRight className="w-5 h-5 group-hover:translate-x-1.5 transition-transform" />} {!isSubmitting && (
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
)}
</button> </button>
</form> </form>
</div> </div>
</div>
</motion.div>
<p className="text-center text-ink-400 text-xs mt-10 font-bold tracking-widest uppercase"> <p className="text-center text-ink-400 text-[10px] font-bold tracking-wider uppercase mt-8 relative z-10">
© 2026 Tech4Biz Solutions. © 2026 Tech4Biz Solutions.
</p> </p>
</motion.div>
</div> </div>
); );
}; };

View File

@ -10,10 +10,12 @@ 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);
@ -36,6 +38,8 @@ 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 () => {
@ -44,18 +48,73 @@ export const OnboardingPage: React.FC = () => {
getActiveLegalDocument('NDA'), getActiveLegalDocument('NDA'),
getActiveLegalDocument('MSA') getActiveLegalDocument('MSA')
]); ]);
const ndaIsSkipped = !!ndaData.skipped;
const msaIsSkipped = !!msaData.skipped;
setNdaSkipped(ndaIsSkipped);
setMsaSkipped(msaIsSkipped);
if (!ndaIsSkipped) {
setNdaContent(ndaData.content); setNdaContent(ndaData.content);
setNdaPdfUrl(ndaData.pdfUrl || null); setNdaPdfUrl(ndaData.pdfUrl || null);
}
if (!msaIsSkipped) {
setMsaContent(msaData.content); setMsaContent(msaData.content);
setMsaPdfUrl(msaData.pdfUrl || null); 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);
} }
}; };
if (user?.onboardingStatus === 'APPROVED') {
navigate('/client');
} else if (user?.onboardingStatus === 'PENDING_APPROVAL') {
setStep(3);
setLoadingDocs(false);
} else {
fetchDocs(); fetchDocs();
}, []); }
}, [user, navigate]);
const handlePrint = (type: DocumentType) => { const handlePrint = (type: DocumentType) => {
const content = type === 'NDA' ? ndaContent : msaContent; const content = type === 'NDA' ? ndaContent : msaContent;
@ -88,7 +147,7 @@ export const OnboardingPage: React.FC = () => {
}; };
const renderDocumentViewer = (type: DocumentType) => { const renderDocumentViewer = (type: DocumentType) => {
const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', ''); const fileHost = (import.meta.env.VITE_API_URL || '/api/v1').replace('/api/v1', '');
const pdfUrl = type === 'NDA' ? ndaPdfUrl : msaPdfUrl; const pdfUrl = type === 'NDA' ? ndaPdfUrl : msaPdfUrl;
if (pdfUrl) { if (pdfUrl) {
@ -143,22 +202,6 @@ 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;
@ -191,8 +234,10 @@ 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);
} catch (error) { success("Document uploaded successfully", `Signed ${type} has been uploaded.`);
console.error(`Failed to upload ${type}:`, error); } catch (err: any) {
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);
} }
@ -210,14 +255,22 @@ 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) {
await checkAuth();
setStep(3);
} else {
setStep(2); setStep(2);
}
} else { } else {
await checkAuth(); await checkAuth();
setStep(3); // PENDING_APPROVAL setStep(3); // PENDING_APPROVAL
} }
} catch (error) { } catch (err: any) {
console.error(`Failed to submit ${type}:`, error); console.error(`Failed to submit ${type}:`, err);
toastError("Submission failed", err.response?.data?.error || `Failed to submit signed ${type}.`);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -242,7 +295,7 @@ export const OnboardingPage: React.FC = () => {
{mode === 'draw' ? ( {mode === 'draw' ? (
<div className="flex-1 flex flex-col justify-center"> <div className="flex-1 flex flex-col justify-center">
<SignatureCapture onSignatureComplete={setSignature} /> <SignatureCapture onSignatureComplete={setSignature} initialSignature={signature} />
{signature && ( {signature && (
<div className="mt-6 flex items-center justify-between p-4 bg-ink-100 border border-ink-300 rounded-xl"> <div className="mt-6 flex items-center justify-between p-4 bg-ink-100 border border-ink-300 rounded-xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -320,24 +373,28 @@ 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 ${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 ${ndaSkipped || step > 1 ? 'bg-ink-900' : step === 1 ? 'border-2 border-ink-900 bg-ink-0' : 'bg-ink-200'}`}>
{step > 1 ? <CheckCircle className="w-3.5 h-3.5 text-ink-0" /> : <div className="w-2 h-2 rounded-full bg-ink-900" />} {(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" />}
</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: step > 1 ? '100%' : '0%' }}></div> <div className="w-full bg-ink-900/20 transition-all duration-500" style={{ height: (ndaSkipped || step > 1) ? '100%' : '0%' }}></div>
</div> </div>
<h3 className={`font-bold transition-colors ${step >= 1 ? 'text-ink-900' : 'text-ink-400'}`}>NDA Agreement</h3> <h3 className={`font-bold transition-colors ${ndaSkipped ? 'text-ink-400 line-through' : step >= 1 ? 'text-ink-900' : 'text-ink-400'}`}>
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 ${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 ${msaSkipped || step > 2 ? 'bg-ink-900' : step === 2 ? 'border-2 border-ink-900 bg-ink-0' : 'bg-ink-200'}`}>
{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" />} {(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" />}
</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: step > 2 ? '100%' : '0%' }}></div> <div className="w-full bg-ink-900/20 transition-all duration-500" style={{ height: (msaSkipped || step > 2) ? '100%' : '0%' }}></div>
</div> </div>
<h3 className={`font-bold transition-colors ${step >= 2 ? 'text-ink-900' : 'text-ink-400'}`}>MSA Agreement</h3> <h3 className={`font-bold transition-colors ${msaSkipped ? 'text-ink-400 line-through' : step >= 2 ? 'text-ink-900' : 'text-ink-400'}`}>
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>
@ -373,9 +430,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 font-extrabold tracking-tight mb-2">Non-Disclosure Agreement</h2> <h2 className="text-3xl login-title font-extrabold tracking-tight mb-2">Non-Disclosure Agreement</h2>
<p className="text-sm font-medium text-ink-500"> <p className="text-sm login-desc text-ink-500 mt-1.5 font-medium max-w-md">
Please provide your signature or upload a signed copy of our standard NDA to proceed. Please review the mutual NDA terms carefully before signing.
</p> </p>
</div> </div>
@ -389,7 +446,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...' : 'Continue to MSA'} {isSubmitting ? 'Processing...' : msaSkipped ? 'Submit' : 'Continue to MSA'}
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</button> </button>
</div> </div>
@ -403,9 +460,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 font-extrabold tracking-tight mb-2">Master Services Agreement</h2> <h2 className="text-3xl login-title font-extrabold tracking-tight mb-2">Master Services Agreement</h2>
<p className="text-sm font-medium text-ink-500"> <p className="text-sm login-desc text-ink-500 mt-1.5 font-medium max-w-md">
Sign the MSA to finalize your compliance requirements and enter the approval queue. Please review the master services partnership terms before signing.
</p> </p>
</div> </div>
@ -414,9 +471,13 @@ 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}
@ -444,11 +505,15 @@ 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">Signed</span> <span className="text-xs font-bold text-ink-900 bg-ink-100 px-2 py-1 rounded-md">
{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">Signed</span> <span className="text-xs font-bold text-ink-900 bg-ink-100 px-2 py-1 rounded-md">
{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

@ -0,0 +1,518 @@
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,18 +1,72 @@
import React from 'react'; import React, { useState } from "react";
import { CheckCircle, Clock, Search, XCircle } from 'lucide-react'; import { CheckCircle, Clock, Search, XCircle, Eye } from "lucide-react";
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from "framer-motion";
import { usePendingPartnersQuery } from '../../hooks/use-legal-query'; import { usePendingPartnersQuery } from "../../hooks/use-legal-query";
import { useApprovePartnerMutation } from '../../hooks/use-legal-mutation'; import { useApprovePartnerMutation } from "../../hooks/use-legal-mutation";
import PageHeader from '../../components/ui/PageHeader'; import PageHeader from "../../components/ui/PageHeader";
import Button from "../../components/ui/Button";
import { 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("");
const approvePartner = (partnerId: string) => { // Preview Modal state
approveMutation.mutate(partnerId); const [selectedPartner, setSelectedPartner] = useState<any | null>(null);
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]">
@ -21,71 +75,95 @@ export const ApprovalsPage: React.FC = () => {
); );
} }
return ( // Header component
<div className="w-full space-y-6 animate-fade-in text-ink-900"> const headerNode = (
<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> // Toolbar component
} const toolbarNode = (
actions={ <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="relative"> <div className="flex items-center gap-2 flex-1 max-w-md">
<div className="relative flex-1">
<Search className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" /> <Search className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input <input
type="text" type="text"
placeholder="Search pending partners..." value={searchTerm}
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" onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search pending partners by email..."
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"
/> />
</div> </div>
} </div>
/> </div>
);
{/* List */} return (
<div className="bg-ink-0 rounded-xl border border-ink-200 overflow-hidden shadow-sm"> <PageLayout header={headerNode} toolbar={toolbarNode}>
<div className="overflow-x-auto"> {/* List Container */}
<div className="flex-1 w-full overflow-x-auto">
<table className="w-full text-left text-sm whitespace-nowrap"> <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"> <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> <tr>
<th className="px-4 py-2.5">Partner</th> <th className="px-5 py-3">Partner</th>
<th className="px-4 py-2.5">NDA Status</th> <th className="px-5 py-3">NDA Document</th>
<th className="px-4 py-2.5">MSA Status</th> <th className="px-5 py-3">MSA Document</th>
<th className="px-4 py-2.5 text-right">Actions</th> <th className="px-5 py-3 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-ink-200"> <tbody className="divide-y divide-ink-200 bg-ink-0">
<AnimatePresence> <AnimatePresence>
{partners.length === 0 ? ( {filteredPartners.length === 0 ? (
<tr> <tr>
<td colSpan={4} className="px-4 py-8 text-center"> <td colSpan={4} className="px-5 py-12 text-center">
<div className="w-12 h-12 rounded-full bg-ink-100 flex items-center justify-center mx-auto mb-4"> <div className="w-12 h-12 rounded-full bg-ink-50 flex items-center justify-center mx-auto mb-4 border border-ink-200">
<CheckCircle className="w-6 h-6 text-ink-900" /> <CheckCircle className="w-6 h-6 text-ink-900" />
</div> </div>
<p className="text-ink-900 font-bold text-sm">Queue is empty</p> <p className="text-ink-900 font-bold text-sm">
<p className="text-ink-500 text-xs mt-1">All partners have been reviewed.</p> Queue is empty
</p>
<p className="text-ink-500 text-xs mt-1">
All partners have been reviewed.
</p>
</td> </td>
</tr> </tr>
) : ( ) : (
partners.map(partner => { filteredPartners.map((partner) => {
const nda = partner.acceptances.find(a => a.document.type === 'NDA'); const nda = partner.acceptances.find(
const msa = partner.acceptances.find(a => a.document.type === 'MSA'); (a) => a.document.type === "NDA",
);
const msa = partner.acceptances.find(
(a) => a.document.type === "MSA",
);
const partnerVerified = verifiedDocs[partner.id] || {
nda: false,
msa: false,
};
return ( return (
<motion.tr <motion.tr
key={partner.id} key={partner.id}
initial={{ opacity: 1 }} initial={{ opacity: 1 }}
exit={{ opacity: 0, x: -20, backgroundColor: 'rgba(0, 0, 0, 0.02)' }} exit={{
opacity: 0,
x: -20,
backgroundColor: "rgba(0, 0, 0, 0.02)",
}}
className="hover:bg-ink-50 transition-colors group" className="hover:bg-ink-50 transition-colors group"
> >
<td className="px-4 py-3"> <td className="px-5 py-4">
<div className="flex items-center gap-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"> <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()} {partner.email.charAt(0).toUpperCase()}
</div> </div>
<div> <div>
<p className="font-bold text-ink-900 text-sm">{partner.email}</p> <p className="font-bold text-ink-900 text-sm">
{partner.email}
</p>
<p className="text-xs text-ink-500 flex items-center gap-1"> <p className="text-xs text-ink-500 flex items-center gap-1">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
{new Date(partner.createdAt).toLocaleDateString()} {new Date(partner.createdAt).toLocaleDateString()}
@ -93,50 +171,119 @@ export const ApprovalsPage: React.FC = () => {
</div> </div>
</div> </div>
</td> </td>
<td className="px-4 py-3"> <td className="px-5 py-4">
{nda ? ( {nda ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-ink-900" /> <CheckCircle className="w-4 h-4 text-emerald-600" />
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md"> <span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
{nda.documentUrl ? 'Uploaded PDF' : 'Digital Sign'} {nda.documentUrl
? "Uploaded PDF"
: "Digital Sign"}
</span> </span>
{nda.documentUrl && ( {nda.signatureBase64 && (
<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 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>
) : ( ) : (
<div className="flex items-center gap-2 text-ink-400"> <div className="flex items-center gap-2 text-ink-400">
<XCircle className="w-4 h-4" /> <XCircle className="w-4 h-4" />
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">Missing</span> <span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">
Missing
</span>
</div> </div>
)} )}
</td> </td>
<td className="px-4 py-3"> <td className="px-5 py-4">
{msa ? ( {msa ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-ink-900" /> <CheckCircle className="w-4 h-4 text-emerald-600" />
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md"> <span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
{msa.documentUrl ? 'Uploaded PDF' : 'Digital Sign'} {msa.documentUrl
? "Uploaded PDF"
: "Digital Sign"}
</span> </span>
{msa.documentUrl && ( {msa.signatureBase64 && (
<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 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>
) : ( ) : (
<div className="flex items-center gap-2 text-ink-400"> <div className="flex items-center gap-2 text-ink-400">
<XCircle className="w-4 h-4" /> <XCircle className="w-4 h-4" />
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">Missing</span> <span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">
Missing
</span>
</div> </div>
)} )}
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-5 py-4 text-right">
<button <Button
onClick={() => approvePartner(partner.id)} onClick={() =>
disabled={!nda || !msa || (approveMutation.isPending && approveMutation.variables === partner.id)} approvePartner(partner.id, partner.email)
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" }
disabled={
approveMutation.isPending &&
approveMutation.variables === partner.id
}
variant="primary"
size="sm"
> >
{approveMutation.isPending && approveMutation.variables === partner.id ? 'Approving...' : 'Approve Access'} {approveMutation.isPending &&
</button> approveMutation.variables === partner.id
? "Approving..."
: "Approve Access"}
</Button>
</td> </td>
</motion.tr> </motion.tr>
); );
@ -146,8 +293,36 @@ export const ApprovalsPage: React.FC = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</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

@ -0,0 +1,351 @@
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