intial commit

This commit is contained in:
Yaseen 2026-07-09 09:31:15 +05:30
commit f17eb1ea57
81 changed files with 16153 additions and 0 deletions

27
.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
uploads/*

5
Channel-Backend/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/src/generated/prisma

View File

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

3274
Channel-Backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
{
"name": "channel-server",
"version": "1.0.0",
"description": "Backend API for Channel Partner Onboarding",
"main": "dist/app.js",
"scripts": {
"start": "node dist/app.js",
"dev": "nodemon src/app.ts",
"build": "tsc"
},
"dependencies": {
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.2.0",
"pg": "^8.22.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.2.0",
"@types/node": "^20.12.7",
"@types/pg": "^8.20.0",
"nodemon": "^3.1.0",
"prisma": "^7.8.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
}
}

View File

@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

View File

@ -0,0 +1,96 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
enum Role {
ADMIN
PARTNER_USER
}
enum DocumentType {
NDA
MSA
}
model Organization {
id String @id @default(uuid())
name String
status String @default("ACTIVE")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
role Role @default(PARTNER_USER)
mfaEnabled Boolean @default(true)
mfaSecret String?
inviteToken String? @unique
inviteTokenExp DateTime?
organizationId String?
onboardingStatus String @default("PENDING_ONBOARDING")
organization Organization? @relation(fields: [organizationId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
acceptances LegalAcceptance[]
auditLogs AuditLog[]
}
model Asset {
id String @id @default(uuid())
title String
type String
size Int
url String
version Int @default(1)
uploadedBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Folder {
id String @id @default(uuid())
name String
parentId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model LegalDocument {
id String @id @default(uuid())
type DocumentType
version String
content String @db.Text
isActive Boolean @default(false)
createdAt DateTime @default(now())
acceptances LegalAcceptance[]
}
model LegalAcceptance {
id String @id @default(uuid())
docId String
userId String
ipAddress String
signatureHash String?
documentUrl String?
acceptedAt DateTime @default(now())
document LegalDocument @relation(fields: [docId], references: [id])
user User @relation(fields: [userId], references: [id])
}
model AuditLog {
id String @id @default(uuid())
actorId String
action String
resource String
metadata Json?
createdAt DateTime @default(now())
actor User @relation(fields: [actorId], references: [id])
}

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

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

27
Channel-Backend/seed.ts Normal file
View File

@ -0,0 +1,27 @@
import dotenv from 'dotenv';
dotenv.config();
import prisma from './src/utils/db';
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

@ -0,0 +1,52 @@
import express, { Express, Request, Response } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import dotenv from 'dotenv';
import path from 'path';
import cookieParser from 'cookie-parser';
dotenv.config();
import { errorHandler } from './middleware/error.middleware';
import authRoutes from './routes/auth.routes';
import assetRoutes from './routes/asset.routes';
import orgRoutes from './routes/organization.routes';
import legalRoutes from './routes/legal.routes';
const app: Express = express();
const PORT = process.env.PORT || 5000;
app.use(helmet());
app.use(cors({ origin: true, credentials: true }));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
// Static uploads directory
app.use('/uploads', express.static(path.join(__dirname, '../../uploads')));
// API Routes
app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/assets', assetRoutes);
app.use('/api/v1/organizations', orgRoutes);
app.use('/api/v1/legal', legalRoutes);
app.get('/api/v1/health', (req: Request, res: Response) => {
res.status(200).json({ status: 'success', message: 'API is fully functional and real.' });
});
app.use(errorHandler);
// Monolithic approach: Serve React build in production
if (process.env.NODE_ENV === 'production') {
const clientBuildPath = path.join(__dirname, '../../Channel-Frontend/dist');
app.use(express.static(clientBuildPath));
app.get('*', (req: Request, res: Response) => {
res.sendFile(path.join(clientBuildPath, 'index.html'));
});
}
app.listen(PORT, () => {
console.log(`[Server]: API is running on http://localhost:${PORT}`);
});

View File

@ -0,0 +1,38 @@
import { Request, Response, NextFunction } from 'express';
import { AssetService } from '../services/asset.service';
import { AuthRequest } from '../middleware/auth.middleware';
export class AssetController {
private assetService = new AssetService();
public uploadAsset = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
if (!req.file) throw new Error('No file uploaded');
const uploaderId = req.user?.userId || 'system';
const asset = await this.assetService.createAsset({
title: req.body.title || req.file.originalname,
type: req.file.mimetype,
size: req.file.size,
url: `/uploads/${req.file.filename}`,
uploadedBy: uploaderId,
});
res.status(201).json(asset);
} catch (err) { next(err); }
}
public listAssets = async (req: Request, res: Response, next: NextFunction) => {
try {
const assets = await this.assetService.getAssets();
res.status(200).json(assets);
} catch(err) { next(err); }
}
public deleteAsset = async (req: Request, res: Response, next: NextFunction) => {
try {
await this.assetService.deleteAsset(req.params.id);
res.status(204).send();
} catch(err) { next(err); }
}
}

View File

@ -0,0 +1,96 @@
import { Request, Response, NextFunction } from 'express';
import { AuthService } from '../services/auth.service';
import { z } from 'zod';
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
});
const registerSchema = loginSchema.extend({
role: z.enum(['ADMIN', 'PARTNER_USER']).optional(),
organizationId: z.string().uuid().optional(),
});
export class AuthController {
private authService = new AuthService();
public register = async (req: Request, res: Response, next: NextFunction) => {
try {
const data = registerSchema.parse(req.body);
const user = await this.authService.register(data);
res.status(201).json({ message: 'User registered successfully', user });
} catch(err) { next(err); }
}
public invitePartner = async (req: Request, res: Response, next: NextFunction) => {
try {
const { email, organizationId } = z.object({ email: z.string().email(), organizationId: z.string().uuid().optional() }).parse(req.body);
const result = await this.authService.invitePartner(email, organizationId);
res.status(201).json({ message: 'Invite created', token: result.inviteToken });
} catch(err) { next(err); }
}
public validateInvite = async (req: Request, res: Response, next: NextFunction) => {
try {
const { token } = req.params;
const result = await this.authService.validateInvite(token);
res.status(200).json(result);
} catch(err) { next(err); }
}
public acceptInvite = async (req: Request, res: Response, next: NextFunction) => {
try {
const { token, password } = z.object({ token: z.string(), password: z.string().min(6) }).parse(req.body);
const result = await this.authService.acceptInvite(token, password);
res.cookie('refreshToken', result.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
res.status(200).json({
user: result.user,
accessToken: result.accessToken,
});
} catch(err) { next(err); }
}
public login = async (req: Request, res: Response, next: NextFunction) => {
try {
const { email, password } = loginSchema.parse(req.body);
const result = await this.authService.login(email, password);
res.cookie('refreshToken', result.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
res.status(200).json({
user: result.user,
accessToken: result.accessToken,
});
} catch (err) { next(err); }
};
public refresh = async (req: Request, res: Response, next: NextFunction) => {
try {
const token = req.cookies.refreshToken;
if (!token) {
return res.status(401).json({ error: 'No refresh token provided' });
}
const result = await this.authService.refresh(token);
res.status(200).json(result);
} catch (err) { next(err); }
};
public listPartners = async (req: Request, res: Response, next: NextFunction) => {
try {
const partners = await this.authService.listPartners();
res.status(200).json(partners);
} catch(err) { next(err); }
};
}

View File

@ -0,0 +1,101 @@
import { Request, Response, NextFunction } from 'express';
import { LegalService } from '../services/legal.service';
import { AuthRequest } from '../middleware/auth.middleware';
import { DocumentType } from '@prisma/client';
import { z } from 'zod';
const docSchema = z.object({
type: z.enum(['NDA', 'MSA']),
version: z.string(),
content: z.string().min(1),
});
export class LegalController {
private legalService = new LegalService();
public create = async (req: Request, res: Response, next: NextFunction) => {
try {
const data = docSchema.parse(req.body);
const doc = await this.legalService.createDocument(data as any);
res.status(201).json(doc);
} catch(err) { next(err); }
}
public getActive = async (req: Request, res: Response, next: NextFunction) => {
try {
const type = req.params.type.toUpperCase() as DocumentType;
const doc = await this.legalService.getActiveDocument(type);
if (!doc) {
return res.status(404).json({ error: 'No active document found' });
}
res.status(200).json(doc);
} catch(err) { next(err); }
}
public accept = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { docId } = req.body;
const userId = req.user!.userId;
const ipAddress = req.ip || req.socket.remoteAddress || 'unknown';
const acceptance = await this.legalService.recordAcceptance(docId, userId, ipAddress);
res.status(201).json(acceptance);
} catch(err) { next(err); }
}
public sign = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { documentType, signatureBase64, documentUrl } = req.body;
const userId = req.user!.userId;
const ipAddress = req.ip || req.socket.remoteAddress || 'unknown';
if (!signatureBase64 && !documentUrl) {
return res.status(400).json({ error: 'Must provide either signatureBase64 or documentUrl' });
}
// Get active document for this type
const type = documentType.toUpperCase() as DocumentType;
const activeDoc = await this.legalService.getActiveDocument(type);
if (!activeDoc) {
return res.status(404).json({ error: 'No active document found to sign' });
}
// Hash the signature if provided
let signatureHash = null;
if (signatureBase64) {
const crypto = require('crypto');
signatureHash = crypto.createHash('sha256').update(signatureBase64).digest('hex');
}
const acceptance = await this.legalService.recordAcceptance(activeDoc.id, userId, ipAddress, signatureHash || undefined, documentUrl);
// Check if they completed all onboarding steps
await this.legalService.checkOnboardingCompletion(userId);
res.status(201).json(acceptance);
} catch(err) { next(err); }
}
public myAcceptances = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const acceptances = await this.legalService.getAcceptances(req.user!.userId);
res.status(200).json(acceptances);
} catch(err) { next(err); }
}
public getPending = async (req: Request, res: Response, next: NextFunction) => {
try {
const pending = await this.legalService.getPendingApprovals();
res.status(200).json(pending);
} catch(err) { next(err); }
}
public approvePartner = async (req: Request, res: Response, next: NextFunction) => {
try {
const { partnerId } = req.params;
await this.legalService.approvePartner(partnerId);
res.status(200).json({ message: 'Partner approved successfully' });
} catch(err) { next(err); }
}
}

View File

@ -0,0 +1,49 @@
import { Request, Response, NextFunction } from 'express';
import { OrganizationService } from '../services/organization.service';
import { z } from 'zod';
const orgSchema = z.object({
name: z.string().min(2),
});
const orgUpdateSchema = z.object({
name: z.string().min(2).optional(),
status: z.string().optional(),
});
export class OrganizationController {
private orgService = new OrganizationService();
public create = async (req: Request, res: Response, next: NextFunction) => {
try {
const data = orgSchema.parse(req.body);
const org = await this.orgService.create(data);
res.status(201).json(org);
} catch(err) { next(err); }
}
public getAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const orgs = await this.orgService.getAll();
res.status(200).json(orgs);
} catch(err) { next(err); }
}
public getById = async (req: Request, res: Response, next: NextFunction) => {
try {
const org = await this.orgService.getById(req.params.id);
if (!org) {
return res.status(404).json({ error: 'Organization not found' });
}
res.status(200).json(org);
} catch(err) { next(err); }
}
public update = async (req: Request, res: Response, next: NextFunction) => {
try {
const data = orgUpdateSchema.parse(req.body);
const org = await this.orgService.update(req.params.id, data);
res.status(200).json(org);
} catch(err) { next(err); }
}
}

View File

@ -0,0 +1,32 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { AppError } from '../utils/errors';
export interface AuthRequest extends Request {
user?: { userId: string; role: string };
}
export const authenticate = (req: AuthRequest, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return next(new AppError('Unauthorized - No token provided', 401));
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'secret') as { userId: string; role: string };
req.user = decoded;
next();
} catch (error) {
next(new AppError('Unauthorized - Invalid token', 401));
}
};
export const requireRole = (role: string) => {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (req.user?.role !== role) {
return next(new AppError('Forbidden: Insufficient permissions', 403));
}
next();
};
};

View File

@ -0,0 +1,17 @@
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../utils/errors';
import { ZodError } from 'zod';
export const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => {
console.error('[Error]:', err.message);
if (err instanceof AppError) {
return res.status(err.statusCode).json({ error: err.message });
}
if (err instanceof ZodError) {
return res.status(400).json({ error: 'Validation Error', details: err.issues });
}
return res.status(500).json({ error: 'Internal Server Error' });
};

View File

@ -0,0 +1,19 @@
import multer from 'multer';
import path from 'path';
import fs from 'fs';
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = path.join(__dirname, '../../../uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + path.extname(file.originalname));
},
});
export const upload = multer({ storage });

View File

@ -0,0 +1,15 @@
import { Router } from 'express';
import { AssetController } from '../controllers/asset.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
import { upload } from '../middleware/upload.middleware';
const router = Router();
const assetController = new AssetController();
router.use(authenticate);
router.post('/upload', requireRole('ADMIN'), upload.single('file'), assetController.uploadAsset);
router.get('/', assetController.listAssets);
router.delete('/:id', requireRole('ADMIN'), assetController.deleteAsset);
export default router;

View File

@ -0,0 +1,19 @@
import { Router } from 'express';
import { AuthController } from '../controllers/auth.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const authController = new AuthController();
router.post('/register', authController.register); // Keep for initial admin setup in dev
router.post('/login', authController.login);
router.post('/refresh', authController.refresh);
// Invite Flow
router.post('/invite', authenticate, requireRole('ADMIN'), authController.invitePartner);
router.get('/invite/:token', authController.validateInvite);
router.post('/invite/accept', authController.acceptInvite);
router.get('/partners', authenticate, requireRole('ADMIN'), authController.listPartners);
export default router;

View File

@ -0,0 +1,23 @@
import { Router } from 'express';
import { LegalController } from '../controllers/legal.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const legalController = new LegalController();
router.use(authenticate);
// Admins create documents
router.post('/documents', requireRole('ADMIN'), legalController.create);
// Anyone can view active docs and sign them
router.get('/documents/active/:type', legalController.getActive);
router.post('/accept', legalController.accept);
router.post('/sign', legalController.sign);
router.get('/my-acceptances', legalController.myAcceptances);
// Admin Approval Routes
router.get('/pending', requireRole('ADMIN'), legalController.getPending);
router.post('/approve/:partnerId', requireRole('ADMIN'), legalController.approvePartner);
export default router;

View File

@ -0,0 +1,18 @@
import { Router } from 'express';
import { OrganizationController } from '../controllers/organization.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const orgController = new OrganizationController();
router.use(authenticate);
// Only admins manage orgs
router.use(requireRole('ADMIN'));
router.post('/', orgController.create);
router.get('/', orgController.getAll);
router.get('/:id', orgController.getById);
router.patch('/:id', orgController.update);
export default router;

View File

@ -0,0 +1,21 @@
import prisma from '../utils/db';
export class AssetService {
public async createAsset(data: any) {
return await prisma.asset.create({ data });
}
public async getAssets() {
return await prisma.asset.findMany({
orderBy: { createdAt: 'desc' }
});
}
public async getAssetById(id: string) {
return await prisma.asset.findUnique({ where: { id } });
}
public async deleteAsset(id: string) {
return await prisma.asset.delete({ where: { id } });
}
}

View File

@ -0,0 +1,131 @@
import prisma from '../utils/db';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { AppError } from '../utils/errors';
export class AuthService {
public async register(data: any) {
const existing = await prisma.user.findUnique({ where: { email: data.email } });
if (existing) throw new AppError('Email already in use', 400);
const passwordHash = await bcrypt.hash(data.password, 10);
const user = await prisma.user.create({
data: {
email: data.email,
passwordHash,
role: data.role || 'PARTNER_USER',
organizationId: data.organizationId || null,
onboardingStatus: data.role === 'ADMIN' ? 'APPROVED' : 'PENDING_ONBOARDING'
}
});
const { passwordHash: _, ...userWithoutPassword } = user;
return userWithoutPassword;
}
public async invitePartner(email: string, organizationId?: string) {
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) throw new AppError('Email already in use', 400);
const crypto = require('crypto');
const inviteToken = crypto.randomBytes(32).toString('hex');
const inviteTokenExp = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
const user = await prisma.user.create({
data: {
email,
passwordHash: '', // Set on accept
role: 'PARTNER_USER',
organizationId: organizationId || null,
inviteToken,
inviteTokenExp,
onboardingStatus: 'PENDING_ONBOARDING',
mfaEnabled: true,
}
});
// In a real app, send email here
return { inviteToken };
}
public async validateInvite(token: string) {
const user = await prisma.user.findUnique({ where: { inviteToken: token } });
if (!user || !user.inviteTokenExp || user.inviteTokenExp < new Date()) {
throw new AppError('Invalid or expired invite token', 400);
}
return { email: user.email };
}
public async acceptInvite(token: string, passwordString: string) {
const user = await prisma.user.findUnique({ where: { inviteToken: token } });
if (!user || !user.inviteTokenExp || user.inviteTokenExp < new Date()) {
throw new AppError('Invalid or expired invite token', 400);
}
const passwordHash = await bcrypt.hash(passwordString, 10);
const updatedUser = await prisma.user.update({
where: { id: user.id },
data: {
passwordHash,
inviteToken: null,
inviteTokenExp: null,
}
});
const secret = process.env.JWT_SECRET || 'secret';
const accessToken = jwt.sign({ userId: updatedUser.id, role: updatedUser.role }, secret, { expiresIn: '15m' });
const refreshToken = jwt.sign({ userId: updatedUser.id }, secret, { expiresIn: '7d' });
const { passwordHash: _, ...userWithoutPassword } = updatedUser;
return { user: userWithoutPassword, accessToken, refreshToken };
}
public async login(email: string, passwordString: string) {
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
throw new AppError('Invalid credentials', 401);
}
const isMatch = await bcrypt.compare(passwordString, user.passwordHash);
if (!isMatch) {
throw new AppError('Invalid credentials', 401);
}
const secret = process.env.JWT_SECRET || 'secret';
const accessToken = jwt.sign({ userId: user.id, role: user.role }, secret, { expiresIn: '15m' });
const refreshToken = jwt.sign({ userId: user.id }, secret, { expiresIn: '7d' });
const { passwordHash: _, ...userWithoutPassword } = user;
return { user: userWithoutPassword, accessToken, refreshToken };
}
public async refresh(refreshToken: string) {
const secret = process.env.JWT_SECRET || 'secret';
try {
const decoded = jwt.verify(refreshToken, secret) as any;
const user = await prisma.user.findUnique({ where: { id: decoded.userId } });
if (!user) throw new AppError('Invalid refresh token', 401);
const newAccessToken = jwt.sign({ userId: user.id, role: user.role }, secret, { expiresIn: '15m' });
return { accessToken: newAccessToken };
} catch(err) {
throw new AppError('Invalid or expired refresh token', 401);
}
}
public async listPartners() {
return await prisma.user.findMany({
where: { role: 'PARTNER_USER' },
select: {
id: true,
email: true,
onboardingStatus: true,
mfaEnabled: true,
createdAt: true,
updatedAt: true,
organizationId: true,
},
orderBy: { createdAt: 'desc' },
});
}
}

View File

@ -0,0 +1,93 @@
import prisma from "../utils/db";
import { DocumentType } from "@prisma/client";
export class LegalService {
public async createDocument(data: {
type: DocumentType;
version: string;
content: string;
}) {
// Deprecate older active versions of the same type
if (data.type) {
await prisma.legalDocument.updateMany({
where: { type: data.type, isActive: true },
data: { isActive: false },
});
}
return await prisma.legalDocument.create({
data: { ...data, isActive: true },
});
}
public async getActiveDocument(type: DocumentType) {
return await prisma.legalDocument.findFirst({
where: { type, isActive: true },
});
}
public async recordAcceptance(
docId: string,
userId: string,
ipAddress: string,
signatureHash?: string,
documentUrl?: string,
) {
// Record acceptance
const acceptance = await prisma.legalAcceptance.create({
data: { docId, userId, ipAddress, signatureHash, documentUrl },
});
return acceptance;
}
public async checkOnboardingCompletion(userId: string) {
// Check if both NDA and MSA have been accepted
const acceptances = await prisma.legalAcceptance.findMany({
where: { userId },
include: { document: true }
});
const hasNDA = acceptances.some(a => a.document.type === 'NDA');
const hasMSA = acceptances.some(a => a.document.type === 'MSA');
if (hasNDA && hasMSA) {
// Both signed, ready for admin approval. We don't automatically set to APPROVED.
// But we can ensure it's PENDING_APPROVAL.
await prisma.user.update({
where: { id: userId },
data: { onboardingStatus: 'PENDING_APPROVAL' }
});
}
return { hasNDA, hasMSA };
}
public async getAcceptances(userId: string) {
return await prisma.legalAcceptance.findMany({
where: { userId },
include: { document: true },
});
}
public async getPendingApprovals() {
return await prisma.user.findMany({
where: { onboardingStatus: 'PENDING_APPROVAL' },
select: {
id: true,
email: true,
createdAt: true,
acceptances: {
include: { document: true }
}
}
});
}
public async approvePartner(userId: string) {
return await prisma.user.update({
where: { id: userId },
data: { onboardingStatus: 'APPROVED' }
});
}
}

View File

@ -0,0 +1,32 @@
import prisma from '../utils/db';
export class OrganizationService {
public async create(data: { name: string }) {
return await prisma.organization.create({ data });
}
public async getAll() {
return await prisma.organization.findMany({
include: {
_count: {
select: { users: true }
}
},
orderBy: { createdAt: 'desc' }
});
}
public async getById(id: string) {
return await prisma.organization.findUnique({
where: { id },
include: { users: true }
});
}
public async update(id: string, data: { name?: string, status?: string }) {
return await prisma.organization.update({
where: { id },
data
});
}
}

View File

@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client';
import { Pool } from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
const connectionString = process.env.DATABASE_URL;
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
export default prisma;

View File

@ -0,0 +1,8 @@
export class AppError extends Error {
public statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
Error.captureStackTrace(this, this.constructor);
}
}

View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}

View File

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tech4Biz Client & Admin Portal</title>
<!-- Premium Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2610
Channel-Frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
{
"name": "tech4biz-channel",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.4.0",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-router": "^1.170.17",
"axios": "^1.18.1",
"framer-motion": "^12.42.2",
"lucide-react": "^1.23.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.81.0",
"react-router-dom": "^7.18.1",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"autoprefixer": "^10.5.2",
"oxlint": "^1.71.0",
"postcss": "^8.5.16",
"tailwindcss": "^4.3.2",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -0,0 +1 @@
/* Boilersplate App.css emptied to avoid styling conflicts with Tailwind */

View File

@ -0,0 +1,25 @@
import { RouterProvider } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { router } from "./app/router";
import { useEffect } from 'react';
import { useThemeStore } from './hooks/use-theme';
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false }
}
});
export const App = () => {
const initTheme = useThemeStore(state => state.initTheme);
useEffect(() => {
initTheme();
}, [initTheme]);
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
};

View File

@ -0,0 +1,170 @@
import React, { useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useThemeStore } from '../../hooks/use-theme';
import { useAuthStore } from '../../hooks/use-auth';
import { ShieldCheck, BarChart3, ClipboardCheck, FolderGit2, BookCopy, Users, LogOut, Menu, X, Sun, Moon, ChevronRight } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
export const AdminLayout: React.FC = () => {
const { user, logout } = useAuthStore();
const { theme, toggleTheme } = useThemeStore();
const location = useLocation();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const handleLogout = () => {
logout();
navigate('/login');
};
const navItems = [
{ name: 'Partners', path: '/admin/partners', icon: Users },
{ name: 'Approvals Queue', path: '/admin/approvals', icon: ClipboardCheck },
{ name: 'Manage Catalog', path: '/admin/assets', icon: FolderGit2 },
{ name: 'Analytics', path: '/admin/analytics', icon: BarChart3 },
{ name: 'Blog CMS', path: '/admin/blog', icon: BookCopy }
];
return (
<div className="min-h-screen flex flex-col md:flex-row bg-slate-50 dark:bg-[#050505] text-slate-900 dark:text-white font-sans transition-colors duration-500 selection:bg-blue-500/30 overflow-hidden">
{/* ── Desktop Sidebar ── */}
<aside className="hidden md:flex md:w-[280px] md:flex-col md:sticky md:top-0 md:h-screen bg-white/80 dark:bg-[#0A0A0A]/90 backdrop-blur-3xl border-r border-slate-200/50 dark:border-white/5 shrink-0 z-20 shadow-[4px_0_24px_rgba(0,0,0,0.02)] dark:shadow-none">
{/* Branding */}
<div className="h-24 flex items-center px-8 border-b border-slate-100 dark:border-white/5">
<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-slate-900 to-slate-700 dark:from-white dark:to-slate-300 shadow-lg group-hover:scale-105 transition-all duration-300">
<ShieldCheck className="w-5 h-5 text-white dark:text-slate-900" />
</div>
<div className="flex flex-col">
<span className="text-lg font-extrabold tracking-tight leading-none text-slate-900 dark:text-white">Tech4Biz</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500 dark:text-white/40 mt-1">Admin Console</span>
</div>
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-8 space-y-2 overflow-y-auto">
{navItems.map(item => {
const Icon = item.icon;
const isActive = location.pathname === item.path;
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group ${
isActive
? 'bg-slate-900 dark:bg-white text-white dark:text-slate-900 shadow-md'
: 'text-slate-500 dark:text-white/50 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5'
}`}
>
<Icon className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? 'text-white dark:text-slate-900' : 'text-slate-400 dark:text-white/40 group-hover:text-slate-900 dark:group-hover:text-white'}`} />
<span>{item.name}</span>
{isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
</Link>
);
})}
</nav>
{/* Footer */}
<div className="p-5 border-t border-slate-100 dark:border-white/5 bg-slate-50/50 dark:bg-transparent">
<div className="flex items-center justify-between mb-4">
<p className="text-[10px] font-bold text-slate-400 dark:text-white/30 uppercase tracking-widest">Appearance</p>
<button
onClick={toggleTheme}
className="p-2 rounded-lg bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 text-slate-500 dark:text-white/40 hover:text-slate-900 dark:hover:text-white hover:shadow-md transition-all group"
>
{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>
</div>
<div className="flex items-center gap-3 p-3 rounded-2xl bg-white dark:bg-white/5 border border-slate-200 dark:border-white/5 mb-4 shadow-sm dark:shadow-inner">
<div className="w-9 h-9 rounded-full bg-slate-900 dark:bg-white flex items-center justify-center text-white dark:text-slate-900 font-bold shadow-sm">
{user?.email?.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-slate-900 dark:text-white truncate">{user?.email}</p>
<p className="text-[10px] uppercase font-bold text-slate-500 dark:text-white/40 tracking-wider truncate">Administrator</p>
</div>
</div>
<button
onClick={handleLogout}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl text-sm font-bold tracking-wide text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200 dark:hover:border-red-500/20"
>
<LogOut className="w-4 h-4" />
<span>Sign Out</span>
</button>
</div>
</aside>
{/* ── Main Content Area ── */}
<main className="flex-1 flex flex-col relative w-full overflow-y-auto">
{/* Ambient Background Glows */}
<div className="fixed top-0 right-0 w-[600px] h-[600px] bg-slate-200/50 dark:bg-white/5 rounded-full blur-[120px] pointer-events-none -z-10" />
<div className="fixed bottom-0 left-[20%] w-[500px] h-[500px] bg-slate-200/30 dark:bg-white/5 rounded-full blur-[100px] pointer-events-none -z-10" />
{/* Mobile Header */}
<header className="md:hidden sticky top-0 z-40 h-16 flex items-center justify-between px-4 bg-white/80 dark:bg-[#0A0A0A]/80 backdrop-blur-xl border-b border-slate-200 dark:border-white/10 shadow-sm">
<Link to="/admin" className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-slate-900 dark:bg-white">
<ShieldCheck className="w-4 h-4 text-white dark:text-slate-900" />
</div>
<span className="text-sm font-extrabold tracking-tight text-slate-900 dark:text-white">Admin Console</span>
</Link>
<button onClick={() => setMobileOpen(true)} className="p-2 rounded-lg border border-slate-200 dark:border-white/10 text-slate-600 dark:text-white/60">
<Menu className="w-5 h-5" />
</button>
</header>
{/* Mobile Menu Drawer */}
<AnimatePresence>
{mobileOpen && (
<>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setMobileOpen(false)} className="fixed inset-0 bg-slate-900/40 dark:bg-black/60 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-white dark:bg-[#0A0A0A] shadow-2xl z-50 border-l border-slate-200 dark:border-white/10 flex flex-col md:hidden">
<div className="p-4 border-b border-slate-100 dark:border-white/10 flex items-center justify-between">
<span className="font-extrabold text-slate-900 dark:text-white">Menu</span>
<button onClick={() => setMobileOpen(false)} className="p-2 rounded-lg bg-slate-100 dark:bg-white/10">
<X className="w-4 h-4" />
</button>
</div>
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
{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-slate-900 dark:bg-white text-white dark:text-slate-900' : 'text-slate-600 dark:text-white/60'}`}>
<item.icon className="w-5 h-5" />
{item.name}
</Link>
))}
</nav>
<div className="p-4 border-t border-slate-100 dark:border-white/10 space-y-4">
<button onClick={toggleTheme} className="flex items-center justify-between w-full p-3 rounded-xl bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 text-sm font-bold text-slate-600 dark:text-white/60">
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-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 font-bold text-sm">Sign Out</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
<div className="flex-1 w-full max-w-[1600px] px-6 py-10 md:px-12 mx-auto relative z-10">
<Outlet />
</div>
{/* Footer */}
<footer className="border-t border-slate-200/50 dark:border-white/5 bg-white/50 dark:bg-[#0A0A0A]/50 backdrop-blur-md mt-auto">
<div className="max-w-[1600px] mx-auto px-6 md:px-12 py-6 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-slate-500 dark:text-white/40">
<p>© 2026 Tech4Biz Solutions Inc. Admin Console.</p>
<div className="flex gap-6">
<span className="hover:text-slate-900 dark:hover:text-white cursor-pointer transition-colors">Security Compliance</span>
<span className="hover:text-slate-900 dark:hover:text-white cursor-pointer transition-colors">System Status</span>
</div>
</div>
</footer>
</main>
</div>
);
};
export default AdminLayout;

View File

@ -0,0 +1,169 @@
import React, { useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useThemeStore } from '../../hooks/use-theme';
import { useAuthStore } from '../../hooks/use-auth';
import { ShieldCheck, Cpu, BookOpen, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
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 = () => {
const { user, logout } = useAuthStore();
const { theme, toggleTheme } = useThemeStore();
const location = useLocation();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const handleLogout = () => {
logout();
navigate('/login');
};
const isApproved = user?.onboardingStatus === 'APPROVED';
return (
<div className="min-h-screen flex flex-col md:flex-row bg-slate-50 dark:bg-[#050505] text-slate-900 dark:text-white font-sans transition-colors duration-500 selection:bg-blue-500/30 overflow-hidden">
{/* ── Desktop Sidebar ── */}
<aside className="hidden md:flex md:w-[280px] md:flex-col md:sticky md:top-0 md:h-screen bg-white/80 dark:bg-[#0A0A0A]/90 backdrop-blur-3xl border-r border-slate-200/50 dark:border-white/5 shrink-0 z-20 shadow-[4px_0_24px_rgba(0,0,0,0.02)] dark:shadow-none">
{/* Branding */}
<div className="h-24 flex items-center px-8 border-b border-slate-100 dark:border-white/5">
<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-blue-600 to-indigo-600 dark:from-blue-500 dark:to-indigo-500 shadow-[0_4px_20px_rgba(37,99,235,0.3)] group-hover:scale-105 transition-all duration-300">
<ShieldCheck className="w-5 h-5 text-white" />
</div>
<div className="flex flex-col">
<span className="text-lg font-extrabold tracking-tight leading-none text-slate-900 dark:text-white">Tech4Biz</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-blue-600 dark:text-blue-400 mt-1">Client Portal</span>
</div>
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-8 space-y-2 overflow-y-auto">
{navItems.map(item => {
const Icon = item.icon;
const isActive = location.pathname === item.path;
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group ${
isActive
? 'bg-blue-50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 shadow-sm border border-blue-100 dark:border-blue-500/20'
: 'text-slate-500 dark:text-white/50 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5'
}`}
>
<Icon className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? 'text-blue-600 dark:text-blue-400' : 'text-slate-400 dark:text-white/40 group-hover:text-slate-900 dark:group-hover:text-white'}`} />
<span>{item.label}</span>
{isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
</Link>
);
})}
</nav>
{/* Footer */}
<div className="p-5 border-t border-slate-100 dark:border-white/5 bg-slate-50/50 dark:bg-transparent">
<div className="flex items-center justify-between mb-4">
<p className="text-[10px] font-bold text-slate-400 dark:text-white/30 uppercase tracking-widest">Appearance</p>
<button
onClick={toggleTheme}
className="p-2 rounded-lg bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 text-slate-500 dark:text-white/40 hover:text-slate-900 dark:hover:text-white hover:shadow-md transition-all group"
>
{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>
</div>
<div className="flex flex-col gap-2 p-4 rounded-2xl bg-white dark:bg-white/5 border border-slate-200 dark:border-white/5 mb-4 shadow-sm dark:shadow-inner">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400 dark:text-white/40">Status</span>
<div className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-bold border ${isApproved ? 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-200 dark:border-emerald-500/20' : 'bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-500/20'}`}>
{isApproved ? <CheckCircle className="w-3 h-3" /> : <Clock className="w-3 h-3" />}
{isApproved ? 'Verified' : 'Pending'}
</div>
</div>
<p className="text-xs font-bold text-slate-900 dark:text-white truncate" title={user?.email || ''}>{user?.email}</p>
</div>
<button
onClick={handleLogout}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl text-sm font-bold tracking-wide text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200 dark:hover:border-red-500/20"
>
<LogOut className="w-4 h-4" />
<span>Sign Out</span>
</button>
</div>
</aside>
{/* ── Main Content Area ── */}
<main className="flex-1 flex flex-col relative w-full overflow-y-auto bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-blue-50/50 via-slate-50 to-slate-50 dark:from-blue-900/10 dark:via-[#050505] dark:to-[#050505]">
{/* Ambient Glow */}
<div className="fixed top-0 right-0 w-[500px] h-[500px] bg-blue-500/5 dark:bg-blue-500/10 rounded-full blur-[150px] pointer-events-none -z-10" />
{/* Mobile Header */}
<header className="md:hidden sticky top-0 z-40 h-16 flex items-center justify-between px-4 bg-white/80 dark:bg-[#0A0A0A]/80 backdrop-blur-xl border-b border-slate-200 dark:border-white/10 shadow-sm">
<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-blue-600 to-indigo-600 dark:from-blue-500 dark:to-indigo-500">
<ShieldCheck className="w-4 h-4 text-white" />
</div>
<span className="text-sm font-extrabold tracking-tight text-slate-900 dark:text-white">Client Portal</span>
</Link>
<button onClick={() => setMobileOpen(true)} className="p-2 rounded-lg border border-slate-200 dark:border-white/10 text-slate-600 dark:text-white/60">
<Menu className="w-5 h-5" />
</button>
</header>
{/* Mobile Menu Drawer */}
<AnimatePresence>
{mobileOpen && (
<>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setMobileOpen(false)} className="fixed inset-0 bg-slate-900/40 dark:bg-black/60 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-white dark:bg-[#0A0A0A] shadow-2xl z-50 border-l border-slate-200 dark:border-white/10 flex flex-col md:hidden">
<div className="p-4 border-b border-slate-100 dark:border-white/10 flex items-center justify-between">
<span className="font-extrabold text-slate-900 dark:text-white">Menu</span>
<button onClick={() => setMobileOpen(false)} className="p-2 rounded-lg bg-slate-100 dark:bg-white/10">
<X className="w-4 h-4" />
</button>
</div>
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
{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-blue-50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 border border-blue-100 dark:border-blue-500/20' : 'text-slate-600 dark:text-white/60'}`}>
<item.icon className="w-5 h-5" />
{item.label}
</Link>
))}
</nav>
<div className="p-4 border-t border-slate-100 dark:border-white/10 space-y-4">
<button onClick={toggleTheme} className="flex items-center justify-between w-full p-3 rounded-xl bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 text-sm font-bold text-slate-600 dark:text-white/60">
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-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 font-bold text-sm">Sign Out</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
<div className="flex-1 w-full max-w-[1600px] px-6 py-10 md:px-12 mx-auto relative z-10">
<Outlet />
</div>
{/* Footer */}
<footer className="border-t border-slate-200/50 dark:border-white/5 bg-white/50 dark:bg-[#0A0A0A]/50 backdrop-blur-md mt-auto">
<div className="max-w-[1600px] mx-auto px-6 md:px-12 py-6 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-slate-500 dark:text-white/40">
<p>© 2026 Tech4Biz Solutions Inc. All rights reserved.</p>
<div className="flex gap-6">
<span className="hover:text-slate-900 dark:hover:text-white cursor-pointer transition-colors">Security</span>
<span className="hover:text-slate-900 dark:hover:text-white cursor-pointer transition-colors">Terms</span>
</div>
</div>
</footer>
</main>
</div>
);
};
export default ClientLayout;

View File

@ -0,0 +1,37 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../hooks/use-auth';
export const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated } = useAuthStore();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
};
export const RequireRole: React.FC<{ children: React.ReactNode; role: 'ADMIN' | 'PARTNER_USER' }> = ({ children, role }) => {
const { user } = useAuthStore();
if (user?.role !== role) {
// If they are an admin trying to access client route, they should be redirected
if (user?.role === 'ADMIN') return <Navigate to="/admin" replace />;
if (user?.role === 'PARTNER_USER') return <Navigate to="/client" replace />;
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
export const RequireOnboardingComplete: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user } = useAuthStore();
// If the user hasn't completed onboarding, redirect to the onboarding wizard
if (user && user.onboardingStatus !== 'APPROVED') {
return <Navigate to="/onboarding" replace />;
}
return <>{children}</>;
};

View File

@ -0,0 +1,156 @@
import React, { Suspense } from 'react';
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { RequireAuth, RequireRole, RequireOnboardingComplete } from './guards';
// Layouts
const ClientLayout = React.lazy(() => import('../layouts/ClientLayout'));
const AdminLayout = React.lazy(() => import('../layouts/AdminLayout'));
// Pages (Lazy Loaded)
const LoginPage = React.lazy(() => import('../../pages/LoginPage').then(m => ({ default: m.LoginPage })));
const InvitePage = React.lazy(() => import('../../pages/InvitePage').then(m => ({ default: m.InvitePage })));
const DashboardPage = React.lazy(() => import('../../pages/DashboardPage').then(m => ({ default: m.DashboardPage })));
const 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 })));
// Dummy Components for routing
const LoadingFallback = () => (
<div className="flex h-screen w-full items-center justify-center bg-ink-50">
<div className="w-8 h-8 border-4 border-primary-400 border-t-transparent rounded-full animate-spin"></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([
{
path: '/',
element: <Navigate to="/login" replace />,
},
{
path: '/login',
element: (
<Suspense fallback={<LoadingFallback />}>
<LoginPage />
</Suspense>
),
},
{
path: '/invite',
element: (
<Suspense fallback={<LoadingFallback />}>
<InvitePage />
</Suspense>
),
},
{
path: '/onboarding',
element: (
<RequireAuth>
<RequireRole role="PARTNER_USER">
<Suspense fallback={<LoadingFallback />}>
<OnboardingPage />
</Suspense>
</RequireRole>
</RequireAuth>
),
},
{
path: '/client',
element: (
<RequireAuth>
<RequireRole role="PARTNER_USER">
<RequireOnboardingComplete>
<Suspense fallback={<LoadingFallback />}>
<ClientLayout />
</Suspense>
</RequireOnboardingComplete>
</RequireRole>
</RequireAuth>
),
children: [
{
index: true,
element: (
<Suspense fallback={<LoadingFallback />}>
<DashboardPage />
</Suspense>
),
},
{
path: 'assets',
element: (
<Suspense fallback={<LoadingFallback />}>
<AssetsPage />
</Suspense>
),
},
{
path: 'agreements',
element: <LegalPage />,
},
{
path: 'blog',
element: <BlogPage />,
}
],
},
{
path: '/admin',
element: (
<RequireAuth>
<RequireRole role="ADMIN">
<Suspense fallback={<LoadingFallback />}>
<AdminLayout />
</Suspense>
</RequireRole>
</RequireAuth>
),
children: [
{
index: true,
element: <DirectoryPage />,
},
{
path: 'assets',
element: (
<Suspense fallback={<LoadingFallback />}>
<AssetsPage />
</Suspense>
),
},
{
path: 'legal',
element: <LegalPage />,
},
{
path: 'analytics',
element: <AnalyticsPage />,
},
{
path: 'blog',
element: <div>Blog Management Coming Soon</div>
},
{
path: 'approvals',
element: (
<Suspense fallback={<LoadingFallback />}>
<ApprovalsPage />
</Suspense>
)
},
{
path: 'partners',
element: (
<Suspense fallback={<LoadingFallback />}>
<DirectoryPage />
</Suspense>
)
}
]
}
]);

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -0,0 +1,98 @@
import { Outlet, Link } from '@tanstack/react-router';
import { useAuthStore } from '../../hooks/use-auth';
import { useThemeStore } from '../../hooks/use-theme';
import { motion } from 'framer-motion';
import { LayoutDashboard, FolderKanban, FileSignature, Users, LogOut, Hexagon, Sun, Moon } from 'lucide-react';
export const MainLayout = () => {
const { isAuthenticated, logout, user } = useAuthStore();
const { theme, toggleTheme } = useThemeStore();
if (!isAuthenticated) {
return <Outlet />;
}
const NAV_ITEMS = [
{ name: 'Dashboard', path: '/', icon: LayoutDashboard },
{ name: 'Asset Library', path: '/assets', icon: FolderKanban },
{ name: 'Legal Documents', path: '/legal', icon: FileSignature },
{ name: 'Directory', path: '/directory', icon: Users },
];
return (
<div className="flex h-screen bg-slate-50 dark:bg-[#050505] text-slate-900 dark:text-white overflow-hidden font-sans selection:bg-blue-500/30 transition-colors duration-500">
{/* Sidebar */}
<motion.aside
initial={{ x: -300 }}
animate={{ x: 0 }}
className="w-72 bg-white/80 dark:bg-[#0A0A0A]/90 backdrop-blur-3xl border-r border-slate-200 dark:border-white/5 flex flex-col relative z-20 shadow-xl dark:shadow-none"
>
<div className="h-24 flex items-center px-8 border-b border-slate-200 dark:border-white/5">
<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-blue-600 to-cyan-400 shadow-[0_0_30px_rgba(37,99,235,0.3)]">
<Hexagon className="text-white w-6 h-6 absolute" />
</div>
<span className="font-extrabold text-xl tracking-tight bg-gradient-to-r from-slate-900 to-slate-600 dark:from-white dark:to-white/50 bg-clip-text text-transparent">Tech4Biz</span>
</div>
</div>
<nav className="flex-1 px-4 py-8 space-y-2 overflow-y-auto">
{NAV_ITEMS.map((item) => (
<Link
key={item.path}
to={item.path}
className="flex items-center gap-4 px-4 py-3.5 rounded-xl transition-all duration-300 text-slate-500 dark:text-white/40 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5 group"
activeProps={{ className: 'bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-500/20 shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05)]' }}
activeOptions={{ exact: item.path === '/' }}
>
<item.icon className="w-5 h-5 transition-transform group-hover:scale-110" />
<span className="font-semibold tracking-wide text-sm">{item.name}</span>
</Link>
))}
</nav>
<div className="p-5 border-t border-slate-200 dark:border-white/5 bg-slate-50 dark:bg-[#0A0A0A]">
<div className="flex items-center justify-between mb-4">
<p className="text-[10px] font-bold text-slate-400 dark:text-white/30 uppercase tracking-widest">Theme Preference</p>
<button
onClick={toggleTheme}
className="p-2 rounded-lg bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 text-slate-500 dark:text-white/40 hover:text-slate-900 dark:hover:text-white hover:shadow-md transition-all group"
>
{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>
</div>
<div className="flex items-center gap-4 p-3.5 rounded-2xl bg-white dark:bg-white/5 border border-slate-200 dark:border-white/5 mb-4 shadow-sm dark:shadow-inner">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-blue-500 flex items-center justify-center text-white text-sm font-bold shadow-lg border border-white/10">
{user?.email?.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-slate-900 dark:text-white truncate">{user?.email}</p>
<p className="text-xs text-slate-500 dark:text-white/40 font-medium truncate tracking-wide">{user?.role}</p>
</div>
</div>
<button
onClick={() => {
logout();
window.location.href = '/login';
}}
className="w-full flex items-center justify-center gap-2.5 px-4 py-3 rounded-xl text-sm font-bold tracking-wide text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200 dark:hover:border-red-500/20"
>
<LogOut className="w-4 h-4" />
SECURE LOGOUT
</button>
</div>
</motion.aside>
{/* Main Content */}
<main className="flex-1 flex flex-col relative overflow-hidden bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-blue-50/50 via-slate-50 to-slate-50 dark:from-blue-900/10 dark:via-[#050505] dark:to-[#050505] transition-colors duration-500">
{/* Ambient Glow */}
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-blue-500/5 dark:bg-blue-500/10 rounded-full blur-[150px] pointer-events-none" />
<div className="absolute bottom-0 left-1/4 w-[400px] h-[400px] bg-purple-500/5 dark:bg-purple-500/5 rounded-full blur-[120px] pointer-events-none" />
<div className="flex-1 overflow-y-auto p-10 relative z-10">
<Outlet />
</div>
</main>
</div>
);
};

View File

@ -0,0 +1,386 @@
import React, { useRef, useState, useEffect } from "react";
import type { SignaturePayload } from "../../types";
interface SignatureCaptureProps {
onSave: (payload: SignaturePayload) => void;
onCancel?: () => void;
}
export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
onSave,
onCancel,
}) => {
const [activeTab, setActiveTab] = useState<"draw" | "type" | "upload">(
"draw",
);
const [typedName, setTypedName] = useState("");
const [typedFont, setTypedFont] = useState<
"font-serif" | "font-mono" | "font-sans-cursive"
>("font-sans-cursive");
const [uploadedImage, setUploadedImage] = useState<string | null>(null);
// Canvas drawing state
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [isDrawing, setIsDrawing] = useState(false);
const lastPos = useRef({ x: 0, y: 0 });
// Get canvas context and configure brush
const getContext = (): CanvasRenderingContext2D | null => {
if (!canvasRef.current) return null;
const ctx = canvasRef.current.getContext("2d");
if (ctx) {
ctx.lineWidth = 2.5;
ctx.lineCap = "round";
ctx.strokeStyle = "#477A2B"; // primary-800 (green/ink accent, no black)
}
return ctx;
};
const getCoordinates = (e: React.MouseEvent | React.TouchEvent) => {
if (!canvasRef.current) return { x: 0, y: 0 };
const rect = canvasRef.current.getBoundingClientRect();
// Check if it's a touch event
if ("touches" in e) {
if (e.touches.length === 0) return { x: 0, y: 0 };
return {
x: e.touches[0].clientX - rect.left,
y: e.touches[0].clientY - rect.top,
};
} else {
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
}
};
const startDrawing = (
e:
| React.MouseEvent<HTMLCanvasElement>
| React.TouchEvent<HTMLCanvasElement>,
) => {
e.preventDefault();
const pos = getCoordinates(e);
lastPos.current = pos;
setIsDrawing(true);
};
const draw = (
e:
| React.MouseEvent<HTMLCanvasElement>
| React.TouchEvent<HTMLCanvasElement>,
) => {
if (!isDrawing) return;
e.preventDefault();
const ctx = getContext();
if (!ctx) return;
const pos = getCoordinates(e);
ctx.beginPath();
ctx.moveTo(lastPos.current.x, lastPos.current.y);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
lastPos.current = pos;
};
const stopDrawing = () => {
setIsDrawing(false);
};
const clearCanvas = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
};
// Resize canvas handler
useEffect(() => {
if (canvasRef.current && activeTab === "draw") {
const canvas = canvasRef.current;
canvas.width = canvas.parentElement?.clientWidth || 500;
canvas.height = 180;
clearCanvas();
}
}, [activeTab]);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setUploadedImage(event.target.result as string);
}
};
reader.readAsDataURL(file);
}
};
const handleSubmit = () => {
if (activeTab === "draw") {
if (!canvasRef.current) return;
// Convert canvas to image
const dataUrl = canvasRef.current.toDataURL("image/png");
onSave({ type: "draw", dataUrl });
} else if (activeTab === "type") {
if (!typedName.trim()) return;
// Render typed text to a canvas to extract as image
const tempCanvas = document.createElement("canvas");
tempCanvas.width = 400;
tempCanvas.height = 100;
const ctx = tempCanvas.getContext("2d");
if (ctx) {
ctx.fillStyle = "#F7F9F6"; // light bg
ctx.fillRect(0, 0, 400, 100);
// Match fonts
let fontStyle = "italic 32px Georgia";
if (typedFont === "font-mono") fontStyle = "italic 32px Courier New";
if (typedFont === "font-sans-cursive")
fontStyle = 'italic 32px "Lucida Handwriting", cursive, sans-serif';
ctx.font = fontStyle;
ctx.fillStyle = "#477A2B"; // green ink, no black
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(typedName, 200, 50);
}
onSave({ type: "type", dataUrl: tempCanvas.toDataURL("image/png") });
} else if (activeTab === "upload") {
if (!uploadedImage) return;
onSave({ type: "upload", dataUrl: uploadedImage });
}
};
return (
<div className="w-full rounded-xl border border-ink-100 bg-white p-6 shadow-premium transition-premium">
{/* Tabs */}
<div className="mb-6 flex border-b border-ink-100 pb-2">
<button
type="button"
onClick={() => setActiveTab("draw")}
className={`mr-4 pb-2 text-sm font-semibold transition-colors ${
activeTab === "draw"
? "border-b-2 border-primary-500 text-ink-800"
: "text-ink-600 hover:text-ink-800"
}`}
>
Draw Signature
</button>
<button
type="button"
onClick={() => setActiveTab("type")}
className={`mr-4 pb-2 text-sm font-semibold transition-colors ${
activeTab === "type"
? "border-b-2 border-primary-500 text-ink-800"
: "text-ink-600 hover:text-ink-800"
}`}
>
Type Signature
</button>
<button
type="button"
onClick={() => setActiveTab("upload")}
className={`pb-2 text-sm font-semibold transition-colors ${
activeTab === "upload"
? "border-b-2 border-primary-500 text-ink-800"
: "text-ink-600 hover:text-ink-800"
}`}
>
Upload Image
</button>
</div>
{/* Drawing Area */}
{activeTab === "draw" && (
<div>
<div className="relative mb-3 rounded-lg border border-dashed border-ink-300 bg-ink-50">
<canvas
ref={canvasRef}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
onTouchStart={startDrawing}
onTouchMove={draw}
onTouchEnd={stopDrawing}
className="block cursor-crosshair rounded-lg"
/>
<button
type="button"
onClick={clearCanvas}
className="absolute right-3 top-3 rounded bg-white px-2.5 py-1 text-xs font-semibold text-ink-600 shadow border border-ink-100 hover:bg-ink-50"
>
Clear
</button>
</div>
<p className="text-xs text-ink-600">
Use your mouse or touchscreen to sign in the box above.
</p>
</div>
)}
{/* Typing Area */}
{activeTab === "type" && (
<div>
<div className="mb-4">
<label className="block text-xs font-semibold text-ink-600 mb-1.5">
Enter Your Full Name
</label>
<input
type="text"
value={typedName}
onChange={(e) => setTypedName(e.target.value)}
placeholder="e.g. John Doe"
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-primary-500 focus:outline-none"
/>
</div>
<div className="mb-4 flex gap-3">
<button
type="button"
onClick={() => setTypedFont("font-sans-cursive")}
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-all ${
typedFont === "font-sans-cursive"
? "border-primary-500 bg-primary-50"
: "border-ink-100 bg-white hover:bg-ink-50"
}`}
style={{
fontFamily: '"Lucida Handwriting", cursive, sans-serif',
}}
>
Signature Style 1
</button>
<button
type="button"
onClick={() => setTypedFont("font-serif")}
className={`rounded-lg border px-3 py-2 text-sm font-serif italic transition-all ${
typedFont === "font-serif"
? "border-primary-500 bg-primary-50"
: "border-ink-100 bg-white hover:bg-ink-50"
}`}
>
Signature Style 2
</button>
<button
type="button"
onClick={() => setTypedFont("font-mono")}
className={`rounded-lg border px-3 py-2 text-sm font-mono italic transition-all ${
typedFont === "font-mono"
? "border-primary-500 bg-primary-50"
: "border-ink-100 bg-white hover:bg-ink-50"
}`}
>
Signature Style 3
</button>
</div>
<div className="rounded-lg border border-ink-100 bg-ink-50 py-6 text-center shadow-inner">
{typedName ? (
<span
className={`text-2xl text-primary-800 ${
typedFont === "font-sans-cursive"
? "font-sans" // Fallback handled inline
: typedFont
}`}
style={
typedFont === "font-sans-cursive"
? {
fontFamily:
'"Lucida Handwriting", cursive, Georgia, serif',
fontStyle: "italic",
}
: { fontStyle: "italic" }
}
>
{typedName}
</span>
) : (
<span className="text-sm italic text-ink-300">
Your Signature Preview
</span>
)}
</div>
</div>
)}
{/* Uploading Area */}
{activeTab === "upload" && (
<div>
<div className="flex items-center justify-center w-full">
<label className="flex flex-col items-center justify-center w-full h-36 border-2 border-ink-300 border-dashed rounded-lg cursor-pointer bg-ink-50 hover:bg-white transition-colors">
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<svg
className="w-8 h-8 mb-3 text-ink-600"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 20 16"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
/>
</svg>
<p className="mb-2 text-sm text-ink-600">
<span className="font-semibold">Click to upload</span>{" "}
signature image
</p>
<p className="text-xs text-ink-600">PNG or JPG (Max 500KB)</p>
</div>
<input
type="file"
className="hidden"
accept="image/*"
onChange={handleFileUpload}
/>
</label>
</div>
{uploadedImage && (
<div className="mt-4 flex flex-col items-center">
<p className="text-xs font-semibold text-ink-600 mb-1">
Uploaded Signature Preview:
</p>
<img
src={uploadedImage}
alt="Signature Upload"
className="max-h-24 object-contain rounded border border-ink-100 bg-white p-2"
/>
</div>
)}
</div>
)}
{/* Action buttons */}
<div className="mt-6 flex justify-end gap-3">
{onCancel && (
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-ink-200 bg-white px-4 py-2 text-sm font-semibold text-ink-600 hover:bg-ink-50 transition-premium"
>
Cancel
</button>
)}
<button
type="button"
onClick={handleSubmit}
disabled={
activeTab === "draw" || // Canvas validation handled at submit
(activeTab === "type" && !typedName.trim()) ||
(activeTab === "upload" && !uploadedImage)
}
className="rounded-lg bg-primary-400 px-5 py-2 text-sm font-semibold text-ink-800 hover:bg-primary-300 disabled:opacity-50 disabled:cursor-not-allowed transition-premium shadow-premium"
>
Sign Document
</button>
</div>
</div>
);
};

View File

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

View File

@ -0,0 +1,679 @@
import React, { useState, useEffect } from "react";
import type { User } from "../../../types";
import { apiClient } from "../../../lib/api-client";
import {
Check,
X,
ShieldAlert,
Eye,
Globe,
Building2,
FileCheck2,
Calendar,
Download,
} from "lucide-react";
import { DocumentPreview } from "../../agreements/components/DocumentPreview";
export const ClientApprovalQueue: React.FC = () => {
const [clients, setClients] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [selectedClient, setSelectedClient] = useState<User | null>(null);
const [processingId, setProcessingId] = useState<string | null>(null);
const [viewingDoc, setViewingDoc] = useState<{
title: string;
type: "NDA" | "MSA";
company: string;
signature?: string;
date?: string;
} | null>(null);
const downloadDocument = async (
type: "NDA" | "MSA",
company: string,
signature?: string,
date?: string,
) => {
// Load html2pdf dynamically
const html2pdf = await new Promise<any>((resolve, reject) => {
if ((window as any).html2pdf) {
resolve((window as any).html2pdf);
return;
}
const script = document.createElement("script");
script.src = "https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js";
script.onload = () => resolve((window as any).html2pdf);
script.onerror = () => reject(new Error("Failed to load PDF library"));
document.body.appendChild(script);
});
const title =
type === "NDA"
? "Mutual Non-Disclosure Agreement"
: "Master Services Agreement";
const formattedDate = date
? new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: "[Pending Signature]";
const element = document.createElement("div");
element.style.padding = "45px 50px";
element.style.fontFamily = "Georgia, serif";
element.style.color = "#1e293b";
element.style.backgroundColor = "#ffffff";
element.style.lineHeight = "1.6";
element.style.fontSize = "13px";
const bodyContent = type === "NDA"
? `
<div style="text-align: justify; margin-bottom: 20px;">
<p>This <strong>MUTUAL NON-DISCLOSURE AGREEMENT</strong> (the "Agreement") is made and entered into as of the date of final execution below (the "Effective Date"), by and between:</p>
<div style="padding-left: 20px; border-left: 2px solid #cbd5e1; margin: 15px 0; font-family: sans-serif; font-size: 11px; color: #475569;">
<p style="margin-bottom: 8px;"><strong>TECH4BIZ SOLUTIONS INC.</strong>, a corporation organized and existing under the laws of Delaware, with its principal executive office located at 100 Innovation Way, Suite 400 (hereinafter referred to as <strong>"Tech4Biz"</strong> or the <strong>"Disclosing Party"</strong>); and</p>
<p style="margin: 0;"><strong>${company.toUpperCase()}</strong>, a business entity registered and operating under the laws of its jurisdiction of incorporation (hereinafter referred to as the <strong>"Company"</strong> or the <strong>"Receiving Party"</strong>).</p>
</div>
<p style="font-style: italic; font-size: 11px; border-top: 1px solid #f1f5f9; border-bottom: 1px solid #f1f5f9; padding: 8px 0; margin: 15px 0;">
WHEREAS, Tech4Biz and the Company (collectively referred to as the "Parties" and individually as a "Party") desire to share proprietary information to evaluate a potential business relationship concerning technology integrations, hardware IP licensing (including RISC-V cores, FPGA modules), and developer frameworks (the "Purpose").
</p>
<p>NOW, THEREFORE, in consideration of the mutual covenants and promises contained herein, the Parties agree as follows:</p>
<div style="margin-top: 20px;">
<h3 style="font-family: sans-serif; font-size: 11px; font-weight: bold; text-transform: uppercase; color: #0f172a; margin-bottom: 4px;">1. Definition of Confidential Information</h3>
<p style="margin-top: 0;">"Confidential Information" shall mean all information or material disclosed by one Party to the other Party that has value in such Party's business, including but not limited to technical data, hardware designs, silicon blueprints, compiler architecture, source code, product plans, marketing strategies, or business metrics, whether designated as confidential or which under the circumstances of disclosure should reasonably be understood to be confidential.</p>
</div>
<div style="margin-top: 20px;">
<h3 style="font-family: sans-serif; font-size: 11px; font-weight: bold; text-transform: uppercase; color: #0f172a; margin-bottom: 4px;">2. Obligations of Confidentiality and Non-Use</h3>
<p style="margin-top: 0;">The Receiving Party agrees: (a) to hold the Disclosing Party's Confidential Information in strict confidence using the same degree of care it uses to protect its own confidential information, but in no event less than a reasonable degree of care; (b) not to disclose such Confidential Information to any third party without prior written consent; and (c) to use the Confidential Information solely for the evaluation and execution of the Purpose.</p>
</div>
<div style="margin-top: 20px;">
<h3 style="font-family: sans-serif; font-size: 11px; font-weight: bold; text-transform: uppercase; color: #0f172a; margin-bottom: 4px;">3. Permitted Disclosures</h3>
<p style="margin-top: 0;">The Receiving Party may disclose Confidential Information only to those of its employees, contractors, and advisors who have a verifiable "need to know" in connection with the Purpose and who are bound by confidentiality obligations at least as restrictive as those contained in this Agreement.</p>
</div>
<div style="margin-top: 20px;">
<h3 style="font-family: sans-serif; font-size: 11px; font-weight: bold; text-transform: uppercase; color: #0f172a; margin-bottom: 4px;">4. Term and Survival</h3>
<p style="margin-top: 0;">This Agreement shall govern disclosures made within three (3) years from the Effective Date. The obligations of confidentiality, non-disclosure, and non-use shall survive for five (5) years following the termination of this Agreement, or indefinitely with respect to trade secrets.</p>
</div>
<div style="margin-top: 20px;">
<h3 style="font-family: sans-serif; font-size: 11px; font-weight: bold; text-transform: uppercase; color: #0f172a; margin-bottom: 4px;">5. Governing Law and Jurisdiction</h3>
<p style="margin-top: 0;">This Agreement shall be governed by, construed, and enforced in accordance with the laws of the State of Delaware, without regard to its conflict of laws principles. Any legal action arising hereunder shall be brought exclusively in the state or federal courts located in Delaware.</p>
</div>
</div>
`
: `
<div style="text-align: justify; margin-bottom: 20px;">
<p>This <strong>MASTER SERVICES AGREEMENT</strong> (the "MSA" or "Agreement") defines the commercial framework for service delivery, digital asset provisioning, and technology licensing, by and between:</p>
<div style="padding-left: 20px; border-left: 2px solid #cbd5e1; margin: 15px 0; font-family: sans-serif; font-size: 11px; color: #475569;">
<p style="margin-bottom: 8px;"><strong>TECH4BIZ SOLUTIONS INC.</strong>, a Delaware corporation, with its principal office at 100 Innovation Way, Suite 400 (hereinafter referred to as <strong>"Tech4Biz"</strong>); and</p>
<p style="margin: 0;"><strong>${company.toUpperCase()}</strong>, a business entity registered and operating under the laws of its jurisdiction of incorporation (hereinafter referred to as the <strong>"Client"</strong>).</p>
</div>
<p style="font-style: italic; font-size: 11px; border-top: 1px solid #f1f5f9; border-bottom: 1px solid #f1f5f9; padding: 8px 0; margin: 15px 0;">
WHEREAS, Tech4Biz provides high-fidelity silicon designs, custom compilation systems, and software engineering consulting; and the Client wishes to engage Tech4Biz to access such tools and services under the terms and conditions set forth herein.
</p>
<p>NOW, THEREFORE, in consideration of the mutual covenants and promises contained herein, the Parties agree as follows:</p>
<div style="margin-top: 20px;">
<h3 style="font-family: sans-serif; font-size: 11px; font-weight: bold; text-transform: uppercase; color: #0f172a; margin-bottom: 4px;">1. Scope of Work and Deliverables</h3>
<p style="margin-top: 0;">All services, deliverables, and digital asset repositories (including synthesized silicon architectures, software libraries, and proprietary compiler tools) provided by Tech4Biz shall be set forth in individually executed Statements of Work ("SOWs") signed by authorized representatives of both Parties. Each SOW shall reference and be governed by the terms of this MSA.</p>
</div>
<div style="margin-top: 20px;">
<h3 style="font-family: sans-serif; font-size: 11px; font-weight: bold; text-transform: uppercase; color: #0f172a; margin-bottom: 4px;">2. Intellectual Property and Licensing</h3>
<p style="margin-top: 0;">Except as explicitly outlined in an SOW, all background Intellectual Property owned by Tech4Biz (including hardware designs, compilers, and the CodeNuk framework) remains the exclusive property of Tech4Biz. Client is granted a limited, non-exclusive, non-transferable, revocable license to utilize downloaded assets solely for internal testing and development purposes.</p>
</div>
<div style="margin-top: 20px;">
<h3 style="font-family: sans-serif; font-size: 11px; font-weight: bold; text-transform: uppercase; color: #0f172a; margin-bottom: 4px;">3. Payment Terms and Financial Covenants</h3>
<p style="margin-top: 0;">Client shall pay Tech4Biz the fees specified in each SOW. Unless otherwise stated in an SOW, all payments are due Net 30 days from the invoice date. Late payments shall accumulate interest at the rate of 1.5% per month, or the maximum rate permitted by law, whichever is lower.</p>
</div>
<div style="margin-top: 20px;">
<h3 style="font-family: sans-serif; font-size: 11px; font-weight: bold; text-transform: uppercase; color: #0f172a; margin-bottom: 4px;">4. Limitation of Liability</h3>
<p style="margin-top: 0;">IN NO EVENT SHALL EITHER PARTY BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES. TECH4BIZ TOTAL AGGREGATE LIABILITY UNDER THIS AGREEMENT AND ANY SOW SHALL NOT EXCEED THE TOTAL FEES PAID BY CLIENT TO TECH4BIZ UNDER THE APPLICABLE SOW IN THE SIX (6) MONTHS PRECEDING THE CLAIM.</p>
</div>
<div style="margin-top: 20px;">
<h3 style="font-family: sans-serif; font-size: 11px; font-weight: bold; text-transform: uppercase; color: #0f172a; margin-bottom: 4px;">5. Confidentiality</h3>
<p style="margin-top: 0;">The Parties agree that the existence, terms, and performance of this Agreement, as well as all proprietary information shared hereunder, shall be treated as confidential and governed by the active Mutual Non-Disclosure Agreement (NDA) executed by the Parties.</p>
</div>
</div>
`;
element.innerHTML = `
<div style="text-align: center; margin-bottom: 30px; border-bottom: 2px solid #0f172a; padding-bottom: 15px;">
<h1 style="font-size: 18px; font-family: sans-serif; font-weight: bold; text-transform: uppercase; margin: 0 0 5px 0; color: #0f172a; letter-spacing: 0.5px;">
${title}
</h1>
<p style="font-size: 10px; font-family: sans-serif; text-transform: uppercase; letter-spacing: 1px; color: #64748b; margin: 0 0 5px 0; font-weight: bold;">
Tech4Biz Technology Integration Portal
</p>
<p style="font-size: 9px; font-family: monospace; color: #64748b; margin: 0; font-weight: bold;">
REF: T4B-${type}-${company.toUpperCase().replace(/[^A-Z0-9]/g, "-")}-2026
</p>
</div>
${bodyContent}
<div style="margin-top: 40px; border-top: 1px solid #cbd5e1; padding-top: 20px;">
<p style="font-size: 9px; text-transform: uppercase; color: #475569; font-family: sans-serif; font-weight: bold; margin-bottom: 20px; text-align: center; letter-spacing: 1px;">
IN WITNESS WHEREOF, the Parties have executed this Agreement as of the dates indicated below.
</p>
<table style="width: 100%; border-collapse: collapse; font-family: sans-serif; font-size: 10px;">
<tr>
<td style="width: 45%; vertical-align: top; padding-right: 20px;">
<p style="font-weight: bold; color: #0f172a; border-bottom: 1px solid #e2e8f0; padding-bottom: 4px; margin: 0 0 8px 0; text-transform: uppercase; letter-spacing: 0.5px;">
For: Tech4Biz Solutions Inc.
</p>
<div style="height: 50px; border-bottom: 1px solid #94a3b8; margin-bottom: 8px; display: flex; align-items: flex-end; padding-bottom: 2px;">
<span style="font-family: Georgia, serif; font-style: italic; font-size: 14px; color: #5c9c37; font-weight: bold;">Yasha Khandelwal</span>
</div>
<p style="margin: 2px 0; color: #475569;"><strong>Name:</strong> Yasha Khandelwal</p>
<p style="margin: 2px 0; color: #475569;"><strong>Title:</strong> CEO</p>
<p style="margin: 2px 0; color: #475569;"><strong>Date:</strong> January 1, 2026</p>
</td>
<td style="width: 10%;"></td>
<td style="width: 45%; vertical-align: top; padding-left: 20px;">
<p style="font-weight: bold; color: #0f172a; border-bottom: 1px solid #e2e8f0; padding-bottom: 4px; margin: 0 0 8px 0; text-transform: uppercase; letter-spacing: 0.5px;">
For: ${company.toUpperCase()}
</p>
<div style="height: 50px; border-bottom: 1px solid #94a3b8; margin-bottom: 8px; display: flex; align-items: flex-end; justify-content: center; padding-bottom: 2px;">
${
signature
? `<img src="${signature}" style="max-height: 40px; max-width: 100%; object-fit: contain;" />`
: `<span style="font-size: 9px; color: #94a3b8; font-style: italic; margin-bottom: 5px;">[Awaiting Signature]</span>`
}
</div>
<p style="margin: 2px 0; color: #475569;"><strong>Name:</strong> Authorized Representative</p>
<p style="margin: 2px 0; color: #475569;"><strong>Title:</strong> Corporate Designee</p>
<p style="margin: 2px 0; color: #475569;"><strong>Date:</strong> ${formattedDate}</p>
</td>
</tr>
</table>
</div>
`;
const opt = {
margin: 15,
filename: `${company.toLowerCase().replace(/[^a-z0-9]/g, "-")}-${type.toLowerCase()}-agreement.pdf`,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2.5, useCORS: true },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
};
html2pdf().from(element).set(opt).save();
};
const fetchClients = async () => {
setLoading(true);
try {
const response = await apiClient.get<User[]>("/admin/clients");
setClients(response.data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchClients();
}, []);
const handleUpdateStatus = async (
clientId: string,
newStatus: "APPROVED" | "REJECTED",
) => {
setProcessingId(clientId);
try {
await apiClient.put(`/admin/clients/${clientId}`, { status: newStatus });
setClients((prev) =>
prev.map((c) =>
c.id === clientId ? { ...c, onboardingStatus: newStatus } : c,
),
);
if (selectedClient?.id === clientId) {
setSelectedClient((prev) =>
prev ? { ...prev, onboardingStatus: newStatus } : null,
);
}
} catch (err) {
console.error(err);
} finally {
setProcessingId(null);
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-ink-800">
Client Onboarding Approvals
</h2>
<p className="text-sm text-ink-600">
Review and authorize client portal access requests, legal NDA, and
MSA agreements.
</p>
</div>
<span className="rounded-lg bg-warning/10 px-3 py-1 text-xs font-semibold text-warning border border-warning/20">
{
clients.filter((c) => c.onboardingStatus === "PENDING_APPROVAL")
.length
}{" "}
Pending Review
</span>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Table/List */}
<div className="lg:col-span-2 rounded-xl border border-ink-100 bg-white p-4 shadow-sm space-y-4">
<h3 className="text-xs font-bold uppercase tracking-wider text-ink-600">
Client Accounts
</h3>
{loading ? (
<div className="space-y-3 py-4">
{[1, 2].map((n) => (
<div
key={n}
className="h-16 animate-pulse rounded-lg bg-ink-50"
/>
))}
</div>
) : clients.length === 0 ? (
<div className="py-8 text-center text-sm text-ink-600">
No client accounts found.
</div>
) : (
<div className="divide-y divide-ink-100">
{clients.map((client) => (
<div
key={client.id}
className={`flex items-center justify-between py-3.5 first:pt-0 last:pb-0 transition-colors ${
selectedClient?.id === client.id ? "bg-primary-50/20" : ""
}`}
>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<p className="text-sm font-bold text-ink-800">
{client.companyName || "Incomplete Profile"}
</p>
<span className="text-xs font-semibold text-ink-600 font-mono">
({client.email})
</span>
</div>
<div className="flex items-center gap-3 text-xs text-ink-600">
<span className="flex items-center gap-1">
<Building2 className="h-3.5 w-3.5" />
{client.sector || "N/A"}
</span>
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
Joined: {client.createdAt.split("T")[0]}
</span>
</div>
</div>
<div className="flex items-center gap-2.5">
{/* Status Badge */}
<span
className={`rounded px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider border ${
client.onboardingStatus === "APPROVED"
? "bg-success/10 text-success border-success/20"
: client.onboardingStatus === "REJECTED"
? "bg-danger/10 text-danger border-danger/20"
: client.onboardingStatus === "PENDING_APPROVAL"
? "bg-warning/10 text-warning border-warning/20 animate-pulse"
: "bg-ink-100 text-ink-600 border-ink-200"
}`}
>
{client.onboardingStatus.replace("_", " ")}
</span>
{/* Review Button */}
<button
onClick={() => setSelectedClient(client)}
className="rounded-lg border border-ink-200 bg-white p-2 text-ink-600 hover:text-ink-800 hover:bg-ink-50 transition-colors"
title="Review Agreements"
>
<Eye className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Details Panel */}
<div className="rounded-xl border border-ink-100 bg-white p-5 shadow-sm space-y-6">
<h3 className="text-xs font-bold uppercase tracking-wider text-ink-600">
Detail Inspector
</h3>
{selectedClient ? (
<div className="space-y-6">
{/* Profile Details */}
<div className="space-y-3">
<h4 className="text-base font-bold text-ink-800">
{selectedClient.companyName}
</h4>
<div className="space-y-2 text-xs text-ink-700">
<div className="flex justify-between border-b border-ink-50 pb-1.5">
<span className="text-ink-600 font-medium">Domain:</span>
<span className="font-semibold">
{selectedClient.sector}
</span>
</div>
<div className="flex justify-between border-b border-ink-50 pb-1.5">
<span className="text-ink-600 font-medium">
Company Size:
</span>
<span className="font-semibold">
{selectedClient.companySize}
</span>
</div>
<div className="flex justify-between border-b border-ink-50 pb-1.5">
<span className="text-ink-600 font-medium">Website:</span>
<a
href={selectedClient.website}
target="_blank"
rel="noreferrer"
className="font-semibold text-primary-700 hover:underline flex items-center gap-1"
>
<Globe className="h-3 w-3" />
Link
</a>
</div>
<div className="flex justify-between border-b border-ink-50 pb-1.5">
<span className="text-ink-600 font-medium">
Email Address:
</span>
<span className="font-semibold">
{selectedClient.email}
</span>
</div>
</div>
</div>
{/* Signature Blocks */}
<div className="space-y-3 pt-2 border-t border-ink-100">
<h5 className="text-xs font-bold uppercase tracking-wider text-ink-600">
Agreement Signatures
</h5>
<div className="grid grid-cols-2 gap-3">
{/* NDA Signature */}
<div className="rounded-lg border border-ink-100 bg-ink-50 p-2.5 flex flex-col items-center">
<span className="text-[10px] font-bold text-ink-600 mb-2">
NDA Signature
</span>
{selectedClient.ndaSignature ? (
<>
<div className="bg-white rounded p-1.5 border border-ink-100 w-full flex justify-center mb-2">
<img
src={selectedClient.ndaSignature.dataUrl}
alt="NDA Sign"
className="h-10 object-contain mix-blend-multiply"
/>
</div>
<div className="flex gap-1.5 w-full">
<button
type="button"
onClick={() =>
setViewingDoc({
title: "Mutual Non-Disclosure Agreement (NDA)",
type: "NDA",
company:
selectedClient.companyName ||
selectedClient.email ||
"Client",
signature: selectedClient.ndaSignature?.dataUrl,
date: selectedClient.ndaSignature?.date,
})
}
className="flex-1 py-1 rounded bg-white border border-ink-200 text-[10px] font-bold text-ink-700 hover:bg-ink-100 hover:text-ink-900 transition-colors flex items-center justify-center gap-0.5 cursor-pointer"
>
<Eye className="w-3 h-3" /> View
</button>
<button
type="button"
onClick={() =>
downloadDocument(
"NDA",
selectedClient.companyName ||
selectedClient.email ||
"Client",
selectedClient.ndaSignature?.dataUrl,
selectedClient.ndaSignature?.date,
)
}
className="flex-1 py-1 rounded bg-primary-400 text-[10px] font-bold text-ink-800 hover:bg-primary-300 transition-premium shadow-sm flex items-center justify-center gap-0.5 cursor-pointer"
>
<Download className="w-3 h-3" /> Get
</button>
</div>
</>
) : (
<>
<span className="text-[10px] italic text-danger flex items-center gap-1 my-3">
<ShieldAlert className="h-3.5 w-3.5" />
Unsigned
</span>
<button
type="button"
onClick={() =>
setViewingDoc({
title: "Mutual Non-Disclosure Agreement (NDA)",
type: "NDA",
company:
selectedClient.companyName ||
selectedClient.email ||
"Client",
})
}
className="w-full py-1.5 rounded bg-white border border-ink-200 text-[10px] font-bold text-ink-600 hover:bg-ink-100 transition-colors flex items-center justify-center gap-0.5 cursor-pointer"
>
<Eye className="w-3 h-3" /> View Template
</button>
</>
)}
</div>
{/* MSA Signature */}
<div className="rounded-lg border border-ink-100 bg-ink-50 p-2.5 flex flex-col items-center">
<span className="text-[10px] font-bold text-ink-600 mb-2">
MSA Signature
</span>
{selectedClient.msaSignature ? (
<>
<div className="bg-white rounded p-1.5 border border-ink-100 w-full flex justify-center mb-2">
<img
src={selectedClient.msaSignature.dataUrl}
alt="MSA Sign"
className="h-10 object-contain mix-blend-multiply"
/>
</div>
<div className="flex gap-1.5 w-full">
<button
type="button"
onClick={() =>
setViewingDoc({
title: "Master Services Agreement (MSA)",
type: "MSA",
company:
selectedClient.companyName ||
selectedClient.email ||
"Client",
signature: selectedClient.msaSignature?.dataUrl,
date: selectedClient.msaSignature?.date,
})
}
className="flex-1 py-1 rounded bg-white border border-ink-200 text-[10px] font-bold text-ink-700 hover:bg-ink-100 hover:text-ink-900 transition-colors flex items-center justify-center gap-0.5 cursor-pointer"
>
<Eye className="w-3 h-3" /> View
</button>
<button
type="button"
onClick={() =>
downloadDocument(
"MSA",
selectedClient.companyName ||
selectedClient.email ||
"Client",
selectedClient.msaSignature?.dataUrl,
selectedClient.msaSignature?.date,
)
}
className="flex-1 py-1 rounded bg-primary-400 text-[10px] font-bold text-ink-800 hover:bg-primary-300 transition-premium shadow-sm flex items-center justify-center gap-0.5 cursor-pointer"
>
<Download className="w-3 h-3" /> Get
</button>
</div>
</>
) : (
<>
<span className="text-[10px] italic text-danger flex items-center gap-1 my-3">
<ShieldAlert className="h-3.5 w-3.5" />
Unsigned
</span>
<button
type="button"
onClick={() =>
setViewingDoc({
title: "Master Services Agreement (MSA)",
type: "MSA",
company:
selectedClient.companyName ||
selectedClient.email ||
"Client",
})
}
className="w-full py-1.5 rounded bg-white border border-ink-200 text-[10px] font-bold text-ink-600 hover:bg-ink-100 transition-colors flex items-center justify-center gap-0.5 cursor-pointer"
>
<Eye className="w-3 h-3" /> View Template
</button>
</>
)}
</div>
</div>
</div>
{/* Action buttons */}
{selectedClient.onboardingStatus === "PENDING_APPROVAL" && (
<div className="grid grid-cols-2 gap-3 pt-4 border-t border-ink-100">
<button
onClick={() =>
handleUpdateStatus(selectedClient.id, "REJECTED")
}
disabled={processingId === selectedClient.id}
className="flex items-center justify-center gap-1.5 rounded-lg border border-danger/30 text-danger bg-danger/5 hover:bg-danger/10 px-4 py-2.5 text-xs font-semibold transition-colors disabled:opacity-50"
>
<X className="h-4 w-4" />
Reject Application
</button>
<button
onClick={() =>
handleUpdateStatus(selectedClient.id, "APPROVED")
}
disabled={processingId === selectedClient.id}
className="flex items-center justify-center gap-1.5 rounded-lg bg-primary-400 text-ink-800 hover:bg-primary-300 px-4 py-2.5 text-xs font-semibold transition-premium shadow-premium disabled:opacity-50"
>
<Check className="h-4 w-4" />
Approve Client
</button>
</div>
)}
{selectedClient.onboardingStatus === "APPROVED" && (
<div className="bg-success/5 border border-success/20 rounded-lg p-3 flex items-center gap-2 text-success">
<FileCheck2 className="h-5 w-5" />
<span className="text-xs font-bold">
Client approved! Documents unlocked for signing.
</span>
</div>
)}
{selectedClient.onboardingStatus === "REJECTED" && (
<div className="bg-danger/5 border border-danger/20 rounded-lg p-3 flex items-center gap-2 text-danger">
<X className="h-5 w-5" />
<span className="text-xs font-bold">
Client application has been rejected.
</span>
</div>
)}
</div>
) : (
<div className="py-12 text-center text-xs text-ink-600 italic">
Select a client from the queue to view their documents and
signatures.
</div>
)}
</div>
</div>
{viewingDoc && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-ink-900/60 backdrop-blur-sm">
<div className="relative w-full max-w-4xl bg-white rounded-2xl shadow-2xl overflow-hidden border border-ink-100 flex flex-col max-h-[90vh]">
{/* Header */}
<div className="p-4 border-b border-ink-100 flex justify-between items-center bg-ink-50">
<h3 className="text-sm font-bold text-ink-800">
Document Viewer: {viewingDoc.title}
</h3>
<button
onClick={() => setViewingDoc(null)}
className="p-1.5 rounded-lg border border-ink-200 bg-white hover:bg-ink-100 text-ink-600 hover:text-ink-800 transition-colors text-xs font-semibold flex items-center gap-1 cursor-pointer"
>
<X className="w-4 h-4" /> Close
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 bg-ink-50">
<DocumentPreview
title={viewingDoc.title}
documentType={viewingDoc.type}
companyName={viewingDoc.company}
signatureDataUrl={viewingDoc.signature}
signatureDate={viewingDoc.date}
/>
</div>
{/* Footer */}
<div className="p-4 border-t border-ink-100 flex justify-end gap-3 bg-white">
<button
type="button"
onClick={() => {
downloadDocument(
viewingDoc.type,
viewingDoc.company,
viewingDoc.signature,
viewingDoc.date,
);
}}
className="px-4 py-2 rounded-xl bg-primary-400 text-xs font-bold text-ink-800 hover:bg-primary-300 transition-premium shadow-premium flex items-center gap-1.5 cursor-pointer"
>
<Download className="w-4 h-4" /> Download PDF Agreement
</button>
<button
type="button"
onClick={() => setViewingDoc(null)}
className="px-4 py-2 rounded-xl border border-ink-200 bg-white text-xs font-bold text-ink-700 hover:bg-ink-50 transition-colors cursor-pointer"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,402 @@
import React, { useState } from "react";
import {
FileText,
ZoomIn,
ZoomOut,
RotateCcw,
ShieldCheck,
} from "lucide-react";
interface DocumentPreviewProps {
title: string;
documentType: "NDA" | "MSA";
companyName: string;
signatureDataUrl?: string;
signatureDate?: string;
}
export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
title,
documentType,
companyName,
signatureDataUrl,
signatureDate,
}) => {
const [zoom, setZoom] = useState(100);
const handleZoomIn = () => setZoom((prev) => Math.min(prev + 10, 140));
const handleZoomOut = () => setZoom((prev) => Math.max(prev - 10, 70));
const handleZoomReset = () => setZoom(100);
const formattedDate = signatureDate
? new Date(signatureDate).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: "[Pending Signature]";
return (
<div className="flex flex-col rounded-xl border border-ink-100 bg-ink-50 shadow-premium overflow-hidden transition-premium w-full">
{/* Doc toolbar */}
<div className="flex flex-wrap items-center justify-between border-b border-ink-100 bg-white px-6 py-4 gap-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-50 border border-primary-100">
<FileText className="h-5 w-5 text-primary-700" />
</div>
<div>
<span className="block text-sm font-bold text-ink-800">
{title}
</span>
<span className="block text-[10px] text-ink-500 font-mono tracking-wider">
REF: T4B-{documentType}-
{companyName.toUpperCase().replace(/[^A-Z0-9]/g, "-")}-2026
</span>
</div>
</div>
<div className="flex items-center gap-3">
{signatureDataUrl && (
<div className="hidden sm:flex items-center gap-1.5 bg-success/10 border border-success/20 px-3 py-1 rounded-full text-xs font-semibold text-success">
<ShieldCheck className="h-3.5 w-3.5" />
<span>Cryptographically Signed</span>
</div>
)}
<div className="flex items-center gap-1.5 bg-ink-50 rounded-lg p-1 border border-ink-100">
<button
type="button"
onClick={handleZoomOut}
className="rounded p-1.5 hover:bg-white text-ink-600 hover:text-ink-800 transition-colors cursor-pointer"
title="Zoom Out"
>
<ZoomOut className="h-4 w-4" />
</button>
<span className="px-2 text-xs font-bold text-ink-700 min-w-[3.5rem] text-center">
{zoom}%
</span>
<button
type="button"
onClick={handleZoomIn}
className="rounded p-1.5 hover:bg-white text-ink-600 hover:text-ink-800 transition-colors cursor-pointer"
title="Zoom In"
>
<ZoomIn className="h-4 w-4" />
</button>
<button
type="button"
onClick={handleZoomReset}
className="rounded p-1.5 hover:bg-white text-ink-600 hover:text-ink-800 transition-colors cursor-pointer"
title="Reset Zoom"
>
<RotateCcw className="h-4 w-4" />
</button>
</div>
</div>
</div>
{/* Doc page area */}
<div className="flex-1 overflow-auto p-6 md:p-8 max-h-[750px] min-h-[600px] flex justify-center bg-ink-100/40 custom-scrollbar">
<div
className="bg-white p-12 md:p-16 shadow-lg border border-ink-100 rounded-md text-sm leading-relaxed text-slate-800 font-serif origin-top transition-all duration-200 w-full h-fit self-start"
style={{
maxWidth: "780px",
transform: `scale(${zoom / 100})`,
transformOrigin: "top center",
marginBottom: zoom > 100 ? `${(zoom - 100) * 6}px` : "0px",
}}
>
{/* Document Header / Letterhead */}
<div className="text-center mb-10 pb-6 border-b-2 border-slate-900">
<h1 className="text-xl md:text-2xl font-bold uppercase tracking-wider text-slate-900 font-sans mb-2">
{documentType === "NDA"
? "Mutual Non-Disclosure Agreement"
: "Master Services Agreement"}
</h1>
<p className="text-xs uppercase tracking-widest text-slate-500 font-sans font-bold">
Tech4Biz Technology Integration Portal
</p>
</div>
{/* Legal Body */}
{documentType === "NDA" ? (
<div className="space-y-6 text-justify">
<p>
This <strong>MUTUAL NON-DISCLOSURE AGREEMENT</strong> (the
"Agreement") is made and entered into as of the date of final
execution below (the "Effective Date"), by and between:
</p>
<div className="space-y-3 pl-6 border-l-2 border-slate-200 py-1 font-sans text-xs text-slate-700">
<p>
<strong>TECH4BIZ SOLUTIONS INC.</strong>, a corporation
organized and existing under the laws of Delaware, with its
principal executive office located at 100 Innovation Way,
Suite 400 (hereinafter referred to as{" "}
<strong>"Tech4Biz"</strong> or the{" "}
<strong>"Disclosing Party"</strong>); and
</p>
<p>
<strong>{companyName || "[COMPANY NAME PENDING]"}</strong>, a
business entity registered and operating under the laws of its
jurisdiction of incorporation (hereinafter referred to as the{" "}
<strong>"Company"</strong> or the{" "}
<strong>"Receiving Party"</strong>).
</p>
</div>
<p className="italic text-xs border-y border-slate-100 py-2">
WHEREAS, Tech4Biz and the Company (collectively referred to as
the "Parties" and individually as a "Party") desire to share
proprietary information to evaluate a potential business
relationship concerning technology integrations, hardware IP
licensing (including RISC-V cores, FPGA modules), and developer
frameworks (the "Purpose").
</p>
<p>
NOW, THEREFORE, in consideration of the mutual covenants and
promises contained herein, the Parties agree as follows:
</p>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
1. Definition of Confidential Information
</h3>
<p>
"Confidential Information" shall mean all information or
material disclosed by one Party to the other Party that has
value in such Party's business, including but not limited to
technical data, hardware designs, silicon blueprints, compiler
architecture, source code, product plans, marketing
strategies, or business metrics, whether designated as
confidential or which under the circumstances of disclosure
should reasonably be understood to be confidential.
</p>
</div>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
2. Obligations of Confidentiality and Non-Use
</h3>
<p>
The Receiving Party agrees: (a) to hold the Disclosing Party's
Confidential Information in strict confidence using the same
degree of care it uses to protect its own confidential
information, but in no event less than a reasonable degree of
care; (b) not to disclose such Confidential Information to any
third party without prior written consent; and (c) to use the
Confidential Information solely for the evaluation and
execution of the Purpose.
</p>
</div>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
3. Permitted Disclosures
</h3>
<p>
The Receiving Party may disclose Confidential Information only
to those of its employees, contractors, and advisors who have
a verifiable "need to know" in connection with the Purpose and
who are bound by confidentiality obligations at least as
restrictive as those contained in this Agreement.
</p>
</div>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
4. Term and Survival
</h3>
<p>
This Agreement shall govern disclosures made within three (3)
years from the Effective Date. The obligations of
confidentiality, non-disclosure, and non-use shall survive for
five (5) years following the termination of this Agreement, or
indefinitely with respect to trade secrets.
</p>
</div>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
5. Governing Law and Jurisdiction
</h3>
<p>
This Agreement shall be governed by, construed, and enforced
in accordance with the laws of the State of Delaware, without
regard to its conflict of laws principles. Any legal action
arising hereunder shall be brought exclusively in the state or
federal courts located in Delaware.
</p>
</div>
</div>
) : (
<div className="space-y-6 text-justify">
<p>
This <strong>MASTER SERVICES AGREEMENT</strong> (the "MSA" or
"Agreement") defines the commercial framework for service
delivery, digital asset provisioning, and technology licensing,
by and between:
</p>
<div className="space-y-3 pl-6 border-l-2 border-slate-200 py-1 font-sans text-xs text-slate-700">
<p>
<strong>TECH4BIZ SOLUTIONS INC.</strong>, a Delaware
corporation, with its principal office at 100 Innovation Way,
Suite 400 (hereinafter referred to as{" "}
<strong>"Tech4Biz"</strong>); and
</p>
<p>
<strong>{companyName || "[COMPANY NAME PENDING]"}</strong>, a
business entity registered and operating under the laws of its
jurisdiction of incorporation (hereinafter referred to as the{" "}
<strong>"Client"</strong>).
</p>
</div>
<p className="italic text-xs border-y border-slate-100 py-2">
WHEREAS, Tech4Biz provides high-fidelity silicon designs, custom
compilation systems, and software engineering consulting; and
the Client wishes to engage Tech4Biz to access such tools and
services under the terms and conditions set forth herein.
</p>
<p>
NOW, THEREFORE, in consideration of the mutual covenants and
promises contained herein, the Parties agree as follows:
</p>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
1. Scope of Work and Deliverables
</h3>
<p>
All services, deliverables, and digital asset repositories
(including synthesized silicon architectures, software
libraries, and proprietary compiler tools) provided by
Tech4Biz shall be set forth in individually executed
Statements of Work ("SOWs") signed by authorized
representatives of both Parties. Each SOW shall reference and
be governed by the terms of this MSA.
</p>
</div>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
2. Intellectual Property and Licensing
</h3>
<p>
Except as explicitly outlined in an SOW, all background
Intellectual Property owned by Tech4Biz (including hardware
designs, compilers, and the CodeNuk framework) remains the
exclusive property of Tech4Biz. Client is granted a limited,
non-exclusive, non-transferable, revocable license to utilize
downloaded assets solely for internal testing and development
purposes.
</p>
</div>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
3. Payment Terms and Financial Covenants
</h3>
<p>
Client shall pay Tech4Biz the fees specified in each SOW.
Unless otherwise stated in an SOW, all payments are due Net 30
days from the invoice date. Late payments shall accumulate
interest at the rate of 1.5% per month, or the maximum rate
permitted by law, whichever is lower.
</p>
</div>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
4. Limitation of Liability
</h3>
<p>
IN NO EVENT SHALL EITHER PARTY BE LIABLE FOR ANY INDIRECT,
INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES. TECH4BIZ TOTAL
AGGREGATE LIABILITY UNDER THIS AGREEMENT AND ANY SOW SHALL NOT
EXCEED THE TOTAL FEES PAID BY CLIENT TO TECH4BIZ UNDER THE
APPLICABLE SOW IN THE SIX (6) MONTHS PRECEDING THE CLAIM.
</p>
</div>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
5. Confidentiality
</h3>
<p>
The Parties agree that the existence, terms, and performance
of this Agreement, as well as all proprietary information
shared hereunder, shall be treated as confidential and
governed by the active Mutual Non-Disclosure Agreement (NDA)
executed by the Parties.
</p>
</div>
</div>
)}
{/* Signatures Section */}
<div className="mt-14 pt-8 border-t border-slate-300">
<p className="text-xs uppercase text-slate-600 font-sans font-bold mb-6 text-center tracking-widest">
IN WITNESS WHEREOF, the Parties have executed this Agreement as of
the dates indicated below.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 text-left font-sans text-xs">
<div className="space-y-4">
<p className="font-bold text-slate-900 border-b border-slate-200 pb-1 uppercase tracking-wider">
For: Tech4Biz Solutions Inc.
</p>
<div className="h-16 flex items-end pb-1 border-b border-slate-300 relative">
<span className="font-serif italic text-base text-primary-700 select-none pb-1">
Yasha Khandelwal{" "}
</span>
</div>
<div className="space-y-1 text-slate-600 text-[11px]">
<p>
<strong>Name:</strong> Yasha Khandelwal
</p>
<p>
<strong>Title:</strong> CEO
</p>
<p>
<strong>Date:</strong> January 1, 2026
</p>
</div>
</div>
<div className="space-y-4">
<p className="font-bold text-slate-900 border-b border-slate-200 pb-1 uppercase tracking-wider">
For: {companyName.toUpperCase()}
</p>
<div className="h-16 flex items-end justify-center pb-1 border-b border-slate-300 relative overflow-hidden">
{signatureDataUrl ? (
<img
src={signatureDataUrl}
alt="Client Signature"
className="max-h-14 object-contain mix-blend-multiply pb-1 animate-premium"
/>
) : (
<span className="text-[11px] italic text-slate-400 pb-2">
[Awaiting Signature]
</span>
)}
</div>
<div className="space-y-1 text-slate-600 text-[11px]">
<p>
<strong>Name:</strong> Authorized Representative
</p>
<p>
<strong>Title:</strong> Corporate Designee
</p>
<p>
<strong>Date:</strong> {formattedDate}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,169 @@
import React, { useRef, useState, useEffect } from 'react';
import { Eraser, Check } from 'lucide-react';
interface SignatureCaptureProps {
onSignatureComplete: (signatureDataUrl: string) => void;
width?: number;
height?: number;
}
export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
onSignatureComplete,
width = 600,
height = 200
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [hasSignature, setHasSignature] = useState(false);
// Initialize canvas context
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Set styling for the signature line
ctx.strokeStyle = '#050505'; // slate-900 or dark ink
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Handle high DPI displays for crisp rendering
const dpr = window.devicePixelRatio || 1;
// Set actual size in memory (scaled to account for extra pixel density)
canvas.width = width * dpr;
canvas.height = height * dpr;
// Set display size
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
// Normalize coordinate system to use css pixels
ctx.scale(dpr, dpr);
}, [width, height]);
const startDrawing = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault();
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
let clientX, clientY;
if ('touches' in e) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = (e as React.MouseEvent).clientX;
clientY = (e as React.MouseEvent).clientY;
}
ctx.beginPath();
ctx.moveTo(clientX - rect.left, clientY - rect.top);
setIsDrawing(true);
};
const draw = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault();
if (!isDrawing) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
let clientX, clientY;
if ('touches' in e) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = (e as React.MouseEvent).clientX;
clientY = (e as React.MouseEvent).clientY;
}
ctx.lineTo(clientX - rect.left, clientY - rect.top);
ctx.stroke();
setHasSignature(true);
};
const stopDrawing = () => {
setIsDrawing(false);
};
const clearSignature = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
setHasSignature(false);
};
const handleSave = () => {
const canvas = canvasRef.current;
if (!canvas || !hasSignature) return;
// We can return a base64 encoded PNG
const dataUrl = canvas.toDataURL('image/png');
onSignatureComplete(dataUrl);
};
return (
<div className="flex flex-col w-full max-w-2xl mx-auto">
<div className="relative rounded-2xl bg-slate-50 dark:bg-white/5 border-2 border-dashed border-slate-200 dark:border-white/10 overflow-hidden shadow-inner group">
{/* Helper Text */}
{!hasSignature && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<p className="text-slate-400 dark:text-white/30 font-medium text-sm">
Draw your signature here
</p>
</div>
)}
<canvas
ref={canvasRef}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseOut={stopDrawing}
onTouchStart={startDrawing}
onTouchMove={draw}
onTouchEnd={stopDrawing}
className="touch-none cursor-crosshair relative z-10 w-full"
style={{ width: '100%', height: `${height}px`, maxWidth: `${width}px` }}
/>
</div>
<div className="flex items-center justify-between mt-4">
<button
type="button"
onClick={clearSignature}
disabled={!hasSignature}
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-slate-500 dark:text-white/50 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Eraser className="w-4 h-4" />
Clear
</button>
<button
type="button"
onClick={handleSave}
disabled={!hasSignature}
className="flex items-center gap-2 px-6 py-2.5 text-sm font-bold text-white dark:text-slate-900 bg-blue-600 dark:bg-blue-400 hover:bg-blue-700 dark:hover:bg-blue-300 rounded-xl transition-all shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-md hover:-translate-y-0.5"
>
<Check className="w-4 h-4" />
Accept & Sign
</button>
</div>
</div>
);
};

View File

@ -0,0 +1,384 @@
import React, { useState, useEffect } from 'react';
import type { Asset, Category } from '../../../types';
import { apiClient } from '../../../lib/api-client';
import { Cpu, Terminal, Brain, Server, Search, Download, Calendar, User, Tag, HelpCircle, X } from 'lucide-react';
const GithubIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
<path d="M9 18c-4.51 2-5-2-7-2" />
</svg>
);
export const AssetExplorer: React.FC = () => {
const [assets, setAssets] = useState<Asset[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
// Filter state
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [selectedSubcategory, setSelectedSubcategory] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
// Selected asset details modal state
const [selectedAsset, setSelectedAsset] = useState<Asset | null>(null);
// Toast notifications simulation
const [toastMessage, setToastMessage] = useState<string | null>(null);
const fetchAssets = async () => {
setLoading(true);
try {
const response = await apiClient.get<Asset[]>('/assets', {
params: {
categoryId: selectedCategory,
subcategory: selectedSubcategory,
search: searchQuery
}
});
setAssets(response.data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const fetchCategories = async () => {
try {
const response = await apiClient.get<Category[]>('/categories');
setCategories(response.data);
} catch (err) {
console.error(err);
}
};
// Trigger search and filters
useEffect(() => {
fetchAssets();
}, [selectedCategory, selectedSubcategory, searchQuery]);
useEffect(() => {
fetchCategories();
}, []);
const triggerToast = (msg: string) => {
setToastMessage(msg);
setTimeout(() => setToastMessage(null), 3000);
};
const handleDownload = async (asset: Asset) => {
triggerToast(`Starting download: ${asset.title}`);
try {
// Simulate incrementing downloads count
const updatedAsset = { ...asset, downloadsCount: asset.downloadsCount + 1 };
await apiClient.put(`/assets/${asset.id}`, updatedAsset);
// Update local state
setAssets(prev => prev.map(a => a.id === asset.id ? updatedAsset : a));
if (selectedAsset?.id === asset.id) {
setSelectedAsset(updatedAsset);
}
} catch (err) {
console.error(err);
}
};
// Helper icons mapping
const getCategoryIcon = (catId: string) => {
switch (catId) {
case 'silicon': return <Cpu className="h-5 w-5" />;
case 'software': return <Terminal className="h-5 w-5" />;
case 'ai': return <Brain className="h-5 w-5" />;
case 'cloud': return <Server className="h-5 w-5" />;
default: return <HelpCircle className="h-5 w-5" />;
}
};
const currentCategory = categories.find(c => c.id === selectedCategory);
return (
<div className="space-y-6">
{/* Toast Notification */}
{toastMessage && (
<div className="fixed bottom-5 right-5 z-50 rounded-xl bg-ink-800 text-white 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-primary-400 animate-bounce" />
<span className="font-semibold text-ink-50">{toastMessage}</span>
</div>
)}
{/* Hero section */}
<div className="rounded-2xl bg-gradient-to-r from-primary-50 to-primary-100/50 p-6 md:p-8 border border-primary-100 flex flex-col md:flex-row items-center justify-between gap-6">
<div className="space-y-2">
<h1 className="text-2xl font-bold text-ink-800 md:text-3xl">Tech4Biz Asset Explorer</h1>
<p className="text-sm text-ink-700 max-w-xl">
Browse, preview, and download proprietary hardware IP, framework assemblies, and cloud-native building blocks licensed under your master agreements.
</p>
</div>
<div className="flex gap-4">
<div className="bg-white rounded-xl border border-ink-100 p-4 text-center min-w-[6.5rem]">
<p className="text-2xl font-bold text-primary-700">{assets.length}</p>
<p className="text-[10px] uppercase font-bold tracking-wider text-ink-600">Available</p>
</div>
</div>
</div>
{/* Toolbar - Search & Category tabs */}
<div className="space-y-4 rounded-xl border border-ink-100 bg-white p-4 shadow-sm md:p-6">
{/* Search & Subcategory select */}
<div className="flex flex-col md:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-600" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by IP block name, language, metadata tag..."
className="w-full rounded-lg border border-ink-200 pl-10 pr-4 py-2 text-sm focus:border-primary-500 focus:outline-none transition-colors"
/>
</div>
{currentCategory && currentCategory.subcategories.length > 0 && (
<div className="w-full md:w-64">
<select
value={selectedSubcategory}
onChange={(e) => setSelectedSubcategory(e.target.value)}
className="w-full rounded-lg border border-ink-200 bg-white px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
>
<option value="all">All Subcategories</option>
{currentCategory.subcategories.map(sub => (
<option key={sub} value={sub}>{sub}</option>
))}
</select>
</div>
)}
</div>
{/* Category Tabs */}
<div className="flex flex-wrap gap-2 pt-2">
<button
onClick={() => {
setSelectedCategory('all');
setSelectedSubcategory('all');
}}
className={`flex items-center gap-2 rounded-lg px-4 py-2.5 text-xs font-semibold transition-all ${
selectedCategory === 'all'
? 'bg-primary-400 text-ink-800 shadow-sm'
: 'bg-ink-50 text-ink-700 border border-ink-100 hover:bg-ink-100'
}`}
>
All Resources
</button>
{categories.map(cat => (
<button
key={cat.id}
onClick={() => {
setSelectedCategory(cat.id);
setSelectedSubcategory('all');
}}
className={`flex items-center gap-2 rounded-lg px-4 py-2.5 text-xs font-semibold transition-all ${
selectedCategory === cat.id
? 'bg-primary-400 text-ink-800 shadow-sm'
: 'bg-ink-50 text-ink-700 border border-ink-100 hover:bg-ink-100'
}`}
>
{getCategoryIcon(cat.id)}
{cat.name}
</button>
))}
</div>
</div>
{/* Skeletons/Grid */}
{loading ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map(n => (
<div key={n} className="animate-pulse rounded-xl border border-ink-100 bg-white p-5 space-y-4">
<div className="h-40 rounded-lg bg-ink-100" />
<div className="h-4 w-3/4 rounded bg-ink-100" />
<div className="h-10 rounded bg-ink-100" />
<div className="h-4 w-1/2 rounded bg-ink-100" />
</div>
))}
</div>
) : assets.length === 0 ? (
<div className="rounded-xl border border-ink-100 bg-white p-12 text-center">
<p className="text-base font-bold text-ink-800 mb-1">No Assets Found</p>
<p className="text-sm text-ink-600">Try adjusting your filters or searching for another keyword.</p>
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{assets.map(asset => (
<div
key={asset.id}
className="group rounded-xl border border-ink-100 bg-white shadow-sm overflow-hidden flex flex-col hover:shadow-premium hover:-translate-y-0.5 transition-all duration-300"
>
{/* Card Image banner */}
<div className="h-44 w-full relative overflow-hidden bg-ink-100">
<img
src={asset.thumbnailUrl}
alt={asset.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
<span className="absolute left-3 top-3 rounded-full bg-white/90 backdrop-blur px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-primary-700 shadow-sm border border-primary-100 flex items-center gap-1.5">
{getCategoryIcon(asset.categoryId)}
{asset.subcategory}
</span>
</div>
{/* Card Body */}
<div className="p-5 flex-1 flex flex-col justify-between space-y-4">
<div className="space-y-2">
<h3 className="text-base font-bold text-ink-800 group-hover:text-primary-700 transition-colors line-clamp-1">
{asset.title}
</h3>
<p className="text-xs text-ink-600 line-clamp-2 leading-relaxed">
{asset.description}
</p>
</div>
<div className="space-y-4 pt-2">
{/* Tags */}
<div className="flex flex-wrap gap-1.5">
{asset.tags.map(tag => (
<span key={tag} className="rounded bg-ink-50 px-2 py-0.5 text-[9px] font-semibold text-ink-700 border border-ink-100">
#{tag}
</span>
))}
</div>
{/* Actions */}
<div className="flex items-center justify-between border-t border-ink-50 pt-3">
<button
onClick={() => setSelectedAsset(asset)}
className="text-xs font-bold text-ink-800 hover:text-primary-700 transition-colors"
>
View Details
</button>
<div className="flex items-center gap-2">
{asset.githubUrl && (
<a
href={asset.githubUrl}
target="_blank"
rel="noreferrer"
className="rounded-lg border border-ink-100 p-2 text-ink-600 hover:text-ink-800 hover:bg-ink-50 transition-colors"
title="Open Repository"
>
<GithubIcon className="h-4 w-4" />
</a>
)}
<button
onClick={() => handleDownload(asset)}
className="rounded-lg bg-primary-400 p-2 text-ink-800 hover:bg-primary-300 transition-colors shadow-sm"
title="Download Asset"
>
<Download className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Asset Details Modal */}
{selectedAsset && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-800/20 backdrop-blur-sm p-4 animate-fade-in">
<div className="w-full max-w-2xl rounded-2xl border border-ink-100 bg-white p-6 shadow-premium max-h-[90vh] overflow-y-auto space-y-6 relative animate-scale-up">
<button
onClick={() => setSelectedAsset(null)}
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>
{/* Modal Image banner */}
<div className="h-60 rounded-xl overflow-hidden bg-ink-100 relative">
<img
src={selectedAsset.thumbnailUrl}
alt={selectedAsset.title}
className="w-full h-full object-cover"
/>
<span className="absolute left-4 top-4 rounded-full bg-white/95 px-3 py-1.5 text-xs font-bold uppercase tracking-wider text-primary-700 shadow-sm border border-primary-100 flex items-center gap-1.5">
{getCategoryIcon(selectedAsset.categoryId)}
{selectedAsset.subcategory}
</span>
</div>
{/* Meta */}
<div className="space-y-4">
<h2 className="text-xl font-bold text-ink-800 leading-snug">{selectedAsset.title}</h2>
<div className="flex flex-wrap gap-y-2 gap-x-4 text-xs text-ink-600 font-medium">
<span className="flex items-center gap-1.5">
<User className="h-4 w-4 text-ink-600" />
By: {selectedAsset.author}
</span>
<span className="flex items-center gap-1.5">
<Calendar className="h-4 w-4 text-ink-600" />
Released: {selectedAsset.publishDate}
</span>
<span className="flex items-center gap-1.5">
<Download className="h-4 w-4 text-ink-600" />
Downloads: {selectedAsset.downloadsCount}
</span>
</div>
</div>
{/* Description */}
<div className="border-t border-ink-100 pt-4">
<h4 className="text-xs font-bold uppercase tracking-wider text-ink-600 mb-2">Description / Technical Overview</h4>
<p className="text-sm text-ink-700 leading-relaxed bg-ink-50 p-4 rounded-lg border border-ink-100">
{selectedAsset.description}
</p>
</div>
{/* Tags */}
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-ink-600 mb-2 flex items-center gap-1">
<Tag className="h-3 w-3 text-ink-600" /> Tags
</h4>
<div className="flex flex-wrap gap-2">
{selectedAsset.tags.map(tag => (
<span key={tag} className="rounded bg-primary-50 px-2.5 py-1 text-xs font-semibold text-primary-700 border border-primary-100">
{tag}
</span>
))}
</div>
</div>
{/* Footer buttons */}
<div className="border-t border-ink-100 pt-4 flex justify-end gap-3">
{selectedAsset.githubUrl && (
<a
href={selectedAsset.githubUrl}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-lg border border-ink-200 bg-white px-4 py-2 text-sm font-semibold text-ink-700 hover:bg-ink-50 transition-premium"
>
<GithubIcon className="h-4 w-4" />
Repository URL
</a>
)}
<button
onClick={() => handleDownload(selectedAsset)}
className="flex items-center gap-2 rounded-lg bg-primary-400 px-5 py-2 text-sm font-semibold text-ink-800 hover:bg-primary-300 transition-premium shadow-premium"
>
<Download className="h-4 w-4" />
Download Files
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,419 @@
import React, { useState, useEffect } from 'react';
import type { Asset, Category } from '../../../types';
import { apiClient } from '../../../lib/api-client';
import { Plus, ToggleLeft, ToggleRight, Trash2, X, Sparkles, Folder, FileText, Tag, Link2 } from 'lucide-react';
export const AssetManagement: React.FC = () => {
const [assets, setAssets] = useState<Asset[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
// Form Modal State
const [isOpen, setIsOpen] = useState(false);
const [title, setTitle] = useState('');
const [categoryId, setCategoryId] = useState('silicon');
const [subcategory, setSubcategory] = useState('');
const [description, setDescription] = useState('');
const [tagsInput, setTagsInput] = useState('');
const [thumbnailUrl, setThumbnailUrl] = useState('');
const [githubUrl, setGithubUrl] = useState('');
const [status, setStatus] = useState<'draft' | 'published'>('draft');
const [formError, setFormError] = useState('');
const [submitting, setSubmitting] = useState(false);
const fetchAssets = async () => {
setLoading(true);
try {
const response = await apiClient.get<Asset[]>('/assets');
setAssets(response.data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const fetchCategories = async () => {
try {
const response = await apiClient.get<Category[]>('/categories');
setCategories(response.data);
// Pre-set subcategory default
if (response.data.length > 0) {
setSubcategory(response.data[0].subcategories[0]);
}
} catch (err) {
console.error(err);
}
};
useEffect(() => {
fetchAssets();
fetchCategories();
}, []);
// Update subcategories when category changes in form
useEffect(() => {
const cat = categories.find(c => c.id === categoryId);
if (cat && cat.subcategories.length > 0) {
setSubcategory(cat.subcategories[0]);
}
}, [categoryId, categories]);
const handleToggleStatus = async (asset: Asset) => {
const updatedStatus = asset.status === 'published' ? 'draft' : 'published';
try {
const response = await apiClient.put<Asset>(`/assets/${asset.id}`, {
...asset,
status: updatedStatus,
});
setAssets(prev => prev.map(a => (a.id === asset.id ? response.data : a)));
} catch (err) {
console.error(err);
}
};
const handleDelete = async (id: string) => {
if (!window.confirm('Are you sure you want to delete this asset?')) return;
try {
await apiClient.delete(`/assets/${id}`);
setAssets(prev => prev.filter(a => a.id !== id));
} catch (err) {
console.error(err);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError('');
if (!title.trim() || !description.trim()) {
setFormError('Title and description are required.');
return;
}
setSubmitting(true);
try {
// Parse tags
const tags = tagsInput
.split(',')
.map(t => t.trim())
.filter(t => t.length > 0);
const payload = {
title,
categoryId,
subcategory,
description,
tags,
thumbnailUrl: thumbnailUrl.trim() || undefined,
githubUrl: githubUrl.trim() || undefined,
status,
author: 'Portal Admin',
};
const response = await apiClient.post<Asset>('/assets/new', payload);
setAssets(prev => [response.data, ...prev]);
// Reset Form & Close
setTitle('');
setDescription('');
setTagsInput('');
setThumbnailUrl('');
setGithubUrl('');
setStatus('draft');
setIsOpen(false);
} catch (err) {
setFormError('Failed to create asset.');
} finally {
setSubmitting(false);
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-ink-800">Resource & Asset Catalog</h2>
<p className="text-sm text-ink-600">Publish silicon blocks, software utilities, or cloud infrastructure configurations.</p>
</div>
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-1.5 rounded-lg bg-primary-400 px-4 py-2.5 text-xs font-semibold text-ink-800 hover:bg-primary-300 transition-premium shadow-premium"
>
<Plus className="h-4 w-4" />
Create Asset
</button>
</div>
{/* Asset Table */}
<div className="rounded-xl border border-ink-100 bg-white shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<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">Tags</th>
<th className="px-5 py-3.5">Downloads</th>
<th className="px-5 py-3.5">Status</th>
<th className="px-5 py-3.5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-100 text-sm">
{loading ? (
<tr>
<td colSpan={5} className="px-5 py-8 text-center text-ink-600 animate-pulse">
Loading resources catalog...
</td>
</tr>
) : assets.length === 0 ? (
<tr>
<td colSpan={5} className="px-5 py-8 text-center text-ink-600">
No resources cataloged yet.
</td>
</tr>
) : (
assets.map(asset => (
<tr key={asset.id} className="hover:bg-ink-50/50 transition-colors">
<td className="px-5 py-4">
<div className="space-y-1">
<p className="font-bold text-ink-800">{asset.title}</p>
<div className="flex items-center gap-1.5 text-xs text-ink-600">
<span className="capitalize font-semibold text-primary-700">{asset.categoryId}</span>
<span></span>
<span>{asset.subcategory}</span>
</div>
</div>
</td>
<td className="px-5 py-4">
<div className="flex flex-wrap gap-1">
{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">
{t}
</span>
))}
</div>
</td>
<td className="px-5 py-4 font-mono font-semibold text-ink-700">
{asset.downloadsCount}
</td>
<td className="px-5 py-4">
<span
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider border ${
asset.status === 'published'
? 'bg-success/10 text-success border-success/20'
: 'bg-ink-100 text-ink-600 border-ink-200'
}`}
>
{asset.status}
</span>
</td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-1.5">
{/* Toggle Status Button */}
<button
onClick={() => handleToggleStatus(asset)}
className="rounded p-1.5 text-ink-600 hover:bg-ink-100 hover:text-ink-800 transition-colors"
title={asset.status === 'published' ? 'Set as Draft' : 'Publish Asset'}
>
{asset.status === 'published' ? (
<ToggleRight className="h-5 w-5 text-primary-600" />
) : (
<ToggleLeft className="h-5 w-5 text-ink-300" />
)}
</button>
{/* Delete Button */}
<button
onClick={() => handleDelete(asset.id)}
className="rounded p-1.5 text-danger hover:bg-danger/10 transition-colors"
title="Delete Asset"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Form Dialog Modal */}
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-800/20 backdrop-blur-sm p-4 animate-fade-in">
<div className="w-full max-w-xl rounded-2xl border border-ink-100 bg-white p-6 shadow-premium 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-primary-600" />
Catalog New Asset
</h3>
<p className="text-xs text-ink-600">Enter technical specifications and publish rules for the resource.</p>
</div>
{formError && (
<div className="mb-4 rounded-lg bg-danger/5 border border-danger/20 p-3 text-xs font-semibold text-danger">
{formError}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1 flex items-center gap-1.5">
<FileText className="h-3.5 w-3.5 text-ink-600" />
Asset Title / Name
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. RISC-V Cryptographic Processor Core"
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-primary-500 focus:outline-none"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1 flex items-center gap-1.5">
<Folder className="h-3.5 w-3.5 text-ink-600" />
Category
</label>
<select
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className="w-full rounded-lg border border-ink-200 bg-white px-3.5 py-2 text-sm focus:border-primary-500 focus:outline-none"
>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Subcategory</label>
<select
value={subcategory}
onChange={(e) => setSubcategory(e.target.value)}
className="w-full rounded-lg border border-ink-200 bg-white px-3.5 py-2 text-sm focus:border-primary-500 focus:outline-none"
>
{categories
.find(c => c.id === categoryId)
?.subcategories.map(sub => (
<option key={sub} value={sub}>{sub}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Technical Overview / Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Provide synthesizable attributes, SDK support, pipeline latency details, etc."
rows={4}
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-primary-500 focus:outline-none resize-y"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1 flex items-center gap-1.5">
<Tag className="h-3.5 w-3.5 text-ink-600" />
Tags (comma-separated)
</label>
<input
type="text"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="FPGA, RISC-V, Open-Source"
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-primary-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1 flex items-center gap-1.5">
<Link2 className="h-3.5 w-3.5 text-ink-600" />
GitHub Repo URL
</label>
<input
type="url"
value={githubUrl}
onChange={(e) => setGithubUrl(e.target.value)}
placeholder="https://github.com/..."
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-primary-500 focus:outline-none"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Thumbnail 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-primary-500 focus:outline-none"
/>
</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="status"
checked={status === 'draft'}
onChange={() => setStatus('draft')}
className="text-primary-500 focus:ring-primary-500"
/>
Draft
</label>
<label className="flex items-center gap-1.5 text-xs text-ink-800 font-semibold cursor-pointer">
<input
type="radio"
name="status"
checked={status === 'published'}
onChange={() => setStatus('published')}
className="text-primary-500 focus:ring-primary-500"
/>
Published
</label>
</div>
</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-white px-4 py-2 text-sm font-semibold text-ink-600 hover:bg-ink-50 transition-premium"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-primary-400 px-5 py-2 text-sm font-semibold text-ink-800 hover:bg-primary-300 transition-premium shadow-premium disabled:opacity-50"
>
{submitting ? 'Creating...' : 'Create Asset'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,594 @@
import React, { useState, useEffect, useRef } from 'react';
import { useAuth } from '../store/AuthContext';
import { useNavigate } from 'react-router-dom';
import { ShieldCheck, Mail, Lock, Sparkles, ArrowRight, Eye, EyeOff, RefreshCw } from 'lucide-react';
/* ── Floating Particle ─────────────────────────────────────── */
interface Particle {
id: number;
x: number;
y: number;
size: number;
opacity: number;
delay: number;
duration: number;
}
const generateParticles = (count: number): Particle[] =>
Array.from({ length: count }, (_, i) => ({
id: i,
x: Math.random() * 100,
y: Math.random() * 100,
size: Math.random() * 4 + 2,
opacity: Math.random() * 0.4 + 0.1,
delay: Math.random() * 6,
duration: Math.random() * 4 + 5,
}));
/* ── Animated Background ───────────────────────────────────── */
const AnimatedBackground: React.FC = () => {
const [particles] = useState(() => generateParticles(25));
return (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{/* Mesh gradient base */}
<div className="absolute inset-0 gradient-mesh" />
{/* Large glowing orbs */}
<div
className="orb orb-primary absolute w-[500px] h-[500px] -top-32 -right-32 opacity-60"
style={{ animationDuration: '9s' }}
/>
<div
className="orb orb-secondary absolute w-[400px] h-[400px] -bottom-20 -left-20 opacity-50"
style={{ animationDuration: '7s', animationDelay: '-4s' }}
/>
<div
className="orb orb-accent absolute w-[300px] h-[300px] top-1/2 left-1/3 opacity-40"
style={{ animationDuration: '11s', animationDelay: '-2s' }}
/>
{/* Morphing blob */}
<div
className="absolute top-1/4 right-1/4 w-64 h-64 opacity-20 animate-morph"
style={{
background: 'linear-gradient(135deg, rgba(162,231,113,0.5), rgba(117,191,70,0.3))',
filter: 'blur(40px)',
}}
/>
{/* Grid overlay */}
<div
className="absolute inset-0 opacity-[0.025]"
style={{
backgroundImage: `linear-gradient(var(--color-ink-800) 1px, transparent 1px),
linear-gradient(90deg, var(--color-ink-800) 1px, transparent 1px)`,
backgroundSize: '60px 60px',
}}
/>
{/* Floating particles */}
{particles.map(p => (
<div
key={p.id}
className="absolute rounded-full animate-float"
style={{
left: `${p.x}%`,
top: `${p.y}%`,
width: `${p.size}px`,
height: `${p.size}px`,
opacity: p.opacity,
background: `radial-gradient(circle, var(--color-primary-400), var(--color-primary-600))`,
animationDelay: `${-p.delay}s`,
animationDuration: `${p.duration}s`,
filter: 'blur(0.5px)',
}}
/>
))}
</div>
);
};
/* ── 3D Orbiting Ring ──────────────────────────────────────── */
const OrbitingRing: React.FC = () => (
<div className="relative w-28 h-28 mx-auto">
{/* Center icon */}
<div className="absolute inset-0 flex items-center justify-center z-10">
<div
className="w-16 h-16 rounded-2xl flex items-center justify-center animate-pulse-glow"
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)',
}}
>
<ShieldCheck className="w-8 h-8" style={{ color: 'var(--color-ink-800)' }} />
</div>
</div>
{/* Orbiting ring 1 */}
<div
className="absolute inset-0 rounded-full"
style={{
border: '1.5px solid rgba(162,231,113,0.3)',
animation: 'spin 6s linear infinite',
}}
>
<div
className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 rounded-full"
style={{ background: 'var(--color-primary-400)', boxShadow: '0 0 8px rgba(162,231,113,0.8)' }}
/>
</div>
{/* Orbiting ring 2 */}
<div
className="absolute inset-3 rounded-full"
style={{
border: '1px solid rgba(162,231,113,0.2)',
animation: 'spin 4s linear infinite reverse',
}}
>
<div
className="absolute -top-1 left-1/2 -translate-x-1/2 w-2 h-2 rounded-full"
style={{ background: 'var(--color-primary-600)' }}
/>
</div>
</div>
);
/* ── Main LoginForm ────────────────────────────────────────── */
export const LoginForm: React.FC = () => {
const { login, register, verifyMfa, mfaPendingEmail } = useAuth();
const navigate = useNavigate();
const [isRegister, setIsRegister] = useState(false);
const [email, setEmail] = useState('');
const [showPass, setShowPass] = useState(false);
const [otp, setOtp] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const cardRef = useRef<HTMLDivElement>(null);
// Client Profile Registration States
const [companyName, setCompanyName] = useState('');
const [website, setWebsite] = useState('');
const [sector, setSector] = useState('Technology');
const [companySize, setCompanySize] = useState('1-10');
/* 3D tilt effect on card */
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!cardRef.current) return;
const rect = cardRef.current.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width - 0.5;
const y = (e.clientY - rect.top) / rect.height - 0.5;
setMousePos({ x, y });
};
const handleMouseLeave = () => setMousePos({ x: 0, y: 0 });
const cardTransform = `perspective(1200px) rotateY(${mousePos.x * 8}deg) rotateX(${-mousePos.y * 6}deg) translateZ(0)`;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
if (!email) { setError('Email address is required.'); setLoading(false); return; }
try {
if (isRegister) {
if (!companyName.trim() || !website.trim()) {
setError('Please fill in all company information.');
setLoading(false);
return;
}
await register(email, { companyName, website, sector, companySize });
} else {
await login(email);
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Authentication failed.');
} finally {
setLoading(false);
}
};
const handleMfaSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
if (otp.length !== 6) { setError('Enter the 6-digit verification code.'); setLoading(false); return; }
try {
const loggedUser = await verifyMfa(otp);
navigate(loggedUser.role === 'ADMIN' ? '/admin' : '/client');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'MFA validation failed.');
} finally {
setLoading(false);
}
};
const [mounted, setMounted] = useState(false);
useEffect(() => { setTimeout(() => setMounted(true), 50); }, []);
return (
<div className="relative min-h-screen flex items-center justify-center overflow-hidden p-4">
<AnimatedBackground />
{/* Card wrapper with 3D tilt */}
<div
ref={cardRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className="relative w-full max-w-md z-10"
style={{
transform: cardTransform,
transition: 'transform 0.15s cubic-bezier(0.16,1,0.3,1)',
transformStyle: 'preserve-3d',
opacity: mounted ? 1 : 0,
animation: mounted ? 'scale-up 0.6s cubic-bezier(0.16,1,0.3,1) forwards' : 'none',
}}
>
{/* Glow behind card */}
<div
className="absolute -inset-4 rounded-3xl opacity-40 blur-3xl"
style={{ background: 'linear-gradient(135deg, rgba(162,231,113,0.3), rgba(117,191,70,0.2))' }}
/>
{/* Main glass card */}
<div
className="relative rounded-3xl overflow-hidden"
style={{
background: 'rgba(255,255,255,0.85)',
backdropFilter: 'blur(40px) saturate(200%)',
WebkitBackdropFilter: 'blur(40px) saturate(200%)',
border: '1px solid rgba(255,255,255,0.9)',
boxShadow: `
0 30px 80px -10px rgba(35,43,33,0.15),
0 0 0 1px rgba(162,231,113,0.15),
inset 0 1px 0 rgba(255,255,255,0.9)
`,
}}
>
{/* Top accent bar */}
<div
className="h-1 w-full"
style={{ background: 'linear-gradient(90deg, var(--color-primary-400), var(--color-primary-600), var(--color-primary-400))' }}
/>
<div className="p-8 space-y-7">
{/* Brand Header */}
<div className="text-center space-y-4 animate-fade-in">
<OrbitingRing />
<div>
<h1 className="text-2xl font-extrabold tracking-tight" style={{ color: 'var(--color-ink-800)' }}>
{mfaPendingEmail ? 'Verify Identity' : 'Tech4Biz Portal'}
</h1>
<p className="text-sm mt-1.5" style={{ color: 'var(--color-ink-600)' }}>
{mfaPendingEmail
? `Code sent to ${mfaPendingEmail}`
: 'Enterprise hardware & software asset distribution'}
</p>
</div>
{/* Mode toggle */}
{!mfaPendingEmail && (
<div
className="inline-flex rounded-xl p-1 gap-1"
style={{ background: 'var(--color-ink-100)' }}
>
{(['Login', 'Sign Up'] as const).map(label => {
const active = label === 'Login' ? !isRegister : isRegister;
return (
<button
key={label}
type="button"
onClick={() => { setIsRegister(label === 'Sign Up'); setError(''); }}
className="px-5 py-1.5 rounded-lg text-xs font-bold transition-all duration-300"
style={{
background: active ? 'white' : 'transparent',
color: active ? 'var(--color-ink-800)' : 'var(--color-ink-500)',
boxShadow: active ? '0 1px 4px rgba(35,43,33,0.08)' : 'none',
}}
>
{label}
</button>
);
})}
</div>
)}
</div>
{/* Error message */}
{error && (
<div
className="flex items-center gap-2.5 rounded-xl px-4 py-3 text-sm font-medium animate-slide-up"
style={{
background: 'rgba(229,72,77,0.07)',
border: '1px solid rgba(229,72,77,0.2)',
color: 'var(--color-danger)',
}}
>
<div className="w-1.5 h-1.5 rounded-full bg-current shrink-0" />
{error}
</div>
)}
{/* ── Credentials Form ── */}
{!mfaPendingEmail ? (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Email field */}
<div className="animate-slide-up" style={{ animationDelay: '0.05s' }}>
<label
className="block text-xs font-bold mb-2 uppercase tracking-widest"
style={{ color: 'var(--color-ink-600)' }}
>
Email Address
</label>
<div className="relative">
<div
className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: 'var(--color-ink-400)' }}
>
<Mail className="w-4 h-4" />
</div>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="client@tech4biz.com"
className="input-field has-left-icon"
required
/>
</div>
</div>
{/* Password field (mock) */}
<div className="animate-slide-up" style={{ animationDelay: '0.1s' }}>
<label
className="block text-xs font-bold mb-2 uppercase tracking-widest"
style={{ color: 'var(--color-ink-600)' }}
>
Password
</label>
<div className="relative">
<div
className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: 'var(--color-ink-400)' }}
>
<Lock className="w-4 h-4" />
</div>
<input
type={showPass ? 'text' : 'password'}
defaultValue="password"
readOnly
className="input-field has-left-icon has-right-icon"
style={{ background: 'var(--color-ink-50)', cursor: 'default' }}
/>
<button
type="button"
onClick={() => setShowPass(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 transition-premium"
style={{ color: 'var(--color-ink-400)' }}
>
{showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="mt-1.5 text-xs" style={{ color: 'var(--color-ink-500)' }}>
Mock password preset:{' '}
<code
className="px-1.5 py-0.5 rounded font-mono text-xs font-bold"
style={{ background: 'var(--color-primary-50)', color: 'var(--color-primary-800)' }}
>
password
</code>
</p>
</div>
{/* Register Profile Fields */}
{isRegister && (
<>
<div className="animate-slide-up" style={{ animationDelay: '0.12s' }}>
<label
className="block text-xs font-bold mb-2 uppercase tracking-widest"
style={{ color: 'var(--color-ink-600)' }}
>
Company Name
</label>
<input
type="text"
value={companyName}
onChange={e => setCompanyName(e.target.value)}
placeholder="e.g. Acme Corporation"
className="input-field"
required
/>
</div>
<div className="animate-slide-up" style={{ animationDelay: '0.14s' }}>
<label
className="block text-xs font-bold mb-2 uppercase tracking-widest"
style={{ color: 'var(--color-ink-600)' }}
>
Corporate Website
</label>
<input
type="url"
value={website}
onChange={e => setWebsite(e.target.value)}
placeholder="https://example.com"
className="input-field"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="animate-slide-up" style={{ animationDelay: '0.16s' }}>
<label
className="block text-xs font-bold mb-2 uppercase tracking-widest"
style={{ color: 'var(--color-ink-600)' }}
>
Sector
</label>
<select
value={sector}
onChange={e => setSector(e.target.value)}
className="input-field bg-white"
>
<option value="Technology">Technology</option>
<option value="Automotive">Automotive</option>
<option value="Telecommunications">Telecommunications</option>
<option value="Defense">Defense</option>
<option value="Semiconductors">Semiconductors</option>
<option value="Other">Other</option>
</select>
</div>
<div className="animate-slide-up" style={{ animationDelay: '0.16s' }}>
<label
className="block text-xs font-bold mb-2 uppercase tracking-widest"
style={{ color: 'var(--color-ink-600)' }}
>
Company Size
</label>
<select
value={companySize}
onChange={e => setCompanySize(e.target.value)}
className="input-field bg-white"
>
<option value="1-10">1-10</option>
<option value="10-50">10-50</option>
<option value="50-250">50-250</option>
<option value="250-1000">250-1000</option>
<option value="1000+">1000+</option>
</select>
</div>
</div>
</>
)}
{/* Quick fill hints */}
{!isRegister && (
<div
className="rounded-xl p-3 animate-slide-up"
style={{ background: 'var(--color-ink-50)', border: '1px solid var(--color-ink-100)', animationDelay: '0.15s' }}
>
<p className="text-xs font-bold mb-2" style={{ color: 'var(--color-ink-600)' }}>
Quick Access Accounts
</p>
<div className="flex flex-wrap gap-2">
{[
{ label: 'Admin', email: 'admin@tech4biz.com' },
{ label: 'Client', email: 'client@tech4biz.com' },
].map(acc => (
<button
key={acc.label}
type="button"
onClick={() => setEmail(acc.email)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-premium"
style={{
background: email === acc.email ? 'var(--color-primary-100)' : 'white',
color: email === acc.email ? 'var(--color-primary-800)' : 'var(--color-ink-600)',
border: `1px solid ${email === acc.email ? 'var(--color-primary-200)' : 'var(--color-ink-200)'}`,
}}
>
<Sparkles className="w-3 h-3" />
{acc.label}
</button>
))}
</div>
</div>
)}
{/* Submit */}
<button
type="submit"
disabled={loading}
className="btn-primary w-full py-3 text-sm flex items-center justify-center gap-2 animate-slide-up"
style={{ animationDelay: '0.2s' }}
>
{loading ? (
<>
<RefreshCw className="w-4 h-4 animate-spin-slow" />
Processing
</>
) : (
<>
{isRegister ? 'Create Account' : 'Authenticate'} <ArrowRight className="w-4 h-4" />
</>
)}
</button>
</form>
) : (
/* ── MFA Form ── */
<form onSubmit={handleMfaSubmit} className="space-y-5">
<div
className="text-center p-4 rounded-2xl animate-fade-in"
style={{ background: 'var(--color-primary-50)', border: '1px solid var(--color-primary-100)' }}
>
<p className="text-xs font-bold mb-1" style={{ color: 'var(--color-primary-700)' }}>
Development Mode
</p>
<p className="text-sm" style={{ color: 'var(--color-ink-700)' }}>
Use code{' '}
<code
className="px-2 py-0.5 rounded font-mono font-bold text-base"
style={{ background: 'var(--color-primary-100)', color: 'var(--color-primary-800)' }}
>
123456
</code>
</p>
</div>
{/* OTP input */}
<div>
<label className="block text-xs font-bold mb-3 text-center uppercase tracking-widest" style={{ color: 'var(--color-ink-600)' }}>
Verification Code
</label>
<input
type="text"
inputMode="numeric"
maxLength={6}
value={otp}
onChange={e => setOtp(e.target.value.replace(/\D/g, ''))}
placeholder="000000"
className="input-field text-center font-mono text-2xl tracking-[0.5em] py-4"
required
autoFocus
/>
</div>
<button
type="submit"
disabled={loading || otp.length !== 6}
className="btn-primary w-full py-3 text-sm flex items-center justify-center gap-2"
>
{loading ? (
<>
<RefreshCw className="w-4 h-4 animate-spin-slow" />
Verifying
</>
) : (
<>
<ShieldCheck className="w-4 h-4" />
Verify Code
</>
)}
</button>
</form>
)}
</div>
{/* Bottom accent */}
<div
className="px-8 py-4 text-center"
style={{ borderTop: '1px solid var(--color-ink-50)', background: 'rgba(247,249,246,0.5)' }}
>
<p className="text-xs" style={{ color: 'var(--color-ink-400)' }}>
© 2026 Tech4Biz Solutions Inc. · Enterprise Security Portal
</p>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,135 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import type { User } from '../../../types';
import { apiClient } from '../../../lib/api-client';
interface AuthContextType {
user: User | null;
loading: boolean;
mfaPendingEmail: string | null;
login: (email: string) => Promise<User>;
register: (
email: string,
profile?: { companyName: string; website: string; sector: string; companySize: string }
) => Promise<User>;
verifyMfa: (code: string) => Promise<User>;
logout: () => void;
updateUserLocal: (updatedUser: User) => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [mfaPendingEmail, setMfaPendingEmail] = useState<string | null>(null);
// Check active session on boot
useEffect(() => {
const fetchSession = async () => {
const activeSessionEmail = sessionStorage.getItem('t4b_session_email');
if (activeSessionEmail) {
try {
const response = await apiClient.get<User>('/auth/session');
if (response.data.mfaVerified) {
setUser(response.data);
} else {
// Need MFA
setMfaPendingEmail(activeSessionEmail);
}
} catch (error) {
sessionStorage.removeItem('t4b_session_email');
}
}
setLoading(false);
};
fetchSession();
}, []);
const login = async (email: string): Promise<User> => {
setLoading(true);
try {
const response = await apiClient.post<User>('/auth/login', { email, password: 'password' });
setMfaPendingEmail(email);
setLoading(false);
return response.data;
} catch (error: any) {
setLoading(false);
throw error;
}
};
const register = async (
email: string,
profile?: { companyName: string; website: string; sector: string; companySize: string }
): Promise<User> => {
setLoading(true);
try {
const response = await apiClient.post<User>('/auth/register', {
email,
password: 'password',
...profile
});
setMfaPendingEmail(email);
setLoading(false);
return response.data;
} catch (error: any) {
setLoading(false);
throw error;
}
};
const verifyMfa = async (code: string): Promise<User> => {
if (!mfaPendingEmail) {
throw new Error('No MFA challenge active');
}
setLoading(true);
try {
const response = await apiClient.post<User>('/auth/mfa-verify', {
email: mfaPendingEmail,
code
});
setUser(response.data);
setMfaPendingEmail(null);
setLoading(false);
return response.data;
} catch (error: any) {
setLoading(false);
throw error;
}
};
const logout = () => {
sessionStorage.removeItem('t4b_session_email');
setUser(null);
setMfaPendingEmail(null);
};
const updateUserLocal = (updatedUser: User) => {
setUser(updatedUser);
};
return (
<AuthContext.Provider value={{
user,
loading,
mfaPendingEmail,
login,
register,
verifyMfa,
logout,
updateUserLocal
}}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@ -0,0 +1,285 @@
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">
<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-primary-400 px-4 py-2.5 text-xs font-semibold text-ink-800 hover:bg-primary-300 transition-premium shadow-premium"
>
<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-100 bg-white 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-100 bg-white 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-100 bg-white shadow-sm overflow-hidden flex flex-col hover:shadow-premium hover:-translate-y-0.5 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-success/90 text-white border-success' : 'bg-ink-200 text-ink-700 border-ink-300'
}`}>
{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-50">
{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-100">
#{t}
</span>
))}
</div>
</div>
</article>
))}
</div>
)}
{/* Post Creator Modal */}
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-800/20 backdrop-blur-sm p-4 animate-fade-in">
<div className="w-full max-w-lg rounded-2xl border border-ink-100 bg-white p-6 shadow-premium 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-primary-600" />
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-danger/5 border border-danger/20 p-3 text-xs font-semibold text-danger">
{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-primary-500 focus:outline-none"
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-primary-500 focus:outline-none resize-y"
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-primary-500 focus:outline-none"
/>
</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-primary-500 focus:outline-none"
/>
</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-primary-500 focus:ring-primary-500"
/>
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-primary-500 focus:ring-primary-500"
/>
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-white px-4 py-2 text-sm font-semibold text-ink-600 hover:bg-ink-50 transition-premium"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-primary-400 px-5 py-2 text-sm font-semibold text-ink-800 hover:bg-primary-300 transition-premium shadow-premium flex items-center gap-1.5"
>
<Send className="h-4 w-4" />
{submitting ? 'Publishing...' : 'Publish Article'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,527 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../auth/store/AuthContext';
import { DocumentPreview } from '../../agreements/components/DocumentPreview';
import { SignatureCapture } from '../../../components/organisms/SignatureCapture';
import type { SignaturePayload, User } from '../../../types';
import { apiClient } from '../../../lib/api-client';
import {
CheckCircle2,
ChevronRight,
FileSignature,
Landmark,
Building2,
Globe,
Loader2,
ShieldAlert,
ShieldX,
LogOut,
Sparkles,
ArrowRight
} from 'lucide-react';
export const OnboardingWizard: React.FC = () => {
const { user, updateUserLocal, logout } = useAuth();
const navigate = useNavigate();
// Determine current active step based on onboarding status and signature state
const getStepIndex = (currentUser: User | null): number => {
if (!currentUser) return 0;
if (currentUser.onboardingStatus === 'NOT_STARTED') return 0;
if (currentUser.onboardingStatus === 'FORM_COMPLETED') return 1;
if (currentUser.onboardingStatus === 'NDA_SIGNED') return 2;
if (currentUser.onboardingStatus === 'PENDING_APPROVAL' || currentUser.onboardingStatus === 'REJECTED') return 3;
if (currentUser.onboardingStatus === 'APPROVED') {
if (!currentUser.ndaSignature) return 1;
if (!currentUser.msaSignature) return 2;
return 4; // Completed
}
return 0;
};
const [step, setStep] = useState<number>(() => getStepIndex(user));
// Step 0: Profile Form State
const [companyName, setCompanyName] = useState(user?.companyName || '');
const [website, setWebsite] = useState(user?.website || '');
const [sector, setSector] = useState(user?.sector || 'Technology');
const [companySize, setCompanySize] = useState(user?.companySize || '1-10');
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const [saving, setSaving] = useState(false);
// Signature state
const [signingDoc, setSigningDoc] = useState<'NDA' | 'MSA' | null>(null);
const [redirecting, setRedirecting] = useState(false);
// Sync step state when user status updates
useEffect(() => {
if (user) {
const targetStep = getStepIndex(user);
setStep(targetStep);
// Initialize form fields if they were empty
if (user.companyName && !companyName) setCompanyName(user.companyName);
if (user.website && !website) setWebsite(user.website);
if (user.sector && sector === 'Technology') setSector(user.sector);
if (user.companySize && companySize === '1-10') setCompanySize(user.companySize);
// Auto-redirect if onboarding is fully complete
if (targetStep === 4 && !redirecting) {
setRedirecting(true);
const timer = setTimeout(() => {
navigate('/client');
}, 2000);
return () => clearTimeout(timer);
}
}
}, [user, navigate, redirecting]);
// Real-time polling for admin approval/rejection status
useEffect(() => {
if (!user || user.onboardingStatus !== 'PENDING_APPROVAL') return;
const interval = setInterval(async () => {
try {
const response = await apiClient.get<User>('/auth/session');
if (response.data.onboardingStatus !== 'PENDING_APPROVAL') {
updateUserLocal(response.data);
}
} catch (err) {
console.error('Error polling onboarding status:', err);
}
}, 3000); // Poll every 3 seconds
return () => clearInterval(interval);
}, [user, updateUserLocal]);
const validateForm = () => {
const newErrors: { [key: string]: string } = {};
if (!companyName.trim()) newErrors.companyName = 'Company name is required';
if (!website.trim()) {
newErrors.website = 'Website is required';
} else if (!/^https?:\/\/[^\s$.?#].[^\s]*$/i.test(website)) {
newErrors.website = 'Enter a valid URL (e.g., https://example.com)';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleProfileSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
setSaving(true);
try {
const response = await apiClient.post<User>('/client/onboarding', {
companyName,
website,
sector,
companySize,
});
updateUserLocal(response.data);
} catch (err) {
console.error(err);
} finally {
setSaving(false);
}
};
const handleSaveSignature = async (payload: SignaturePayload) => {
if (!signingDoc) return;
setSaving(true);
try {
const endpoint = signingDoc === 'NDA' ? '/client/nda' : '/client/msa';
const response = await apiClient.post<User>(endpoint, payload);
updateUserLocal(response.data);
setSigningDoc(null);
} catch (err) {
console.error(err);
} finally {
setSaving(false);
}
};
// Steps breadcrumb styling (stepper has 4 primary steps)
const stepsList = [
{ title: 'Profile', icon: Building2 },
{ title: 'NDA Agreement', icon: FileSignature },
{ title: 'MSA Agreement', icon: Landmark },
{ title: 'Admin Review', icon: ShieldAlert }
];
return (
<div className="mx-auto max-w-4xl p-4 md:p-8">
{/* Step Indicator */}
<div className="mb-8 rounded-xl border border-ink-100 bg-white p-4 shadow-sm md:p-6">
<div className="flex items-center justify-between">
{stepsList.map((s, idx) => {
const Icon = s.icon;
// The step is index-based.
const isCompleted = step > idx;
const isActive = step === idx;
return (
<React.Fragment key={s.title}>
<div className="flex flex-col items-center gap-1.5 md:flex-row md:gap-3">
<div
className={`flex h-10 w-10 items-center justify-center rounded-full transition-all duration-300 ${
isCompleted
? 'bg-success text-white'
: isActive
? 'bg-primary-400 text-ink-800 ring-4 ring-primary-100'
: 'bg-ink-100 text-ink-600'
}`}
>
{isCompleted ? (
<CheckCircle2 className="h-5 w-5" />
) : (
<Icon className="h-5 w-5" />
)}
</div>
<div className="text-center md:text-left">
<p className={`text-[10px] font-bold uppercase tracking-wider ${isActive ? 'text-primary-700' : 'text-ink-600'}`}>
Step {idx + 1}
</p>
<p className={`text-xs font-semibold ${isActive ? 'text-ink-800' : 'text-ink-600'}`}>
{s.title}
</p>
</div>
</div>
{idx < stepsList.length - 1 && (
<div className="hidden h-[2px] flex-1 bg-ink-100 md:block mx-4" />
)}
</React.Fragment>
);
})}
</div>
</div>
{/* Forms & Screens */}
{step === 0 && (
<div className="rounded-xl border border-ink-100 bg-white p-6 shadow-premium transition-premium">
<div className="mb-6">
<h2 className="text-xl font-bold text-ink-800">Business Profile</h2>
<p className="text-sm text-ink-600">Please provide your business and legal details to start the partnership onboarding.</p>
</div>
<form onSubmit={handleProfileSubmit} className="space-y-5">
<div className="grid gap-5 md:grid-cols-2">
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1.5 flex items-center gap-1">
<Building2 className="h-3.5 w-3.5 text-ink-600" />
Legal Company Name
</label>
<input
type="text"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
className={`w-full rounded-lg border px-3.5 py-2 text-sm focus:border-primary-500 focus:outline-none transition-colors ${
errors.companyName ? 'border-danger bg-danger/5' : 'border-ink-200'
}`}
placeholder="e.g. Acme Tech Ltd."
/>
{errors.companyName && (
<p className="mt-1 text-xs text-danger font-medium">{errors.companyName}</p>
)}
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1.5 flex items-center gap-1">
<Globe className="h-3.5 w-3.5 text-ink-600" />
Corporate Website
</label>
<input
type="text"
value={website}
onChange={(e) => setWebsite(e.target.value)}
className={`w-full rounded-lg border px-3.5 py-2 text-sm focus:border-primary-500 focus:outline-none transition-colors ${
errors.website ? 'border-danger bg-danger/5' : 'border-ink-200'
}`}
placeholder="e.g. https://acme.com"
/>
{errors.website && (
<p className="mt-1 text-xs text-danger font-medium">{errors.website}</p>
)}
</div>
</div>
<div className="grid gap-5 md:grid-cols-2">
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1.5">Industry Sector</label>
<select
value={sector}
onChange={(e) => setSector(e.target.value)}
className="w-full rounded-lg border border-ink-200 bg-white px-3.5 py-2 text-sm focus:border-primary-500 focus:outline-none"
>
<option value="Technology">Technology & Software</option>
<option value="Automotive">Automotive & Robotics</option>
<option value="Telecommunications">Telecommunications</option>
<option value="Defense">Aerospace & Defense</option>
<option value="Semiconductors">Semiconductors & HW</option>
<option value="Other">Other Industries</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1.5">Company Size</label>
<select
value={companySize}
onChange={(e) => setCompanySize(e.target.value)}
className="w-full rounded-lg border border-ink-200 bg-white px-3.5 py-2 text-sm focus:border-primary-500 focus:outline-none"
>
<option value="1-10">1 - 10 employees</option>
<option value="10-50">10 - 50 employees</option>
<option value="50-250">50 - 250 employees</option>
<option value="250-1000">250 - 1000 employees</option>
<option value="1000+">1000+ employees</option>
</select>
</div>
</div>
<div className="flex justify-end pt-4 border-t border-ink-100">
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 rounded-lg bg-primary-400 px-6 py-2.5 text-sm font-semibold text-ink-800 hover:bg-primary-300 disabled:opacity-50 disabled:cursor-not-allowed transition-premium shadow-premium"
>
{saving ? 'Saving...' : 'Save & Continue'}
<ChevronRight className="h-4 w-4" />
</button>
</div>
</form>
</div>
)}
{step === 1 && (
<div className="space-y-6">
<div className="rounded-xl border border-ink-100 bg-white p-6 shadow-premium animate-premium">
<h2 className="text-xl font-bold text-ink-800">Mutual Non-Disclosure Agreement (NDA)</h2>
<p className="text-sm text-ink-600 mb-4">Please review and electronically sign our mutual NDA to request access to restricted silicon design files and proprietary source repositories.</p>
<DocumentPreview
title="Mutual Non-Disclosure Agreement (NDA)"
documentType="NDA"
companyName={companyName}
signatureDataUrl={user?.ndaSignature?.dataUrl}
signatureDate={user?.ndaSignature?.date}
/>
{!user?.ndaSignature && !signingDoc && (
<div className="mt-6 flex justify-end">
<button
type="button"
onClick={() => setSigningDoc('NDA')}
className="flex items-center gap-2 rounded-lg bg-primary-400 px-6 py-2.5 text-sm font-semibold text-ink-800 hover:bg-primary-300 transition-premium shadow-premium cursor-pointer"
>
<FileSignature className="h-4 w-4" />
Sign NDA Agreement
</button>
</div>
)}
{user?.ndaSignature && (
<div className="mt-6 flex justify-between items-center bg-success/5 border border-success/20 p-4 rounded-lg animate-premium">
<div className="flex items-center gap-2 text-success">
<CheckCircle2 className="h-5 w-5" />
<span className="text-sm font-semibold">NDA Agreement Signed Successfully</span>
</div>
<button
type="button"
onClick={() => setStep(2)}
className="flex items-center gap-1 text-sm font-bold text-primary-800 hover:underline cursor-pointer"
>
Proceed to MSA
<ChevronRight className="h-4 w-4" />
</button>
</div>
)}
</div>
{signingDoc === 'NDA' && (
<div className="mt-4 animate-premium">
<h3 className="text-base font-bold text-ink-800 mb-3 flex items-center gap-2">
<FileSignature className="h-5 w-5 text-primary-600" />
Sign Document: NDA
</h3>
<SignatureCapture
onSave={handleSaveSignature}
onCancel={() => setSigningDoc(null)}
/>
</div>
)}
</div>
)}
{step === 2 && (
<div className="space-y-6">
<div className="rounded-xl border border-ink-100 bg-white p-6 shadow-premium animate-premium">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-ink-800">Master Services Agreement (MSA)</h2>
<p className="text-sm text-ink-600">Please review and sign the Master Services Agreement specifying licensing structures.</p>
</div>
<button
onClick={() => setStep(1)}
className="text-xs text-ink-600 hover:underline cursor-pointer"
>
Back to NDA
</button>
</div>
<DocumentPreview
title="Master Services Agreement (MSA)"
documentType="MSA"
companyName={companyName}
signatureDataUrl={user?.msaSignature?.dataUrl}
signatureDate={user?.msaSignature?.date}
/>
{!user?.msaSignature && !signingDoc && (
<div className="mt-6 flex justify-end">
<button
type="button"
onClick={() => setSigningDoc('MSA')}
className="flex items-center gap-2 rounded-lg bg-primary-400 px-6 py-2.5 text-sm font-semibold text-ink-800 hover:bg-primary-300 transition-premium shadow-premium cursor-pointer"
>
<FileSignature className="h-4 w-4" />
Sign MSA Agreement
</button>
</div>
)}
{user?.msaSignature && (
<div className="mt-6 flex justify-between items-center bg-success/5 border border-success/20 p-4 rounded-lg animate-premium">
<div className="flex items-center gap-2 text-success">
<CheckCircle2 className="h-5 w-5" />
<span className="text-sm font-semibold">MSA Agreement Signed Successfully</span>
</div>
</div>
)}
</div>
{signingDoc === 'MSA' && (
<div className="mt-4 animate-premium">
<h3 className="text-base font-bold text-ink-800 mb-3 flex items-center gap-2">
<FileSignature className="h-5 w-5 text-primary-600" />
Sign Document: MSA
</h3>
<SignatureCapture
onSave={handleSaveSignature}
onCancel={() => setSigningDoc(null)}
/>
</div>
)}
</div>
)}
{step === 3 && user?.onboardingStatus === 'PENDING_APPROVAL' && (
<div className="rounded-xl border border-ink-100 bg-white p-8 shadow-premium text-center space-y-6 animate-premium">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-warning/10 border border-warning/20">
<Loader2 className="h-8 w-8 text-warning animate-spin" />
</div>
<div>
<h2 className="text-2xl font-bold text-ink-800 mb-2">Partnership Review Pending</h2>
<p className="mx-auto max-w-md text-sm text-ink-600">
Thank you! Your company details have been submitted. Your registration request is currently awaiting administrator review.
</p>
</div>
{/* Details inspection */}
<div className="mx-auto max-w-md border border-ink-100 rounded-xl bg-ink-50/50 p-4 text-left space-y-3">
<h3 className="text-xs font-bold uppercase tracking-wider text-ink-600 border-b border-ink-100 pb-1.5 flex items-center gap-1.5">
<Building2 className="w-3.5 h-3.5 text-ink-500" /> Submitted Profile Details
</h3>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<p className="font-semibold text-ink-500">Company Name</p>
<p className="font-bold text-ink-800 mt-0.5">{user.companyName}</p>
</div>
<div>
<p className="font-semibold text-ink-500">Corporate Website</p>
<a href={user.website} target="_blank" rel="noreferrer" className="font-bold text-primary-700 hover:underline flex items-center gap-0.5 mt-0.5">
<Globe className="w-3 h-3" /> Visit Link
</a>
</div>
<div>
<p className="font-semibold text-ink-500">Industry Sector</p>
<p className="font-bold text-ink-800 mt-0.5">{user.sector}</p>
</div>
<div>
<p className="font-semibold text-ink-500">Company Size</p>
<p className="font-bold text-ink-800 mt-0.5">{user.companySize} employees</p>
</div>
</div>
</div>
<div className="border-t border-ink-100 pt-6 flex flex-col items-center gap-3">
<p className="text-xs text-ink-500 max-w-lg">
Once an administrator reviews your application and signed agreements, you will be granted full access to the portal dashboard.
</p>
<button
onClick={logout}
className="flex items-center gap-1.5 text-sm font-semibold text-danger hover:underline px-4 py-2 border border-danger/10 bg-danger/5 rounded-lg transition-colors mt-2 cursor-pointer"
>
<LogOut className="h-4 w-4" />
Sign Out
</button>
</div>
</div>
)}
{step === 3 && user?.onboardingStatus === 'REJECTED' && (
<div className="rounded-xl border border-danger/20 bg-white p-8 shadow-premium text-center space-y-6 animate-premium">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-danger/10 border border-danger/20">
<ShieldX className="h-8 w-8 text-danger animate-pulse" />
</div>
<div>
<h2 className="text-2xl font-bold text-danger mb-2">Registration Declined</h2>
<p className="mx-auto max-w-md text-sm text-ink-600">
We regret to inform you that your request for partnership access to the Tech4Biz Developer Portal was not approved by our compliance team.
</p>
</div>
<div className="mx-auto max-w-md border border-danger/10 rounded-xl bg-danger/5 p-4 text-left">
<p className="text-xs text-danger font-semibold leading-relaxed">
Compliance Notice: Your business profile does not satisfy our current regional/regulatory developer guidelines. If you believe this is an error or wish to submit additional verification, please contact us.
</p>
</div>
<div className="border-t border-ink-100 pt-6 flex flex-col items-center gap-3">
<button
onClick={logout}
className="flex items-center gap-1.5 text-sm font-semibold text-danger hover:underline px-6 py-2.5 border border-danger/30 bg-danger/5 rounded-lg transition-colors cursor-pointer"
>
<LogOut className="h-4 w-4" />
Return to Login
</button>
</div>
</div>
)}
{step === 4 && (
<div className="rounded-xl border border-ink-100 bg-white p-8 shadow-premium text-center space-y-6 transition-premium">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-success/10 border border-success/20">
<Sparkles className="h-8 w-8 text-success animate-pulse" />
</div>
<div>
<h2 className="text-2xl font-bold text-ink-800 mb-2">Onboarding Completed!</h2>
<p className="mx-auto max-w-md text-sm text-ink-600">
Welcome to Tech4Biz! Your profile is approved and both agreements (NDA & MSA) are signed and filed.
</p>
</div>
<div className="flex flex-col items-center gap-3">
<span className="text-xs text-ink-500">Redirecting to your dashboard...</span>
<button
onClick={() => navigate('/client')}
className="flex items-center gap-2 rounded-lg bg-primary-400 px-6 py-2.5 text-sm font-semibold text-ink-800 hover:bg-primary-300 transition-premium shadow-premium"
>
Enter Portal <ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,28 @@
import { create } from 'zustand';
import type { User, AuthResponse } from '../types/auth';
interface AuthState {
user: User | null;
isAuthenticated: boolean;
accessToken: string | null;
setAuth: (data: AuthResponse) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isAuthenticated: false,
accessToken: null,
setAuth: (data) => set({
user: data.user,
accessToken: data.accessToken,
isAuthenticated: true
}),
logout: () => {
set({
user: null,
accessToken: null,
isAuthenticated: false
});
}
}));

View File

@ -0,0 +1,36 @@
import { create } from 'zustand';
type Theme = 'dark' | 'light';
interface ThemeState {
theme: Theme;
toggleTheme: () => void;
initTheme: () => void;
}
export const useThemeStore = create<ThemeState>((set) => ({
theme: 'dark',
toggleTheme: () => set((state) => {
const newTheme = state.theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme-preference', newTheme);
if (newTheme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
return { theme: newTheme };
}),
initTheme: () => set(() => {
const saved = localStorage.getItem('theme-preference');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialTheme = saved ? (saved as Theme) : (prefersDark ? 'dark' : 'light');
if (initialTheme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
return { theme: initialTheme };
})
}));

View File

@ -0,0 +1,555 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));/* ============================================================
TECH4BIZ DESIGN SYSTEM Tailwind v4
Brand Primary: #A2E771 (Lime Green)
Philosophy: Light theme, no pure black, 3D-first, animated
============================================================ */
@theme {
/* ── Brand Primary — Lime Green #A2E771 ── */
--color-primary-50: #F3FCE9;
--color-primary-100: #E2F7C9;
--color-primary-200: #C9EF9E;
--color-primary-300: #B2E97E;
--color-primary-400: #A2E771;
--color-primary-500: #8FD95C;
--color-primary-600: #75BF46;
--color-primary-700: #5C9C37;
--color-primary-800: #477A2B;
--color-primary-900: #365F21;
/* ── Ink Neutrals (No Pure Black) ── */
--color-ink-0: #FFFFFF;
--color-ink-50: #F7F9F6;
--color-ink-100: #ECF0EA;
--color-ink-200: #DFE5DC;
--color-ink-300: #C6CFC2;
--color-ink-400: #A8B5A3;
--color-ink-500: #8A9985;
--color-ink-600: #5B6B57;
--color-ink-700: #43503F;
--color-ink-800: #232B21;
--color-ink-900: #1D241B;
/* ── Semantic Colors ── */
--color-success: #3FAE5C;
--color-warning: #E8A93F;
--color-danger: #E5484D;
--color-info: #4C8DF0;
/* ── Typography ── */
--font-sans: 'Outfit', 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
/* ── Spacing Scale ── */
--spacing-18: 4.5rem;
--spacing-22: 5.5rem;
/* ── Shadows ── */
--shadow-sm: 0 1px 3px 0 rgba(35, 43, 33, 0.06);
--shadow-md: 0 4px 12px -2px rgba(35, 43, 33, 0.08);
--shadow-lg: 0 8px 30px -4px rgba(35, 43, 33, 0.10);
--shadow-premium: 0 4px 24px -4px rgba(162, 231, 113, 0.25), 0 2px 8px -1px rgba(35, 43, 33, 0.06);
--shadow-glow: 0 0 30px rgba(162, 231, 113, 0.45), 0 0 60px rgba(162, 231, 113, 0.15);
--shadow-glow-sm: 0 0 15px rgba(162, 231, 113, 0.35);
--shadow-inner-glow: inset 0 1px 0 rgba(162, 231, 113, 0.15);
--shadow-3d: 0 20px 60px -10px rgba(35, 43, 33, 0.18), 0 8px 25px -5px rgba(162, 231, 113, 0.12);
/* ── Border Radius ── */
--radius-2xl: 1rem;
--radius-3xl: 1.5rem;
--radius-4xl: 2rem;
/* ── Animations ── */
--animate-float: float 6s ease-in-out infinite;
--animate-float-slow: float 9s ease-in-out infinite;
--animate-float-fast: float 4s ease-in-out infinite;
--animate-pulse-glow: pulse-glow 2.5s ease-in-out infinite;
--animate-shimmer: shimmer 2s linear infinite;
--animate-fade-in: fade-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
--animate-scale-up: scale-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
--animate-slide-up: slide-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
--animate-slide-right: slide-right 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
--animate-spin-slow: spin 8s linear infinite;
--animate-orbit: orbit 12s linear infinite;
--animate-morph: morph 8s ease-in-out infinite;
/* ── Keyframes ── */
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); }
33% { transform: translateY(-12px) rotate(1deg); }
66% { transform: translateY(-6px) rotate(-1deg); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 15px rgba(162, 231, 113, 0.3); }
50% { box-shadow: 0 0 40px rgba(162, 231, 113, 0.6), 0 0 80px rgba(162, 231, 113, 0.2); }
}
@keyframes shimmer {
from { background-position: -200% center; }
to { background-position: 200% center; }
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scale-up {
from { opacity: 0; transform: scale(0.92) translateY(8px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slide-right {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes orbit {
from { transform: rotate(0deg) translateX(120px) rotate(0deg); }
to { transform: rotate(360deg) translateX(120px) rotate(-360deg); }
}
@keyframes morph {
0%, 100% { border-radius: 40% 60% 70% 30% / 40% 50% 60% 50%; }
34% { border-radius: 70% 30% 50% 50% / 30% 30% 70% 70%; }
67% { border-radius: 100% 60% 60% 100% / 100% 100% 60% 60%; }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
}
/* ============================================================
BASE STYLES
============================================================ */
*, *::before, *::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
-webkit-text-size-adjust: 100%;
}
body {
background-color: var(--color-ink-50);
color: var(--color-ink-800);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.6;
}
/* Premium Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--color-ink-50); }
::-webkit-scrollbar-thumb { background: var(--color-ink-300); border-radius: 9999px; }
::-webkit-scrollbar-thumb:hover { background: var(--color-ink-600); }
/* Focus ring */
:focus-visible {
outline: 2px solid var(--color-primary-400);
outline-offset: 2px;
border-radius: 6px;
}
/* ============================================================
3D & PERSPECTIVE UTILITIES
============================================================ */
.perspective-1000 { perspective: 1000px; }
.perspective-1500 { perspective: 1500px; }
.preserve-3d { transform-style: preserve-3d; }
.backface-hidden { backface-visibility: hidden; }
.translate-z-0 { transform: translateZ(0); }
.card-3d {
transform-style: preserve-3d;
transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.card-3d:hover {
transform: rotateY(-4deg) rotateX(2deg) translateZ(8px);
}
/* ============================================================
GLASS MORPHISM
============================================================ */
.glass {
background: rgba(247, 249, 246, 0.72);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(236, 240, 234, 0.8);
}
.glass-strong {
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(32px) saturate(200%);
-webkit-backdrop-filter: blur(32px) saturate(200%);
border: 1px solid rgba(162, 231, 113, 0.2);
}
.glass-dark {
background: rgba(35, 43, 33, 0.75);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(162, 231, 113, 0.15);
}
/* ============================================================
GRADIENT UTILITIES
============================================================ */
.gradient-brand {
background: linear-gradient(135deg, var(--color-primary-300) 0%, var(--color-primary-500) 100%);
}
.gradient-brand-soft {
background: linear-gradient(135deg, var(--color-primary-50) 0%, var(--color-primary-100) 100%);
}
.gradient-mesh {
background:
radial-gradient(at 40% 20%, rgba(162, 231, 113, 0.18) 0px, transparent 50%),
radial-gradient(at 80% 0%, rgba(117, 191, 70, 0.12) 0px, transparent 50%),
radial-gradient(at 0% 50%, rgba(162, 231, 113, 0.10) 0px, transparent 50%),
radial-gradient(at 80% 50%, rgba(79, 122, 43, 0.08) 0px, transparent 50%),
radial-gradient(at 0% 100%, rgba(162, 231, 113, 0.12) 0px, transparent 50%),
var(--color-ink-50);
}
.gradient-text {
background: linear-gradient(135deg, var(--color-primary-600), var(--color-primary-400), var(--color-primary-700));
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.gradient-shimmer {
background: linear-gradient(
90deg,
var(--color-primary-300) 0%,
var(--color-primary-500) 25%,
var(--color-primary-300) 50%,
var(--color-primary-500) 75%,
var(--color-primary-300) 100%
);
background-size: 200% auto;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 3s linear infinite;
}
/* ============================================================
PREMIUM COMPONENT UTILITIES
============================================================ */
.btn-primary {
background: linear-gradient(135deg, var(--color-primary-400), var(--color-primary-500));
color: var(--color-ink-800);
font-weight: 600;
padding: 0.625rem 1.25rem;
border-radius: 0.625rem;
border: none;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 2px 8px rgba(162, 231, 113, 0.3), 0 1px 2px rgba(0,0,0,0.05);
position: relative;
overflow: hidden;
}
.btn-primary::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255,255,255,0.25), transparent);
opacity: 0;
transition: opacity 0.2s;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(162, 231, 113, 0.45), 0 4px 10px rgba(0,0,0,0.08);
}
.btn-primary:hover::before { opacity: 1; }
.btn-primary:active { transform: translateY(0); }
.btn-ghost {
background: transparent;
border: 1.5px solid var(--color-ink-200);
color: var(--color-ink-700);
font-weight: 600;
padding: 0.625rem 1.25rem;
border-radius: 0.625rem;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.btn-ghost:hover {
background: var(--color-ink-100);
border-color: var(--color-ink-300);
transform: translateY(-1px);
}
.input-field {
width: 100%;
background: var(--color-ink-50);
border: 1.5px solid var(--color-ink-200);
border-radius: 0.625rem;
padding: 0.625rem 1rem;
font-size: 0.875rem;
color: var(--color-ink-800);
font-family: var(--font-sans);
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
outline: none;
}
.input-field.has-left-icon {
padding-left: 2.75rem;
}
.input-field.has-right-icon {
padding-right: 2.75rem;
}
.input-field:focus {
background: white;
border-color: var(--color-primary-400);
box-shadow: 0 0 0 3px rgba(162, 231, 113, 0.2), 0 1px 3px rgba(0,0,0,0.05);
}
.input-field::placeholder { color: var(--color-ink-400); }
.card {
background: white;
border: 1px solid var(--color-ink-100);
border-radius: 1rem;
box-shadow: var(--shadow-sm);
transition: box-shadow 0.3s, transform 0.3s;
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.card-glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.8);
border-radius: 1rem;
box-shadow: var(--shadow-3d);
}
/* ============================================================
FLOATING ORBS (Background Decoration)
============================================================ */
.orb {
position: absolute;
border-radius: 50%;
filter: blur(60px);
pointer-events: none;
animation: var(--animate-float);
}
.orb-primary {
background: radial-gradient(circle, rgba(162, 231, 113, 0.35) 0%, rgba(162, 231, 113, 0.05) 70%);
}
.orb-secondary {
background: radial-gradient(circle, rgba(117, 191, 70, 0.25) 0%, rgba(117, 191, 70, 0.03) 70%);
animation-delay: -3s;
animation-duration: 8s;
}
.orb-accent {
background: radial-gradient(circle, rgba(76, 141, 240, 0.15) 0%, rgba(76, 141, 240, 0.02) 70%);
animation-delay: -6s;
animation-duration: 11s;
}
/* ============================================================
STATUS BADGES
============================================================ */
.badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.625rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.badge-success { background: rgba(63, 174, 92, 0.12); color: #3FAE5C; border: 1px solid rgba(63, 174, 92, 0.25); }
.badge-warning { background: rgba(232, 169, 63, 0.12); color: #E8A93F; border: 1px solid rgba(232, 169, 63, 0.25); }
.badge-danger { background: rgba(229, 72, 77, 0.12); color: #E5484D; border: 1px solid rgba(229, 72, 77, 0.25); }
.badge-neutral { background: rgba(35, 43, 33, 0.06); color: #5B6B57; border: 1px solid rgba(35, 43, 33, 0.12); }
.badge-primary { background: rgba(162, 231, 113, 0.15); color: #477A2B; border: 1px solid rgba(162, 231, 113, 0.3); }
/* ============================================================
ANIMATION UTILITY CLASSES
============================================================ */
.animate-float { animation: float 6s ease-in-out infinite; }
.animate-float-slow { animation: float 9s ease-in-out infinite; }
.animate-float-fast { animation: float 4s ease-in-out infinite; }
.animate-pulse-glow { animation: pulse-glow 2.5s ease-in-out infinite; }
.animate-shimmer { animation: shimmer 2s linear infinite; }
.animate-fade-in { animation: fade-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
.animate-scale-up { animation: scale-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
.animate-slide-up { animation: slide-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
.animate-slide-right { animation: slide-right 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
.animate-spin-slow { animation: spin 8s linear infinite; }
.animate-orbit { animation: orbit 12s linear infinite; }
.animate-morph { animation: morph 8s ease-in-out infinite; }
.delay-100 { animation-delay: 0.1s; }
.delay-200 { animation-delay: 0.2s; }
.delay-300 { animation-delay: 0.3s; }
.delay-500 { animation-delay: 0.5s; }
.delay-700 { animation-delay: 0.7s; }
/* ============================================================
TRANSITION UTILITIES
============================================================ */
.transition-premium { transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); }
.transition-slow { transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1); }
/* ============================================================
INTERACTIVE HOVER EFFECTS
============================================================ */
.hover-lift { transition: transform 0.3s, box-shadow 0.3s; }
.hover-lift:hover { transform: translateY(-4px); box-shadow: var(--shadow-3d); }
.hover-glow:hover { box-shadow: var(--shadow-glow); }
/* Magnetic button ripple effect */
.ripple {
position: relative;
overflow: hidden;
}
.ripple::after {
content: '';
position: absolute;
inset: -50%;
background: radial-gradient(circle, rgba(162, 231, 113, 0.3) 0%, transparent 70%);
opacity: 0;
transition: opacity 0.4s;
}
.ripple:hover::after { opacity: 1; }
/* ============================================================
TABLE UTILITIES
============================================================ */
.table-premium {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
.table-premium th {
background: var(--color-ink-50);
color: var(--color-ink-600);
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 0.875rem 1.25rem;
border-bottom: 1px solid var(--color-ink-100);
}
.table-premium td {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-ink-50);
font-size: 0.875rem;
color: var(--color-ink-700);
vertical-align: middle;
transition: background 0.15s;
}
.table-premium tr:hover td { background: rgba(162, 231, 113, 0.03); }
.table-premium tr:last-child td { border-bottom: none; }
/* ============================================================
NAVIGATION
============================================================ */
.nav-link {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border-radius: 0.625rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-ink-600);
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
position: relative;
}
.nav-link::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--color-ink-100);
opacity: 0;
transition: opacity 0.2s;
}
.nav-link:hover { color: var(--color-ink-800); }
.nav-link:hover::before { opacity: 1; }
.nav-link.active {
background: linear-gradient(135deg, var(--color-primary-400), var(--color-primary-500));
color: var(--color-ink-800);
box-shadow: 0 2px 8px rgba(162, 231, 113, 0.35);
}
.nav-link.active::before { display: none; }
/* ============================================================
TYPOGRAPHY
============================================================ */
.heading-display {
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.025em;
}
.heading-section {
font-size: clamp(1.25rem, 3vw, 1.75rem);
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.015em;
}
.text-label {
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-ink-600);
}
/* ============================================================
LOADING STATES
============================================================ */
@keyframes skeleton-wave {
from { background-position: -200px 0; }
to { background-position: calc(200px + 100%) 0; }
}
.skeleton {
background: linear-gradient(
90deg,
var(--color-ink-100) 25%,
var(--color-ink-50) 50%,
var(--color-ink-100) 75%
);
background-size: 400px 100%;
animation: skeleton-wave 1.4s ease-in-out infinite;
border-radius: 0.5rem;
}
/* ============================================================
PROGRESS BAR
============================================================ */
.progress-bar {
height: 0.375rem;
border-radius: 9999px;
background: var(--color-ink-100);
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
border-radius: 9999px;
background: linear-gradient(90deg, var(--color-primary-400), var(--color-primary-600));
transition: width 0.6s cubic-bezier(0.16, 1, 0.3, 1);
position: relative;
}
.progress-bar-fill::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
animation: shimmer 1.5s linear infinite;
}

View File

@ -0,0 +1,344 @@
import type { User, Asset, Category, BlogPost, Announcement } from '../types';
import { initializeStorage } from './mock-data';
// Ensure data is seeded in localStorage
initializeStorage();
// Helper delay function to simulate network response
const delay = (ms: number = 300) => new Promise(resolve => setTimeout(resolve, ms));
const getStored = <T>(key: string): T => {
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : [] as unknown as T;
};
const setStored = <T>(key: string, data: T): void => {
localStorage.setItem(key, JSON.stringify(data));
};
export interface ApiResponse<T> {
data: T;
status: number;
}
// Custom mock API client resembling Axios
export const apiClient = {
get: async <T>(url: string, config?: { params?: any }): Promise<ApiResponse<T>> => {
await delay();
// Clean up url by removing query string if any
const cleanUrl = url.split('?')[0];
// Auth endpoints
if (cleanUrl === '/auth/session') {
const activeUserEmail = sessionStorage.getItem('t4b_session_email');
if (!activeUserEmail) {
throw { status: 401, message: 'Unauthorized' };
}
const users = getStored<User[]>('t4b_users');
const user = users.find(u => u.email === activeUserEmail);
if (!user) {
throw { status: 401, message: 'User not found' };
}
return { data: user as unknown as T, status: 200 };
}
// Asset endpoints
if (cleanUrl === '/assets') {
const assets = getStored<Asset[]>('t4b_assets');
const params = config?.params || {};
let filtered = [...assets];
if (params.categoryId && params.categoryId !== 'all') {
filtered = filtered.filter(a => a.categoryId === params.categoryId);
}
if (params.subcategory && params.subcategory !== 'all') {
filtered = filtered.filter(a => a.subcategory === params.subcategory);
}
if (params.search) {
const q = params.search.toLowerCase();
filtered = filtered.filter(a =>
a.title.toLowerCase().includes(q) ||
a.description.toLowerCase().includes(q) ||
a.tags.some(t => t.toLowerCase().includes(q))
);
}
// If client is logged in and not admin, only show published assets
const activeUserEmail = sessionStorage.getItem('t4b_session_email');
const users = getStored<User[]>('t4b_users');
const activeUser = users.find(u => u.email === activeUserEmail);
if (!activeUser || activeUser.role !== 'ADMIN') {
filtered = filtered.filter(a => a.status === 'published');
}
return { data: filtered as unknown as T, status: 200 };
}
if (cleanUrl.startsWith('/assets/')) {
const id = cleanUrl.split('/assets/')[1];
const assets = getStored<Asset[]>('t4b_assets');
const asset = assets.find(a => a.id === id);
if (!asset) {
throw { status: 404, message: 'Asset not found' };
}
return { data: asset as unknown as T, status: 200 };
}
// Categories
if (cleanUrl === '/categories') {
const categories = getStored<Category[]>('t4b_categories');
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
if (cleanUrl === '/announcements') {
const announcements = getStored<Announcement[]>('t4b_announcements');
return { data: announcements as unknown as T, status: 200 };
}
// Admin Client Queue
if (cleanUrl === '/admin/clients') {
const users = getStored<User[]>('t4b_users');
// Filter out admins, return only clients
const clients = users.filter(u => u.role === 'CLIENT');
return { data: clients as unknown as T, status: 200 };
}
throw { status: 404, message: 'Route not found' };
},
post: async <T>(url: string, payload: any): Promise<ApiResponse<T>> => {
await delay();
if (url === '/auth/login') {
const { email, password } = payload;
const users = getStored<User[]>('t4b_users');
const user = users.find(u => u.email === email);
if (!user || password !== 'password') {
throw { status: 400, message: 'Invalid email or password' };
}
// Login success, return user (MFA verification will occur if required)
return { data: user as unknown as T, status: 200 };
}
if (url === '/auth/register') {
const { email, companyName, website, sector, companySize } = payload;
const users = getStored<User[]>('t4b_users');
if (users.some(u => u.email === email)) {
throw { status: 400, message: 'Email already registered' };
}
const newUser: User = {
id: `user-${Date.now()}`,
email,
role: 'CLIENT',
companyName: companyName || '',
website: website || '',
sector: sector || 'Technology',
companySize: companySize || '1-10',
onboardingStatus: 'NOT_STARTED',
mfaVerified: false,
createdAt: new Date().toISOString()
};
users.push(newUser);
setStored('t4b_users', users);
return { data: newUser as unknown as T, status: 201 };
}
if (url === '/auth/mfa-verify') {
const { email, code } = payload;
const users = getStored<User[]>('t4b_users');
const index = users.findIndex(u => u.email === email);
if (index === -1) {
throw { status: 404, message: 'User not found' };
}
if (code !== '123456') {
throw { status: 400, message: 'Invalid OTP code. Try "123456"' };
}
users[index].mfaVerified = true;
setStored('t4b_users', users);
sessionStorage.setItem('t4b_session_email', email);
return { data: users[index] as unknown as T, status: 200 };
}
if (url === '/client/onboarding') {
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' };
}
users[index] = {
...users[index],
...payload,
onboardingStatus: 'FORM_COMPLETED'
};
setStored('t4b_users', users);
return { data: users[index] as unknown as T, status: 200 };
}
if (url === '/client/nda') {
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' };
}
users[index].ndaSignature = {
type: payload.type,
dataUrl: payload.dataUrl,
date: new Date().toISOString()
};
users[index].onboardingStatus = 'NDA_SIGNED';
setStored('t4b_users', users);
return { data: users[index] as unknown as T, status: 200 };
}
if (url === '/client/msa') {
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' };
}
users[index].msaSignature = {
type: payload.type,
dataUrl: payload.dataUrl,
date: new Date().toISOString()
};
users[index].onboardingStatus = 'PENDING_APPROVAL';
setStored('t4b_users', users);
return { data: users[index] as unknown as T, status: 200 };
}
if (url === '/assets/new') {
const assets = getStored<Asset[]>('t4b_assets');
const newAsset: Asset = {
id: `asset-${Date.now()}`,
title: payload.title,
description: payload.description,
categoryId: payload.categoryId,
subcategory: payload.subcategory,
tags: payload.tags || [],
author: payload.author || 'Admin Staff',
publishDate: new Date().toISOString().split('T')[0],
thumbnailUrl: payload.thumbnailUrl || 'https://images.unsplash.com/photo-1488590528505-98d2b5aba04b?w=500&auto=format&fit=crop&q=60',
downloadUrl: payload.downloadUrl || '#',
githubUrl: payload.githubUrl,
status: payload.status || 'draft',
downloadsCount: 0
};
assets.push(newAsset);
setStored('t4b_assets', assets);
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' };
},
put: async <T>(url: string, payload: any): Promise<ApiResponse<T>> => {
await delay();
if (url.startsWith('/admin/clients/')) {
const clientId = url.split('/admin/clients/')[1];
const users = getStored<User[]>('t4b_users');
const index = users.findIndex(u => u.id === clientId);
if (index === -1) {
throw { status: 404, message: 'Client not found' };
}
users[index].onboardingStatus = payload.status; // 'APPROVED' or 'REJECTED'
setStored('t4b_users', users);
return { data: users[index] as unknown as T, status: 200 };
}
if (url.startsWith('/assets/')) {
const id = url.split('/assets/')[1];
const assets = getStored<Asset[]>('t4b_assets');
const index = assets.findIndex(a => a.id === id);
if (index === -1) {
throw { status: 404, message: 'Asset not found' };
}
assets[index] = {
...assets[index],
...payload
};
setStored('t4b_assets', assets);
return { data: assets[index] as unknown as T, status: 200 };
}
throw { status: 404, message: 'Route not found' };
},
delete: async <T>(url: string): Promise<ApiResponse<T>> => {
await delay();
if (url.startsWith('/assets/')) {
const id = url.split('/assets/')[1];
let assets = getStored<Asset[]>('t4b_assets');
const exists = assets.some(a => a.id === id);
if (!exists) {
throw { status: 404, message: 'Asset not found' };
}
assets = assets.filter(a => a.id !== id);
setStored('t4b_assets', assets);
return { data: { success: true } as unknown as T, status: 200 };
}
throw { status: 404, message: 'Route not found' };
}
};

View File

@ -0,0 +1,296 @@
import type { User, Asset, Category, BlogPost, Announcement } from "../types";
// Pre-defined categories
export const SEED_CATEGORIES: Category[] = [
{
id: "silicon",
name: "Silicon",
subcategories: [
"FPGA Modules",
"RISC-V Cores",
"ASIC Accelerators",
"Memory Controllers",
],
},
{
id: "software",
name: "Software",
subcategories: [
"CodeNuk Framework",
"Core Libraries",
"System Drivers",
"SDKs & Tools",
],
},
{
id: "ai",
name: "AI",
subcategories: [
"Large Language Models",
"Speech Recognition",
"Computer Vision",
"Agentic Workflows",
],
},
{
id: "cloud",
name: "Cloud",
subcategories: [
"Kubernetes Operators",
"Terraform Modules",
"Serverless Functions",
"DB Connectors",
],
},
];
// Pre-defined seed assets
export const SEED_ASSETS: Asset[] = [
{
id: "asset-1",
title: "RISC-V High-Performance Quad-Core IP Block",
description:
"A synthesizable quad-core RISC-V processor IP core designed for edge computing applications. Supports RV64GC instruction set, includes a 32KB L1 cache, and has been optimized for low power consumption.",
categoryId: "silicon",
subcategory: "RISC-V Cores",
tags: ["RISC-V", "IP-Core", "Hardware", "Edge-Computing"],
author: "Silicon Engineering Team",
publishDate: "2026-06-15",
thumbnailUrl:
"https://images.unsplash.com/photo-1518770660439-4636190af475?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3",
downloadUrl: "/downloads/riscv-quad-core-spec.pdf",
githubUrl: "https://github.com/tech4biz/riscv-quad-core",
status: "published",
downloadsCount: 142,
},
{
id: "asset-2",
title: "FPGA Cryptographic Accelerator IP",
description:
"Hardware accelerator IP for Xilinx UltraScale+ FPGAs providing high-throughput AES-GCM and SHA-3 hashing. Optimized for network security processors and TLS termination offloading.",
categoryId: "silicon",
subcategory: "FPGA Modules",
tags: ["FPGA", "Crypto", "Security", "AES-GCM"],
author: "Security Hardware Group",
publishDate: "2026-06-20",
thumbnailUrl:
"https://images.unsplash.com/photo-1601524909162-be87252be298?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3",
downloadUrl: "/downloads/fpga-crypto-accel.zip",
githubUrl: "https://github.com/tech4biz/fpga-crypto-accel",
status: "published",
downloadsCount: 89,
},
{
id: "asset-3",
title: "CodeNuk Core Framework v3.2.0",
description:
"The definitive micro-service framework for rapid enterprise deployments. Includes built-in service discovery, dynamic routing, and automated telemetry dashboard injection.",
categoryId: "software",
subcategory: "CodeNuk Framework",
tags: ["Framework", "Microservices", "NodeJS", "TypeScript"],
author: "CodeNuk Team",
publishDate: "2026-06-28",
thumbnailUrl:
"https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3",
downloadUrl: "/downloads/codenuk-core-3.2.0.tgz",
githubUrl: "https://github.com/tech4biz/codenuk-core",
status: "published",
downloadsCount: 312,
},
{
id: "asset-4",
title: "Automated Agentic Orchestrator (AAO)",
description:
"An advanced multi-agent coordination library that schedules, monitors, and optimizes LLM-based autonomous workflows. Features memory persistence and automatic fallback paths.",
categoryId: "ai",
subcategory: "Agentic Workflows",
tags: ["AI-Agents", "Orchestrator", "LLM", "Python"],
author: "AI Research Lab",
publishDate: "2026-06-29",
thumbnailUrl:
"https://images.unsplash.com/photo-1677442136019-21780efad99a?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3",
downloadUrl: "/downloads/agentic-orchestrator.tar.gz",
githubUrl: "https://github.com/tech4biz/agentic-orchestrator",
status: "published",
downloadsCount: 220,
},
{
id: "asset-5",
title: "Multi-Cloud Kubernetes Operator for DBs",
description:
"A cloud-agnostic Kubernetes operator that manages replication, backups, and failover for PostgreSQL databases across AWS, GCP, and Azure environments.",
categoryId: "cloud",
subcategory: "Kubernetes Operators",
tags: ["Kubernetes", "Operator", "Database", "Multi-Cloud"],
author: "Cloud Infra Team",
publishDate: "2026-05-12",
thumbnailUrl:
"https://images.unsplash.com/photo-1667372393119-3d4c48d07fc9?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3",
downloadUrl: "/downloads/k8s-db-operator.yaml",
status: "published",
downloadsCount: 154,
},
{
id: "asset-6",
title: "Secure Enclave Storage Driver",
description:
"Low-level device driver providing unified APIs for Intel SGX and AMD SEV secure enclave storage. Useful for storing cryptographic keys and private data locally.",
categoryId: "software",
subcategory: "System Drivers",
tags: ["System-Programming", "Enclave", "Intel-SGX", "Security"],
author: "Systems Lab",
publishDate: "2026-07-01",
thumbnailUrl:
"https://images.unsplash.com/photo-1563986768609-322da13575f3?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3",
downloadUrl: "/downloads/secure-enclave-driver.tar.gz",
githubUrl: "https://github.com/tech4biz/enclave-driver",
status: "draft",
downloadsCount: 0,
},
];
// 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
export const SEED_ANNOUNCEMENTS: Announcement[] = [
{
id: "ann-1",
title: "Scheduled Maintenance: Client Portal API Gateway Upgrade",
content:
"We will be upgrading our client portal API Gateway on Sunday, July 5th, from 02:00 to 04:00 UTC. During this window, you may experience brief authentication interruptions.",
date: "2026-07-01",
severity: "info",
},
{
id: "ann-2",
title: "New Asset Category: Agentic Workflows Now Live!",
content:
"We have added a brand new subcategory under AI for Agentic Workflows. Check out our first release: the Automated Agentic Orchestrator (AAO) for multi-agent coordination.",
date: "2026-06-30",
severity: "success",
},
];
// Function to initialize storage
export const initializeStorage = () => {
if (!localStorage.getItem("t4b_users")) {
const seedUsers: User[] = [
{
id: "user-admin",
email: "admin@tech4biz.com",
role: "ADMIN",
companyName: "Tech4Biz Corp",
website: "https://tech4biz.io",
sector: "Technology",
companySize: "100-500",
onboardingStatus: "APPROVED",
mfaVerified: true,
createdAt: "2026-01-01T00:00:00.000Z",
},
{
id: "user-client-approved",
email: "client@tech4biz.com",
role: "CLIENT",
companyName: "Acme Innovation",
website: "https://acme-inn.com",
sector: "Automotive",
companySize: "50-100",
onboardingStatus: "APPROVED",
mfaVerified: true,
ndaSignature: {
type: "draw",
dataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
date: "2026-06-10T10:00:00.000Z",
},
msaSignature: {
type: "draw",
dataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
date: "2026-06-10T10:15:00.000Z",
},
createdAt: "2026-06-09T08:00:00.000Z",
},
{
id: "user-client-pending",
email: "pendingclient@tech4biz.com",
role: "CLIENT",
companyName: "Genesis Biotech",
website: "https://genesisbio.com",
sector: "Semiconductors",
companySize: "10-50",
onboardingStatus: "PENDING_APPROVAL",
mfaVerified: true,
ndaSignature: {
type: "draw",
dataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
date: "2026-07-02T02:05:00.000Z",
},
msaSignature: {
type: "draw",
dataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
date: "2026-07-02T02:10:00.000Z",
},
createdAt: "2026-07-02T02:00:00.000Z",
},
{
id: "user-client-new",
email: "newclient@tech4biz.com",
role: "CLIENT",
onboardingStatus: "NOT_STARTED",
mfaVerified: false,
createdAt: "2026-07-01T12:00:00.000Z",
},
];
localStorage.setItem("t4b_users", JSON.stringify(seedUsers));
}
if (!localStorage.getItem("t4b_assets")) {
localStorage.setItem("t4b_assets", JSON.stringify(SEED_ASSETS));
}
if (!localStorage.getItem("t4b_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")) {
localStorage.setItem(
"t4b_announcements",
JSON.stringify(SEED_ANNOUNCEMENTS),
);
}
};

View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import { App } from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,118 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import type { Variants } from 'framer-motion';
import { UploadCloud, Search, Filter, FileText, Image as ImageIcon, File, MoreVertical, Download, ChevronDown, CheckCircle } from 'lucide-react';
import { useAuthStore } from '../hooks/use-auth';
const containerVariants: Variants = {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.05 } }
};
const itemVariants: Variants = {
hidden: { opacity: 0, y: 15, scale: 0.98 },
show: { opacity: 1, y: 0, scale: 1, transition: { type: 'spring', stiffness: 350, damping: 25 } }
};
const MOCK_ASSETS = [
{ id: 1, name: 'Q3_Enterprise_Pitch_Deck.pdf', type: 'PDF', size: '4.2 MB', date: 'Oct 12, 2026', author: 'Marketing Team', category: 'Presentations', icon: FileText, color: 'text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-500/10' },
{ id: 2, name: 'Brand_Guidelines_V4.png', type: 'IMAGE', size: '1.8 MB', date: 'Oct 10, 2026', author: 'Design Ops', category: 'Branding', icon: ImageIcon, color: 'text-blue-600 dark:text-blue-400 bg-blue-100 dark:bg-blue-500/10' },
{ id: 3, name: 'Partner_Onboarding_Kit.zip', type: 'ARCHIVE', size: '12.5 MB', date: 'Oct 05, 2026', author: 'Partner Success', category: 'Resources', icon: File, color: 'text-amber-600 dark:text-amber-400 bg-amber-100 dark:bg-amber-500/10' },
{ id: 4, name: 'Technical_Architecture_Diagram.pdf', type: 'PDF', size: '2.1 MB', date: 'Sep 28, 2026', author: 'Engineering', category: 'Technical', icon: FileText, color: 'text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-500/10' },
];
export const AssetsPage = () => {
const user = useAuthStore((state) => state.user);
const [searchQuery, setSearchQuery] = useState('');
return (
<motion.div variants={containerVariants} initial="hidden" animate="show" className="w-full space-y-8">
{/* Header Section */}
<motion.div variants={itemVariants} className="flex flex-col lg:flex-row lg:items-end justify-between gap-6">
<div className="flex flex-col gap-3">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 w-fit">
<CheckCircle className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
<span className="text-[10px] font-bold text-blue-700 dark:text-blue-400 tracking-widest uppercase">Global CDN Active</span>
</div>
<h1 className="text-4xl md:text-5xl font-extrabold text-slate-900 dark:text-white tracking-tight mt-2">
Asset Library
</h1>
<p className="text-base text-slate-500 dark:text-white/50 max-w-2xl font-medium mt-1">
Securely manage, distribute, and track marketing collateral and partner resources.
</p>
</div>
{user?.role === 'ADMIN' && (
<button className="group relative bg-slate-900 dark:bg-white text-white dark:text-slate-900 font-bold tracking-wide py-3.5 px-6 rounded-xl hover:bg-slate-800 dark:hover:bg-slate-100 transition-all duration-300 overflow-hidden flex items-center justify-center gap-2 shadow-[0_8px_20px_rgba(0,0,0,0.15)] dark:shadow-[0_8px_20px_rgba(255,255,255,0.15)] hover:shadow-[0_12px_25px_rgba(0,0,0,0.25)] dark:hover:shadow-[0_12px_25px_rgba(255,255,255,0.25)] hover:-translate-y-0.5">
<UploadCloud className="w-4 h-4 group-hover:-translate-y-0.5 transition-transform" />
<span>Upload Secure Asset</span>
</button>
)}
</motion.div>
{/* Action Bar */}
<motion.div variants={itemVariants} className="flex flex-col md:flex-row gap-4 items-center pt-4">
<div className="relative flex-1 w-full group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Search className="w-5 h-5 text-slate-400 dark:text-white/30 group-focus-within:text-blue-500 transition-colors" />
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-white dark:bg-[#111] border border-slate-200 dark:border-white/10 rounded-xl py-3.5 pl-11 pr-4 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-white/30 outline-none transition-all focus:border-blue-500 focus:ring-4 ring-blue-500/10 font-medium shadow-sm hover:border-slate-300 dark:hover:border-white/20"
placeholder="Search assets by name, category, or metadata..."
/>
</div>
<button className="flex items-center justify-center gap-2 px-5 py-3.5 rounded-xl bg-white dark:bg-[#111] border border-slate-200 dark:border-white/10 text-slate-700 dark:text-white/70 font-semibold text-sm tracking-wide hover:bg-slate-50 dark:hover:bg-white/5 transition-all shadow-sm hover:border-slate-300 dark:hover:border-white/20">
<Filter className="w-4 h-4" />
Filter Options
<ChevronDown className="w-4 h-4 ml-1 opacity-50" />
</button>
</motion.div>
{/* Asset Grid */}
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 pt-2">
{MOCK_ASSETS.map((asset) => (
<div key={asset.id} className="group relative bg-white dark:bg-[#111] border border-slate-200 dark:border-white/10 rounded-2xl p-5 hover:border-slate-300 dark:hover:border-white/20 transition-all duration-300 shadow-sm hover:shadow-[0_8px_30px_rgba(0,0,0,0.06)] dark:hover:shadow-[0_8px_30px_rgba(255,255,255,0.04)] hover:-translate-y-1 flex flex-col">
<div className="flex justify-between items-start mb-5">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${asset.color}`}>
<asset.icon className="w-6 h-6" />
</div>
<button className="p-1.5 rounded-lg text-slate-400 dark:text-white/30 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/10 transition-colors opacity-0 group-hover:opacity-100">
<MoreVertical className="w-4 h-4" />
</button>
</div>
<div className="space-y-1 mb-6 flex-1">
<div className="inline-block px-2 py-0.5 rounded text-[9px] font-bold text-slate-500 dark:text-white/40 uppercase tracking-widest bg-slate-100 dark:bg-white/5 border border-slate-200 dark:border-white/10 mb-2">
{asset.category}
</div>
<h3 className="font-bold text-slate-900 dark:text-white text-base leading-snug line-clamp-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors" title={asset.name}>
{asset.name}
</h3>
<p className="text-xs font-medium text-slate-500 dark:text-white/40 mt-1">
{asset.size} {asset.date}
</p>
</div>
<div className="pt-4 border-t border-slate-100 dark:border-white/10 flex items-center justify-between mt-auto">
<div className="flex items-center gap-2.5">
<div className="w-6 h-6 rounded-full bg-slate-900 dark:bg-white flex items-center justify-center text-[9px] font-bold text-white dark:text-slate-900">
{asset.author.charAt(0)}
</div>
<span className="text-xs font-semibold text-slate-700 dark:text-white/60">{asset.author}</span>
</div>
<button className="w-8 h-8 rounded-lg bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 flex items-center justify-center text-slate-600 dark:text-white/60 hover:bg-blue-50 dark:hover:bg-blue-500/10 hover:border-blue-200 dark:hover:border-blue-500/20 hover:text-blue-600 dark:hover:text-blue-400 transition-all">
<Download className="w-4 h-4" />
</button>
</div>
</div>
))}
</motion.div>
</motion.div>
);
};

View File

@ -0,0 +1,118 @@
import { useAuthStore } from '../hooks/use-auth';
import { motion } from 'framer-motion';
import type { Variants } from 'framer-motion';
import { FolderKanban, FileSignature, Users, ArrowUpRight, Activity, Zap, ShieldCheck } from 'lucide-react';
import { Link } from 'react-router-dom';
const containerVariants: Variants = {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.1 } }
};
const itemVariants: Variants = {
hidden: { opacity: 0, y: 30 },
show: { opacity: 1, y: 0, transition: { type: 'spring', stiffness: 300, damping: 24 } }
};
export const DashboardPage = () => {
const user = useAuthStore((state) => state.user);
const CARDS = [
{
title: 'Asset Library',
description: 'Manage and assign premium marketing collateral, brand guidelines, and shared resources.',
icon: FolderKanban,
color: 'from-blue-500 to-cyan-400',
path: '/assets',
metrics: '24 New Assets'
},
{
title: 'Legal Engine',
description: 'Strict version control for active NDAs, MSAs, and partner compliance tracking.',
icon: FileSignature,
color: 'from-purple-500 to-pink-500',
path: '/legal',
metrics: '3 Pending Signatures'
},
{
title: 'Partner Directory',
description: 'View active channel partners, audit logs, and their assigned enterprise content.',
icon: Users,
color: 'from-emerald-400 to-teal-500',
path: '/directory',
metrics: '12 Active Partners'
}
];
return (
<motion.div variants={containerVariants} initial="hidden" animate="show" className="max-w-[1400px] mx-auto w-full space-y-10">
<motion.div variants={itemVariants} className="flex flex-col gap-3">
<div className="inline-flex items-center gap-2.5 px-4 py-1.5 rounded-full bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 w-fit backdrop-blur-md shadow-sm dark:shadow-lg">
<div className="w-2 h-2 rounded-full bg-green-500 dark:bg-green-400 animate-pulse shadow-[0_0_10px_rgba(34,197,94,0.5)] dark:shadow-[0_0_10px_rgba(74,222,128,0.8)]" />
<span className="text-[11px] font-bold text-slate-600 dark:text-white/80 tracking-widest uppercase">System Operational</span>
</div>
<h1 className="text-5xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-slate-900 via-slate-700 to-slate-500 dark:from-white dark:via-white/90 dark:to-white/40 tracking-tight mt-6">
Welcome back, {user?.email?.split('@')[0]}
</h1>
<p className="text-xl text-slate-500 dark:text-white/40 max-w-2xl leading-relaxed mt-2 font-medium">
You are authenticated as <span className="text-blue-600 dark:text-blue-400 font-bold">{user?.role}</span>. Manage your channel network, monitor compliance, and distribute assets globally.
</p>
</motion.div>
{/* Stats Grid */}
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-6">
{[
{ label: 'Global Network Uptime', value: '99.99%', icon: Activity, trend: '+0.01%' },
{ label: 'Active Data Streams', value: '1,492', icon: Zap, trend: '+12%' },
{ label: 'Security Compliance', value: 'Level 4', icon: ShieldCheck, trend: 'Verified' }
].map((stat, i) => (
<div key={i} className="relative group overflow-hidden rounded-3xl bg-white/60 dark:bg-[#0A0A0A]/50 backdrop-blur-xl border border-slate-200 dark:border-white/10 p-8 hover:border-slate-300 dark:hover:border-white/20 hover:bg-white dark:hover:bg-white/5 transition-all duration-300 shadow-xl shadow-slate-200/50 dark:shadow-2xl">
<div className="absolute top-0 right-0 p-6 opacity-5 dark:opacity-5 group-hover:opacity-10 dark:group-hover:opacity-20 transition-opacity duration-500 group-hover:scale-110 transform">
<stat.icon className="w-24 h-24 text-slate-900 dark:text-white" />
</div>
<p className="text-sm font-bold uppercase tracking-wider text-slate-500 dark:text-white/30 mb-2">{stat.label}</p>
<div className="flex items-end gap-4 mt-4">
<h3 className="text-5xl font-extrabold text-slate-900 dark:text-white tracking-tighter">{stat.value}</h3>
<span className="text-xs font-bold text-emerald-700 dark:text-emerald-400 bg-emerald-100 dark:bg-emerald-400/10 px-2.5 py-1.5 rounded-lg mb-1.5 border border-emerald-200 dark:border-emerald-400/20">{stat.trend}</span>
</div>
</div>
))}
</motion.div>
{/* Main Action Cards */}
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-8 pt-6">
{CARDS.map((card, idx) => (
<Link key={idx} to={card.path} className="group relative block">
<div className="absolute -inset-[1px] bg-gradient-to-b from-slate-200 to-transparent dark:from-white/15 dark:to-transparent rounded-[2rem] opacity-0 group-hover:opacity-100 transition-opacity duration-500 blur-sm" />
<div className="relative h-full bg-white/80 dark:bg-[#0A0A0A]/80 backdrop-blur-2xl border border-slate-200 dark:border-white/10 rounded-[2rem] p-10 hover:border-slate-300 dark:hover:border-white/30 transition-all duration-300 overflow-hidden shadow-xl shadow-slate-200/50 dark:shadow-2xl hover:shadow-2xl hover:shadow-slate-300/50 dark:hover:shadow-[0_20px_40px_rgba(0,0,0,0.5)] hover:-translate-y-1">
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br opacity-5 dark:opacity-10 group-hover:opacity-10 dark:group-hover:opacity-30 blur-3xl transition-opacity duration-500 rounded-full -mr-12 -mt-12" />
<div className="flex justify-between items-start mb-16 relative z-10">
<div className={`w-16 h-16 rounded-2xl bg-gradient-to-br ${card.color} p-[1px] shadow-lg group-hover:scale-110 transition-transform duration-500`}>
<div className="w-full h-full bg-white dark:bg-[#0A0A0A] rounded-2xl flex items-center justify-center">
<card.icon className="w-8 h-8 text-slate-800 dark:text-white" />
</div>
</div>
<div className="w-12 h-12 rounded-full bg-slate-50 dark:bg-white/5 flex items-center justify-center group-hover:bg-slate-100 dark:group-hover:bg-white/20 transition-all duration-300 border border-slate-200 dark:border-white/5 group-hover:border-slate-300 dark:group-hover:border-white/20">
<ArrowUpRight className="w-6 h-6 text-slate-400 dark:text-white/40 group-hover:text-slate-900 dark:group-hover:text-white transition-colors" />
</div>
</div>
<div className="relative z-10">
<div className="inline-block px-3.5 py-1.5 rounded-full bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 text-xs font-bold text-slate-500 dark:text-white/60 mb-5 shadow-sm dark:shadow-inner">
{card.metrics}
</div>
<h3 className="text-3xl font-extrabold text-slate-900 dark:text-white mb-4 tracking-tight group-hover:text-transparent group-hover:bg-clip-text group-hover:bg-gradient-to-r group-hover:from-slate-900 group-hover:to-slate-600 dark:group-hover:from-white dark:group-hover:to-white/50 transition-all">
{card.title}
</h3>
<p className="text-slate-500 dark:text-white/40 leading-relaxed text-sm font-medium">
{card.description}
</p>
</div>
</div>
</Link>
))}
</motion.div>
</motion.div>
);
};

View File

@ -0,0 +1,181 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Shield, CheckCircle, AlertCircle, ChevronRight, KeyRound } from 'lucide-react';
import axios from 'axios';
import { axiosInstance } from '../services/axios';
import { useAuthStore } from '../hooks/use-auth';
export const InvitePage: React.FC = () => {
const [searchParams] = useSearchParams();
const token = searchParams.get('token');
const navigate = useNavigate();
const { setAuth } = useAuthStore();
const [status, setStatus] = useState<'loading' | 'valid' | 'invalid'>('loading');
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!token) {
setStatus('invalid');
return;
}
const validateToken = async () => {
try {
const response = await axios.get(`${import.meta.env.VITE_API_URL || 'http://localhost:5001/api/v1'}/auth/invite/${token}`);
setEmail(response.data.email);
setStatus('valid');
} catch (err) {
setStatus('invalid');
}
};
validateToken();
}, [token]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
setIsSubmitting(true);
try {
const response = await axiosInstance.post('/auth/invite/accept', {
token,
password
});
// Save auth state
setAuth(response.data);
// Redirect to onboarding wizard
navigate('/onboarding');
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to accept invite');
setIsSubmitting(false);
}
};
if (status === 'loading') {
return (
<div className="min-h-screen bg-slate-50 dark:bg-[#050505] flex items-center justify-center">
<div className="w-8 h-8 border-4 border-blue-600/30 border-t-blue-600 rounded-full animate-spin" />
</div>
);
}
if (status === 'invalid') {
return (
<div className="min-h-screen bg-slate-50 dark:bg-[#050505] flex items-center justify-center p-4">
<div className="w-full max-w-md bg-white dark:bg-[#0A0A0A] rounded-3xl p-8 border border-slate-200 dark:border-white/10 text-center shadow-2xl">
<div className="w-16 h-16 bg-red-100 dark: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" />
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">Invalid or Expired Link</h2>
<p className="text-slate-500 dark:text-white/50 text-sm mb-8">
This invitation link is no longer valid. Please request a new invitation from your administrator.
</p>
<button onClick={() => navigate('/login')} className="text-blue-600 dark:text-blue-400 font-bold hover:underline">
Return to Login
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-slate-50 dark:bg-[#050505] text-slate-900 dark:text-white font-sans flex items-center justify-center p-4">
<div className="fixed top-0 right-0 w-[500px] h-[500px] bg-blue-500/5 dark:bg-blue-500/10 rounded-full blur-[120px] pointer-events-none" />
<div className="w-full max-w-md z-10">
<div className="text-center mb-10">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-600 shadow-xl flex items-center justify-center mx-auto mb-6">
<Shield className="w-6 h-6 text-white" />
</div>
<h1 className="text-3xl font-extrabold tracking-tight mb-2">Welcome to Tech4Biz</h1>
<p className="text-sm font-medium text-slate-500 dark:text-white/50">
Set up your partner account for <span className="text-slate-900 dark:text-white font-bold">{email}</span>
</p>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white dark:bg-[#0A0A0A] rounded-[2rem] p-8 shadow-2xl border border-slate-200 dark:border-white/10"
>
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="p-4 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0" />
<p className="text-xs font-bold text-red-800 dark:text-red-400">{error}</p>
</div>
)}
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-white/40">Set Password</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<KeyRound className="w-4 h-4 text-slate-400 dark:text-white/30" />
</div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-11 pr-4 py-3.5 bg-slate-50 dark:bg-[#111] border border-slate-200 dark:border-white/10 rounded-xl text-sm font-medium focus:ring-2 focus:ring-blue-500 outline-none transition-all"
placeholder="Enter a secure password"
required
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-white/40">Confirm Password</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<CheckCircle className="w-4 h-4 text-slate-400 dark:text-white/30" />
</div>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full pl-11 pr-4 py-3.5 bg-slate-50 dark:bg-[#111] border border-slate-200 dark:border-white/10 rounded-xl text-sm font-medium focus:ring-2 focus:ring-blue-500 outline-none transition-all"
placeholder="Confirm your password"
required
/>
</div>
</div>
<button
type="submit"
disabled={isSubmitting || !password || !confirmPassword}
className="w-full flex items-center justify-center gap-2 py-4 rounded-xl bg-blue-600 text-white font-bold hover:bg-blue-700 hover:shadow-lg hover:-translate-y-0.5 transition-all disabled:opacity-50 disabled:hover:translate-y-0 mt-6"
>
{isSubmitting ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
Create Account & Continue
<ChevronRight className="w-4 h-4" />
</>
)}
</button>
</form>
</motion.div>
</div>
</div>
);
};

View File

@ -0,0 +1,130 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { loginUser } from '../services/auth-api';
import { useAuthStore } from '../hooks/use-auth';
import { useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { Hexagon, Lock, Mail, ArrowRight } from 'lucide-react';
import { motion } from 'framer-motion';
const loginSchema = z.object({
email: z.string().email({ message: 'Invalid email address' }),
password: z.string().min(6, { message: 'Password must be at least 6 characters' }),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export const LoginPage = () => {
const [error, setError] = useState<string | null>(null);
const setAuth = useAuthStore((state) => state.setAuth);
const navigate = useNavigate();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginFormValues) => {
try {
setError(null);
const response = await loginUser({ email: data.email, password: data.password });
setAuth(response);
// Route based on role — no redirect flash
if (response.user.role === 'ADMIN') {
navigate('/admin');
} else {
navigate('/client');
}
} catch (err: any) {
setError(err.response?.data?.error || 'Authentication failed. Verify credentials.');
}
};
return (
<div className="min-h-screen bg-slate-50 dark:bg-[#050505] flex flex-col justify-center items-center p-4 relative overflow-hidden font-sans selection:bg-blue-500/30 transition-colors duration-500">
{/* Dynamic Background Elements */}
<div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-blue-500/5 dark:bg-blue-600/10 rounded-full blur-[150px] pointer-events-none animate-pulse" />
<div className="absolute bottom-1/4 right-1/4 w-[600px] h-[600px] bg-purple-500/5 dark:bg-purple-600/10 rounded-full blur-[150px] pointer-events-none" />
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1] }}
className="w-full max-w-md relative z-10"
>
<div className="bg-white/80 dark:bg-[#0A0A0A]/80 backdrop-blur-3xl border border-slate-200 dark:border-white/10 rounded-[2rem] shadow-2xl dark:shadow-[0_0_50px_rgba(0,0,0,0.5)] p-12 relative overflow-hidden">
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-slate-300 dark:via-white/20 to-transparent" />
<div className="flex flex-col items-center mb-12 text-center">
<div className="relative flex items-center justify-center w-20 h-20 rounded-[1.5rem] bg-gradient-to-tr from-blue-600 to-cyan-400 shadow-[0_0_40px_rgba(37,99,235,0.3)] mb-8">
<Hexagon className="text-white w-10 h-10 absolute" />
</div>
<h2 className="text-3xl font-extrabold text-slate-900 dark:text-white tracking-tight">Channel Portal</h2>
<p className="text-slate-500 dark:text-white/40 text-sm mt-3 font-medium uppercase tracking-widest">Authorized Access Only</p>
</div>
{error && (
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="bg-red-50 dark:bg-red-500/10 border border-red-200 dark: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 dark:shadow-lg">
<div className="w-2 h-2 rounded-full bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)] dark:shadow-[0_0_10px_rgba(239,68,68,0.8)]" />
{error}
</motion.div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-2">
<label className="block text-[11px] font-bold text-slate-500 dark:text-white/50 uppercase tracking-widest">Work Email</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Mail className={`w-5 h-5 transition-colors ${errors.email ? 'text-red-400' : 'text-slate-400 dark:text-white/20 group-focus-within:text-blue-500 dark:group-focus-within:text-blue-400'}`} />
</div>
<input
type="email"
{...register('email')}
className={`w-full bg-slate-50 dark:bg-white/5 border ${errors.email ? 'border-red-500/50 focus:border-red-500' : 'border-slate-200 dark:border-white/10 focus:border-blue-500/50'} rounded-2xl py-4 pl-12 pr-4 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-white/20 outline-none transition-all focus:bg-white dark:focus:bg-white/10 focus:ring-4 ring-blue-500/10 font-medium`}
placeholder="admin@tech4biz.com"
/>
</div>
{errors.email && <p className="text-red-500 dark:text-red-400 text-xs mt-1.5 font-bold">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<label className="block text-[11px] font-bold text-slate-500 dark:text-white/50 uppercase tracking-widest">Password</label>
<a href="#" className="text-[11px] font-bold text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 transition-colors tracking-wider">RECOVERY?</a>
</div>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Lock className={`w-5 h-5 transition-colors ${errors.password ? 'text-red-400' : 'text-slate-400 dark:text-white/20 group-focus-within:text-blue-500 dark:group-focus-within:text-blue-400'}`} />
</div>
<input
type="password"
{...register('password')}
className={`w-full bg-slate-50 dark:bg-white/5 border ${errors.password ? 'border-red-500/50 focus:border-red-500' : 'border-slate-200 dark:border-white/10 focus:border-blue-500/50'} rounded-2xl py-4 pl-12 pr-4 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-white/20 outline-none transition-all focus:bg-white dark:focus:bg-white/10 focus:ring-4 ring-blue-500/10 font-medium`}
placeholder="••••••••"
/>
</div>
{errors.password && <p className="text-red-500 dark:text-red-400 text-xs mt-1.5 font-bold">{errors.password.message}</p>}
</div>
<button
type="submit"
disabled={isSubmitting}
className="group relative w-full bg-slate-900 dark:bg-white text-white dark:text-black font-extrabold tracking-wide py-4 px-4 rounded-2xl hover:bg-slate-800 dark:hover:bg-gray-100 transition-all duration-300 disabled:opacity-70 disabled:cursor-not-allowed mt-4 overflow-hidden flex items-center justify-center gap-3 shadow-lg dark:shadow-[0_0_20px_rgba(255,255,255,0.2)] dark:hover:shadow-[0_0_30px_rgba(255,255,255,0.4)]"
>
{isSubmitting ? 'AUTHENTICATING...' : 'SECURE SIGN IN'}
{!isSubmitting && <ArrowRight className="w-5 h-5 group-hover:translate-x-1.5 transition-transform" />}
</button>
</form>
</div>
<p className="text-center text-slate-500 dark:text-white/20 text-xs mt-10 font-bold tracking-widest uppercase">
© 2026 Tech4Biz Solutions.
</p>
</motion.div>
</div>
);
};

View File

@ -0,0 +1,328 @@
import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ShieldCheck, CheckCircle, ChevronRight, Lock, UploadCloud, FileText, Clock } from 'lucide-react';
import { useAuthStore } from '../hooks/use-auth';
import { SignatureCapture } from '../features/agreements/components/SignatureCapture';
import { useNavigate } from 'react-router-dom';
import { axiosInstance } from '../services/axios';
type DocumentType = 'NDA' | 'MSA';
export const OnboardingPage: React.FC = () => {
const { user } = useAuthStore();
const navigate = useNavigate();
const [step, setStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
// State for NDA
const [ndaSignature, setNdaSignature] = useState<string | null>(null);
const [ndaUploadUrl, setNdaUploadUrl] = useState<string | null>(null);
const [ndaMode, setNdaMode] = useState<'draw' | 'upload'>('draw');
// State for MSA
const [msaSignature, setMsaSignature] = useState<string | null>(null);
const [msaUploadUrl, setMsaUploadUrl] = useState<string | null>(null);
const [msaMode, setMsaMode] = useState<'draw' | 'upload'>('draw');
const fileInputRef = useRef<HTMLInputElement>(null);
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
axiosInstance.get('/legal/my-acceptances').then(res => {
const hasNDA = res.data.some((a: any) => a.document.type === 'NDA');
const hasMSA = res.data.some((a: any) => a.document.type === 'MSA');
if (hasNDA && !hasMSA) setStep(2);
if (hasNDA && hasMSA) setStep(3);
}).catch(console.error);
}
}, [user, navigate]);
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>, type: DocumentType) => {
const file = e.target.files?.[0];
if (!file) return;
setIsSubmitting(true);
try {
const formData = new FormData();
formData.append('file', file);
formData.append('title', `Signed ${type} - ${user?.email}`);
const response = await axiosInstance.post('/assets/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (type === 'NDA') setNdaUploadUrl(response.data.url);
if (type === 'MSA') setMsaUploadUrl(response.data.url);
} catch (error) {
console.error(`Failed to upload ${type}:`, error);
} finally {
setIsSubmitting(false);
}
};
const submitDocument = async (type: DocumentType) => {
setIsSubmitting(true);
try {
const signatureBase64 = type === 'NDA' ? ndaSignature : msaSignature;
const documentUrl = type === 'NDA' ? ndaUploadUrl : msaUploadUrl;
await axiosInstance.post('/legal/sign', {
documentType: type,
signatureBase64,
documentUrl
});
if (type === 'NDA') {
setStep(2);
} else {
setStep(3); // PENDING_APPROVAL
// Force a page reload to update auth state / guard triggers
window.location.reload();
}
} catch (error) {
console.error(`Failed to submit ${type}:`, error);
} finally {
setIsSubmitting(false);
}
};
const renderDocumentTab = (type: DocumentType, mode: 'draw' | 'upload', setMode: any, signature: any, setSignature: any, uploadUrl: any, setUploadUrl: any) => (
<div className="flex-1 flex flex-col mb-8">
<div className="flex bg-slate-100 dark:bg-white/5 rounded-xl p-1 mb-6 max-w-sm">
<button
onClick={() => setMode('draw')}
className={`flex-1 py-2 text-sm font-bold rounded-lg transition-all ${mode === 'draw' ? 'bg-white dark:bg-[#222] shadow text-blue-600 dark:text-blue-400' : 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'}`}
>
Draw Signature
</button>
<button
onClick={() => setMode('upload')}
className={`flex-1 py-2 text-sm font-bold rounded-lg transition-all ${mode === 'upload' ? 'bg-white dark:bg-[#222] shadow text-blue-600 dark:text-blue-400' : 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'}`}
>
Upload PDF
</button>
</div>
{mode === 'draw' ? (
<div className="flex-1 flex flex-col justify-center">
<SignatureCapture onSignatureComplete={setSignature} />
{signature && (
<div className="mt-6 flex items-center justify-between p-4 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 rounded-xl">
<div className="flex items-center gap-3">
<CheckCircle className="w-5 h-5 text-emerald-600 dark:text-emerald-400" />
<span className="font-semibold text-emerald-700 dark:text-emerald-400 text-sm">Signature captured successfully</span>
</div>
<button onClick={() => setSignature(null)} className="text-xs font-bold text-emerald-600 dark:text-emerald-400 underline">Redraw</button>
</div>
)}
</div>
) : (
<div className="flex-1 flex flex-col justify-center items-center p-12 border-2 border-dashed border-slate-200 dark:border-white/10 rounded-2xl bg-slate-50 dark:bg-white/5 transition-colors hover:bg-slate-100 dark:hover:bg-white/10">
<input
type="file"
ref={fileInputRef}
onChange={(e) => handleFileUpload(e, type)}
className="hidden"
accept=".pdf,.doc,.docx,.jpg,.png"
/>
{uploadUrl ? (
<div className="text-center">
<div className="w-16 h-16 bg-emerald-100 dark:bg-emerald-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-emerald-600 dark:text-emerald-400" />
</div>
<h3 className="font-bold text-slate-900 dark:text-white mb-2">Document Uploaded</h3>
<button onClick={() => setUploadUrl(null)} className="text-sm font-bold text-slate-500 hover:text-slate-900 underline">Remove & Replace</button>
</div>
) : (
<div className="text-center">
<div className="w-16 h-16 bg-blue-100 dark:bg-blue-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<UploadCloud className="w-8 h-8 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="font-bold text-slate-900 dark:text-white mb-2">Upload Signed Document</h3>
<p className="text-sm text-slate-500 dark:text-white/50 mb-6">PDF, Word, or Image formats accepted.</p>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
className="px-6 py-2.5 bg-slate-900 dark:bg-white text-white dark:text-slate-900 font-bold rounded-xl hover:shadow-lg transition-all"
>
{isSubmitting ? 'Uploading...' : 'Browse Files'}
</button>
</div>
)}
</div>
)}
</div>
);
return (
<div className="min-h-screen bg-slate-50 dark:bg-[#050505] text-slate-900 dark:text-white font-sans flex items-center justify-center p-4">
<div className="fixed top-0 right-0 w-[600px] h-[600px] bg-blue-500/5 dark:bg-blue-500/10 rounded-full blur-[150px] pointer-events-none" />
<div className="fixed bottom-0 left-[10%] w-[500px] h-[500px] bg-purple-500/5 dark:bg-purple-500/10 rounded-full blur-[120px] pointer-events-none" />
<div className="w-full max-w-5xl bg-white dark:bg-[#0A0A0A] rounded-[2rem] shadow-2xl border border-slate-200 dark:border-white/10 overflow-hidden relative z-10 flex flex-col md:flex-row min-h-[700px]">
{/* Left Side: Progress & Info */}
<div className="w-full md:w-1/3 bg-slate-50 dark:bg-white/5 border-r border-slate-200 dark:border-white/10 p-8 flex flex-col">
<div className="flex items-center gap-3 mb-12">
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-blue-600 to-indigo-600 shadow-lg">
<ShieldCheck className="w-5 h-5 text-white" />
</div>
<span className="text-xl font-extrabold tracking-tight">Tech4Biz</span>
</div>
<div className="space-y-8 flex-1">
<div className="relative pl-8">
<div className="absolute left-0 top-1 w-6 h-6 rounded-full bg-blue-600 flex items-center justify-center shadow-md">
<CheckCircle className="w-3.5 h-3.5 text-white" />
</div>
<div className="absolute left-3 top-7 bottom-[-20px] w-0.5 bg-blue-600/30"></div>
<h3 className="font-bold text-slate-900 dark:text-white">Account Created</h3>
<p className="text-xs font-medium text-slate-500 dark:text-white/40 mt-1">Credentials verified securely.</p>
</div>
<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-blue-600' : step === 1 ? 'border-2 border-blue-600 bg-white dark:bg-[#0A0A0A]' : 'bg-slate-200 dark:bg-white/10'}`}>
{step > 1 ? <CheckCircle className="w-3.5 h-3.5 text-white" /> : <div className="w-2 h-2 rounded-full bg-blue-600" />}
</div>
<div className="absolute left-3 top-7 bottom-[-20px] w-0.5 bg-slate-200 dark:bg-white/10">
<div className="w-full bg-blue-600/30 transition-all duration-500" style={{ height: step > 1 ? '100%' : '0%' }}></div>
</div>
<h3 className={`font-bold transition-colors ${step >= 1 ? 'text-slate-900 dark:text-white' : 'text-slate-400 dark:text-white/30'}`}>NDA Agreement</h3>
<p className="text-xs font-medium text-slate-500 dark:text-white/40 mt-1">Non-disclosure signature.</p>
</div>
<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-blue-600' : step === 2 ? 'border-2 border-blue-600 bg-white dark:bg-[#0A0A0A]' : 'bg-slate-200 dark:bg-white/10'}`}>
{step > 2 ? <CheckCircle className="w-3.5 h-3.5 text-white" /> : step === 2 ? <div className="w-2 h-2 rounded-full bg-blue-600" /> : <div className="w-2 h-2 rounded-full bg-transparent" />}
</div>
<div className="absolute left-3 top-7 bottom-[-20px] w-0.5 bg-slate-200 dark:bg-white/10">
<div className="w-full bg-blue-600/30 transition-all duration-500" style={{ height: step > 2 ? '100%' : '0%' }}></div>
</div>
<h3 className={`font-bold transition-colors ${step >= 2 ? 'text-slate-900 dark:text-white' : 'text-slate-400 dark:text-white/30'}`}>MSA Agreement</h3>
<p className="text-xs font-medium text-slate-500 dark:text-white/40 mt-1">Master Services Agreement.</p>
</div>
<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 === 3 ? 'bg-amber-500' : 'bg-slate-200 dark:bg-white/10'}`}>
{step === 3 ? <Clock className="w-3.5 h-3.5 text-white" /> : <div className="w-2 h-2 rounded-full bg-transparent" />}
</div>
<h3 className={`font-bold transition-colors ${step === 3 ? 'text-slate-900 dark:text-white' : 'text-slate-400 dark:text-white/30'}`}>Admin Approval</h3>
<p className="text-xs font-medium text-slate-500 dark:text-white/40 mt-1">Pending compliance review.</p>
</div>
</div>
<div className="mt-8 pt-8 border-t border-slate-200 dark:border-white/10">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-slate-200 dark:bg-white/10 flex items-center justify-center text-xs font-bold">
{user?.email?.charAt(0).toUpperCase()}
</div>
<div>
<p className="text-xs font-bold truncate max-w-[150px]">{user?.email}</p>
<p className="text-[10px] uppercase text-slate-500 dark:text-white/40 font-bold tracking-wider">Pending Partner</p>
</div>
</div>
</div>
</div>
{/* Right Side: Wizard Content */}
<div className="w-full md:w-2/3 p-8 md:p-12 flex flex-col relative">
<AnimatePresence mode="wait">
{step === 1 && (
<motion.div key="step1" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="flex flex-col h-full">
<div className="mb-8">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 mb-4">
<Lock className="w-3.5 h-3.5 text-amber-600 dark:text-amber-400" />
<span className="text-[10px] font-bold text-amber-700 dark:text-amber-400 tracking-widest uppercase">Action Required</span>
</div>
<h2 className="text-3xl font-extrabold tracking-tight mb-2">Non-Disclosure Agreement</h2>
<p className="text-sm font-medium text-slate-500 dark:text-white/50">
Please provide your signature or upload a signed copy of our standard NDA to proceed.
</p>
</div>
{renderDocumentTab('NDA', ndaMode, setNdaMode, ndaSignature, setNdaSignature, ndaUploadUrl, setNdaUploadUrl)}
<div className="mt-auto pt-6 border-t border-slate-200 dark:border-white/10 flex justify-end items-center">
<button
onClick={() => submitDocument('NDA')}
disabled={(!ndaSignature && !ndaUploadUrl) || isSubmitting}
className="flex items-center gap-2 px-8 py-3.5 rounded-xl bg-blue-600 text-white font-bold hover:bg-blue-700 hover:shadow-lg transition-all hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Processing...' : 'Continue to MSA'}
<ChevronRight className="w-4 h-4" />
</button>
</div>
</motion.div>
)}
{step === 2 && (
<motion.div key="step2" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="flex flex-col h-full">
<div className="mb-8">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20 mb-4">
<FileText className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
<span className="text-[10px] font-bold text-blue-700 dark:text-blue-400 tracking-widest uppercase">Final Agreement</span>
</div>
<h2 className="text-3xl font-extrabold tracking-tight mb-2">Master Services Agreement</h2>
<p className="text-sm font-medium text-slate-500 dark:text-white/50">
Sign the MSA to finalize your compliance requirements and enter the approval queue.
</p>
</div>
{renderDocumentTab('MSA', msaMode, setMsaMode, msaSignature, setMsaSignature, msaUploadUrl, setMsaUploadUrl)}
<div className="mt-auto pt-6 border-t border-slate-200 dark:border-white/10 flex justify-between items-center">
<button onClick={() => setStep(1)} className="text-sm font-bold text-slate-500 hover:text-slate-900 dark:hover:text-white transition-colors">
Back to NDA
</button>
<button
onClick={() => submitDocument('MSA')}
disabled={(!msaSignature && !msaUploadUrl) || isSubmitting}
className="flex items-center gap-2 px-8 py-3.5 rounded-xl bg-blue-600 text-white font-bold hover:bg-blue-700 hover:shadow-lg transition-all hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Processing...' : 'Submit for Approval'}
<ChevronRight className="w-4 h-4" />
</button>
</div>
</motion.div>
)}
{step === 3 && (
<motion.div key="step3" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="flex flex-col h-full items-center justify-center text-center">
<div className="w-24 h-24 rounded-full bg-amber-100 dark:bg-amber-500/20 flex items-center justify-center mb-8 relative">
<div className="absolute inset-0 rounded-full border-4 border-amber-200 dark:border-amber-500/30 animate-[spin_3s_linear_infinite]" border-style="dashed"></div>
<Clock className="w-10 h-10 text-amber-600 dark:text-amber-400 relative z-10" />
</div>
<h2 className="text-3xl font-extrabold tracking-tight mb-4">Pending Approval</h2>
<p className="text-slate-500 dark:text-white/60 mb-8 max-w-md mx-auto leading-relaxed">
Your legal agreements have been submitted securely. An administrator is currently reviewing your application. You will receive an email once your dashboard is unlocked.
</p>
<div className="p-6 bg-slate-50 dark:bg-white/5 rounded-2xl border border-slate-200 dark:border-white/10 max-w-sm w-full">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-medium text-slate-500">NDA Status</span>
<span className="text-xs font-bold text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 px-2 py-1 rounded-md">Signed</span>
</div>
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-medium text-slate-500">MSA Status</span>
<span className="text-xs font-bold text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 px-2 py-1 rounded-md">Signed</span>
</div>
<div className="flex justify-between items-center pt-3 border-t border-slate-200 dark:border-white/10">
<span className="text-sm font-medium text-slate-500">Account Access</span>
<span className="text-xs font-bold text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-500/10 px-2 py-1 rounded-md">Locked</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,193 @@
import React, { useEffect, useState } from 'react';
import { CheckCircle, Clock, Search, XCircle } from 'lucide-react';
import { axiosInstance } from '../../services/axios';
import { motion, AnimatePresence } from 'framer-motion';
interface PendingPartner {
id: string;
email: string;
createdAt: string;
acceptances: Array<{
id: string;
signatureHash: string | null;
documentUrl: string | null;
document: {
type: string;
version: string;
}
}>;
}
export const ApprovalsPage: React.FC = () => {
const [partners, setPartners] = useState<PendingPartner[]>([]);
const [loading, setLoading] = useState(true);
const [processingId, setProcessingId] = useState<string | null>(null);
useEffect(() => {
fetchPending();
}, []);
const fetchPending = async () => {
try {
const res = await axiosInstance.get('/legal/pending');
setPartners(res.data);
} catch (err) {
console.error('Failed to fetch pending partners', err);
} finally {
setLoading(false);
}
};
const approvePartner = async (partnerId: string) => {
setProcessingId(partnerId);
try {
await axiosInstance.post(`/legal/approve/${partnerId}`);
setPartners(partners.filter(p => p.id !== partnerId));
} catch (err) {
console.error('Failed to approve', err);
} finally {
setProcessingId(null);
}
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center min-h-[60vh]">
<div className="w-8 h-8 border-4 border-blue-600/30 border-t-blue-600 rounded-full animate-spin" />
</div>
);
}
return (
<div className="max-w-6xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-extrabold text-slate-900 dark:text-white tracking-tight flex items-center gap-3">
Approvals Queue
<span className="bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 text-xs px-2 py-1 rounded-full font-bold">
{partners.length} Pending
</span>
</h1>
<p className="text-slate-500 dark:text-white/50 text-sm mt-1">
Review and approve partner legal documents to grant platform access.
</p>
</div>
<div className="relative">
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="Search pending partners..."
className="pl-9 pr-4 py-2 bg-white dark:bg-[#111] border border-slate-200 dark:border-white/10 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 w-64"
/>
</div>
</div>
{/* List */}
<div className="bg-white dark:bg-[#0A0A0A] rounded-2xl border border-slate-200 dark:border-white/10 overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm whitespace-nowrap">
<thead className="bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10 text-slate-500 dark:text-white/50 font-bold uppercase tracking-wider text-xs">
<tr>
<th className="px-6 py-4">Partner</th>
<th className="px-6 py-4">NDA Status</th>
<th className="px-6 py-4">MSA Status</th>
<th className="px-6 py-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-white/10">
<AnimatePresence>
{partners.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-12 text-center">
<div className="w-12 h-12 rounded-full bg-emerald-100 dark:bg-emerald-500/10 flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-6 h-6 text-emerald-600 dark:text-emerald-400" />
</div>
<p className="text-slate-900 dark:text-white font-bold">Queue is empty</p>
<p className="text-slate-500 dark:text-white/50 text-xs mt-1">All partners have been reviewed.</p>
</td>
</tr>
) : (
partners.map(partner => {
const nda = partner.acceptances.find(a => a.document.type === 'NDA');
const msa = partner.acceptances.find(a => a.document.type === 'MSA');
return (
<motion.tr
key={partner.id}
initial={{ opacity: 1 }}
exit={{ opacity: 0, x: -20, backgroundColor: 'rgba(59, 130, 246, 0.1)' }}
className="hover:bg-slate-50 dark:hover:bg-white/5 transition-colors group"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center text-white font-bold text-xs shadow-md">
{partner.email.charAt(0).toUpperCase()}
</div>
<div>
<p className="font-bold text-slate-900 dark:text-white">{partner.email}</p>
<p className="text-xs text-slate-500 dark:text-white/40 flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(partner.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</td>
<td className="px-6 py-4">
{nda ? (
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-500" />
<span className="text-xs font-bold text-emerald-700 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 px-2 py-1 rounded-md">
{nda.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
</span>
{nda.documentUrl && (
<a href={nda.documentUrl} target="_blank" rel="noreferrer" className="text-xs font-bold text-blue-600 hover:underline ml-2">View</a>
)}
</div>
) : (
<div className="flex items-center gap-2 text-amber-500">
<XCircle className="w-4 h-4" />
<span className="text-xs font-bold">Missing</span>
</div>
)}
</td>
<td className="px-6 py-4">
{msa ? (
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-500" />
<span className="text-xs font-bold text-emerald-700 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 px-2 py-1 rounded-md">
{msa.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
</span>
{msa.documentUrl && (
<a href={msa.documentUrl} target="_blank" rel="noreferrer" className="text-xs font-bold text-blue-600 hover:underline ml-2">View</a>
)}
</div>
) : (
<div className="flex items-center gap-2 text-amber-500">
<XCircle className="w-4 h-4" />
<span className="text-xs font-bold">Missing</span>
</div>
)}
</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => approvePartner(partner.id)}
disabled={!nda || !msa || processingId === partner.id}
className="px-4 py-2 bg-blue-600 text-white text-xs font-bold rounded-lg hover:bg-blue-700 transition-colors shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{processingId === partner.id ? 'Approving...' : 'Approve Access'}
</button>
</td>
</motion.tr>
);
})
)}
</AnimatePresence>
</tbody>
</table>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,266 @@
import React, { useState, useEffect } from 'react';
import { Users, Mail, CheckCircle, AlertCircle, ChevronRight, UserPlus, Clock, ShieldCheck, RefreshCw } from 'lucide-react';
import { axiosInstance } from '../../services/axios';
import { motion, AnimatePresence } from 'framer-motion';
interface Partner {
id: string;
email: string;
onboardingStatus: string;
mfaEnabled: boolean;
createdAt: string;
}
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
PENDING_ONBOARDING: {
label: 'Pending Onboarding',
color: 'text-amber-700 dark:text-amber-400',
bg: 'bg-amber-50 dark:bg-amber-500/10',
border: 'border-amber-200 dark:border-amber-500/20',
},
PENDING_APPROVAL: {
label: 'Awaiting Approval',
color: 'text-blue-700 dark:text-blue-400',
bg: 'bg-blue-50 dark:bg-blue-500/10',
border: 'border-blue-200 dark:border-blue-500/20',
},
APPROVED: {
label: 'Active',
color: 'text-emerald-700 dark:text-emerald-400',
bg: 'bg-emerald-50 dark:bg-emerald-500/10',
border: 'border-emerald-200 dark:border-emerald-500/20',
},
};
const getStatusConfig = (status: string) => STATUS_CONFIG[status] ?? {
label: status,
color: 'text-slate-700 dark:text-slate-400',
bg: 'bg-slate-50 dark:bg-white/5',
border: 'border-slate-200 dark:border-white/10',
};
export const DirectoryPage: React.FC = () => {
const [email, setEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [inviteResult, setInviteResult] = useState<{ token?: string; error?: string } | null>(null);
const [partners, setPartners] = useState<Partner[]>([]);
const [loadingPartners, setLoadingPartners] = useState(true);
const fetchPartners = async () => {
setLoadingPartners(true);
try {
const res = await axiosInstance.get('/auth/partners');
setPartners(res.data);
} catch (err) {
console.error('Failed to fetch partners', err);
} finally {
setLoadingPartners(false);
}
};
useEffect(() => {
fetchPartners();
}, []);
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setInviteResult(null);
try {
const res = await axiosInstance.post('/auth/invite', { email });
setInviteResult({ token: res.data.token });
setEmail('');
// Refresh partner list to show the newly invited partner
fetchPartners();
} catch (err: any) {
setInviteResult({ error: err.response?.data?.error || 'Failed to send invite' });
} finally {
setIsSubmitting(false);
}
};
const counts = {
total: partners.length,
active: partners.filter(p => p.onboardingStatus === 'APPROVED').length,
pending: partners.filter(p => p.onboardingStatus !== 'APPROVED').length,
};
return (
<div className="max-w-6xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-extrabold text-slate-900 dark:text-white tracking-tight">
Partner Directory
</h1>
<p className="text-slate-500 dark:text-white/50 text-sm mt-1">
Manage your network and invite new partners to the platform.
</p>
</div>
<button
onClick={fetchPartners}
className="p-2.5 rounded-xl bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 text-slate-500 dark:text-white/40 hover:text-slate-900 dark:hover:text-white hover:shadow-md transition-all group"
title="Refresh"
>
<RefreshCw className="w-4 h-4 group-hover:rotate-180 transition-transform duration-500" />
</button>
</div>
{/* Stats Row */}
<div className="grid grid-cols-3 gap-4">
{[
{ label: 'Total Partners', value: counts.total, icon: Users },
{ label: 'Active', value: counts.active, icon: ShieldCheck },
{ label: 'Pending', value: counts.pending, icon: Clock },
].map((stat, i) => (
<div key={i} className="bg-white dark:bg-[#0A0A0A] rounded-2xl border border-slate-200 dark:border-white/10 p-5 shadow-sm">
<div className="flex items-center justify-between mb-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-500 dark:text-white/40">{stat.label}</p>
<stat.icon className="w-4 h-4 text-slate-400 dark:text-white/20" />
</div>
<p className="text-3xl font-extrabold text-slate-900 dark:text-white tracking-tight">{stat.value}</p>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Invite Form */}
<div className="lg:col-span-1">
<div className="bg-white dark:bg-[#0A0A0A] rounded-2xl border border-slate-200 dark:border-white/10 p-6 shadow-sm relative overflow-hidden sticky top-8">
<div className="absolute top-0 right-0 p-4 opacity-5 pointer-events-none">
<UserPlus className="w-24 h-24 text-blue-600" />
</div>
<h3 className="text-lg font-bold text-slate-900 dark:text-white mb-2 relative z-10">Invite Partner</h3>
<p className="text-xs text-slate-500 dark:text-white/50 mb-6 relative z-10">
Generate a secure invitation link for a new partner.
</p>
<form onSubmit={handleInvite} className="space-y-4 relative z-10">
<div>
<label className="text-[10px] font-bold uppercase tracking-wider text-slate-500 dark:text-white/40 mb-1.5 block">Email Address</label>
<div className="relative">
<Mail className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="partner@company.com"
required
className="w-full pl-9 pr-4 py-2.5 bg-slate-50 dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all"
/>
</div>
</div>
<button
type="submit"
disabled={isSubmitting || !email}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-blue-600 text-white font-bold text-sm hover:bg-blue-700 hover:shadow-lg transition-all disabled:opacity-50"
>
{isSubmitting ? 'Generating...' : 'Generate Invite Link'}
<ChevronRight className="w-4 h-4" />
</button>
</form>
<AnimatePresence>
{inviteResult && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="mt-6 overflow-hidden"
>
{inviteResult.error ? (
<div className="p-4 bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-xl flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-xs font-bold text-red-800 dark:text-red-400">{inviteResult.error}</p>
</div>
) : (
<div className="p-4 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 rounded-xl">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
<span className="text-xs font-bold text-emerald-700 dark:text-emerald-400">Invite Created!</span>
</div>
<p className="text-[10px] text-emerald-600/80 dark:text-emerald-400/80 mb-2 font-medium">Send this secure link to the partner:</p>
<div className="p-2 bg-white dark:bg-black/40 border border-emerald-200 dark:border-emerald-500/30 rounded-lg text-xs break-all font-mono text-emerald-900 dark:text-emerald-300">
{window.location.origin}/invite?token={inviteResult.token}
</div>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Partner List */}
<div className="lg:col-span-2">
<div className="bg-white dark:bg-[#0A0A0A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm whitespace-nowrap">
<thead className="bg-slate-50 dark:bg-white/5 border-b border-slate-200 dark:border-white/10 text-slate-500 dark:text-white/50 font-bold uppercase tracking-wider text-xs">
<tr>
<th className="px-6 py-4">Partner</th>
<th className="px-6 py-4">Status</th>
<th className="px-6 py-4">MFA</th>
<th className="px-6 py-4">Joined</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-white/10">
{loadingPartners ? (
<tr>
<td colSpan={4} className="px-6 py-12 text-center">
<div className="w-6 h-6 border-4 border-blue-600/30 border-t-blue-600 rounded-full animate-spin mx-auto" />
</td>
</tr>
) : partners.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-12 text-center">
<div className="w-12 h-12 bg-slate-100 dark:bg-white/5 rounded-full flex items-center justify-center mx-auto mb-3">
<Users className="w-6 h-6 text-slate-400 dark:text-white/20" />
</div>
<p className="text-sm font-bold text-slate-900 dark:text-white">No partners yet</p>
<p className="text-xs text-slate-500 dark:text-white/40 mt-1">Use the invite form to add your first partner.</p>
</td>
</tr>
) : (
partners.map(partner => {
const sc = getStatusConfig(partner.onboardingStatus);
return (
<tr key={partner.id} className="hover:bg-slate-50 dark:hover:bg-white/5 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center text-white font-bold text-xs shadow-md">
{partner.email.charAt(0).toUpperCase()}
</div>
<span className="font-bold text-slate-900 dark:text-white">{partner.email}</span>
</div>
</td>
<td className="px-6 py-4">
<span className={`text-xs font-bold px-2.5 py-1 rounded-md border ${sc.color} ${sc.bg} ${sc.border}`}>
{sc.label}
</span>
</td>
<td className="px-6 py-4">
{partner.mfaEnabled ? (
<span className="text-xs font-bold text-emerald-600 dark:text-emerald-400">Enabled</span>
) : (
<span className="text-xs font-bold text-slate-400 dark:text-white/30">Disabled</span>
)}
</td>
<td className="px-6 py-4 text-xs text-slate-500 dark:text-white/40 font-medium">
{new Date(partner.createdAt).toLocaleDateString()}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,23 @@
import { axiosInstance } from "./axios";
import type { AuthResponse, LoginParams } from "../types/auth";
export const AUTH_API_ROUTES = {
login: "/auth/login",
refresh: "/auth/refresh",
logout: "/auth/logout",
};
export const loginUser = async (params: LoginParams): Promise<AuthResponse> => {
const response = await axiosInstance.post<AuthResponse>(
AUTH_API_ROUTES.login,
params,
);
return response.data;
};
export const refreshAuthToken = async (): Promise<{ accessToken: string }> => {
const response = await axiosInstance.post<{ accessToken: string }>(
AUTH_API_ROUTES.refresh
);
return response.data;
};

View File

@ -0,0 +1,78 @@
import axios from 'axios';
export const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:5001/api/v1',
headers: {
'Content-Type': 'application/json',
},
withCredentials: true,
});
import { useAuthStore } from '../hooks/use-auth';
import { refreshAuthToken } from './auth-api';
let isRefreshing = false;
let failedQueue: Array<{ resolve: (value?: unknown) => void, reject: (reason?: any) => void }> = [];
const processQueue = (error: any, token: string | null = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
axiosInstance.interceptors.request.use(
(config) => {
const token = useAuthStore.getState().accessToken;
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
axiosInstance.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(token => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return axiosInstance(originalRequest);
}).catch(err => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
try {
const { accessToken } = await refreshAuthToken();
const { setAuth, user } = useAuthStore.getState();
if (user) {
setAuth({ user, accessToken });
}
processQueue(null, accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return axiosInstance(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
useAuthStore.getState().logout();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);

View File

@ -0,0 +1,17 @@
export interface User {
id: string;
email: string;
role: 'ADMIN' | 'PARTNER_USER';
organizationId: string | null;
onboardingStatus?: string;
}
export interface AuthResponse {
user: User;
accessToken: string;
}
export interface LoginParams {
email: string;
password: string;
}

View File

@ -0,0 +1,90 @@
// Types file defining core interfaces for the Tech4Biz client & admin portal
export type UserRole = 'CLIENT' | 'ADMIN';
export type OnboardingStatus =
| 'NOT_STARTED'
| 'FORM_COMPLETED'
| 'NDA_SIGNED'
| 'MSA_SIGNED'
| 'PENDING_APPROVAL'
| 'APPROVED'
| 'REJECTED';
export interface User {
id: string;
email: string;
role: UserRole;
companyName?: string;
website?: string;
sector?: string;
companySize?: string;
onboardingStatus: OnboardingStatus;
mfaVerified: boolean;
ndaSignature?: {
type: 'draw' | 'type' | 'upload';
dataUrl: string;
date: string;
};
msaSignature?: {
type: 'draw' | 'type' | 'upload';
dataUrl: string;
date: string;
};
createdAt: string;
}
export interface Category {
id: string;
name: string; // e.g. 'Silicon', 'Software', 'AI', 'Cloud'
subcategories: string[];
}
export interface Asset {
id: string;
title: string;
description: string; // Rich text / markdown
categoryId: string; // e.g. 'silicon'
subcategory: string; // e.g. 'FPGA'
tags: string[];
author: string;
publishDate: string;
thumbnailUrl: string;
bannerUrl?: string;
downloadUrl?: string;
githubUrl?: string;
status: 'draft' | 'in_review' | 'published' | 'archived';
downloadsCount: number;
}
export interface BlogPost {
id: string;
title: string;
content: string;
author: string;
publishDate: string;
thumbnailUrl: string;
readTime: string;
tags: string[];
status: 'draft' | 'published';
}
export interface Announcement {
id: string;
title: string;
content: string;
date: string;
severity: 'info' | 'warning' | 'success';
}
export interface OnboardingFormInput {
companyName: string;
website: string;
sector: string;
companySize: string;
}
export interface SignaturePayload {
type: 'draw' | 'type' | 'upload';
dataUrl: string;
}

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [tailwindcss(), react()],
server: {
allowedHosts: [
"toughly-coinstantaneous-dimple.ngrok-free.dev"
],
cors: true
}
});

795
Guide.md Normal file
View File

@ -0,0 +1,795 @@
Full Stack Architecture & Implementation Guide
===========================================================
You are a **world-class Staff+ Full Stack Engineer, Solution Architect, Product Designer, DevOps Engineer, and Security Expert** with experience building enterprise SaaS platforms used by companies like Microsoft, Atlassian, Salesforce, Notion, Stripe, and Linear.
Your mindset is **perfection over shortcuts**.
I don't want just a working application.
I want a **production-ready**, **enterprise-grade**, **beautiful**, **high-performance**, **secure**, **scalable**, **maintainable**, and **future-proof** platform.
You should think like the CTO of a billion-dollar SaaS company.
Tech Stack
==========
### Backend
* Node.js
* Express.js
* PostgreSQL
### Frontend
* React
* Tailwind CSS
### You are free to use
Use any FREE and OPEN SOURCE technologies whenever they genuinely improve the product.
Examples include:
* React Query / TanStack Query
* React Hook Form
* Zod
* Zustand
* Framer Motion
* DND Kit
* TipTap
* React PDF
* Shadcn/UI
* Radix UI
* UploadThing alternative (self-hosted)
* MinIO
* Keycloak
* Authentik
* Ory Kratos
* TOTP MFA
* PDF generation libraries
* File preview libraries
* Image optimization
* Docker
* Redis
* BullMQ
* pgvector
* OpenSearch (if needed)
* etc.
Choose the best tools.
Always prefer open-source.
Project
=======
Build a **Channel Partner Onboarding Platform**.
This platform will be used by administrators to onboard channel partners, securely share assets, manage documents, and personalize each partner's experience.
The platform should feel like a premium SaaS product—not an internal admin dashboard.
Think of a combination of:
* Notion
* Linear
* Dropbox
* Vercel Dashboard
* Stripe Dashboard
* HubSpot Portal
The UX should be elegant, minimal, modern, and delightful.
Phase 1 Priority
================
Before implementing backend logic, carefully audit the existing frontend.
Go through every page.
Every component.
Every layout.
Every interaction.
Every animation.
Every responsive breakpoint.
Every state.
Identify:
* missing functionality
* broken UI
* inconsistent spacing
* inconsistent typography
* UX improvements
* accessibility issues
* responsiveness issues
* performance issues
* animation improvements
* code quality improvements
Do not break existing design language.
Instead,
Refine it into a premium enterprise experience.
Core Requirement 1
==================
Personalized Client Dashboard
-----------------------------
Every client/channel partner should have their own dashboard.
The dashboard must be fully personalized.
Admins should have complete control over what each client can see.
Examples:
* Products
* Solutions
* URLs
* Videos
* PDFs
* PPTs
* Whitepapers
* Images
* Marketing assets
* Sales assets
* Training materials
* Documents
* Internal announcements
* Custom messages
* Release notes
* Product roadmap (optional)
Each client should only see the content assigned to them.
Nothing else.
Everything should feel personalized.
Core Requirement 2
==================
Dynamic Asset Management
------------------------
Only Admins can upload assets.
Assets can be:
* Product URLs
* Landing pages
* Websites
* Images
* Videos
* Audio
* PDF
* PPT
* DOC
* XLS
* ZIP
* Whitepapers
* Marketing Collateral
* Design Files
* Training Material
* Documentation
* Any digital asset
The upload system should be:
* minimal by default
* extremely powerful
* intuitive
* drag & drop
* multi-upload
* upload progress
* retry upload
* validation
* preview
* thumbnails
* search
* filters
* tags
* categories
* folders
* metadata
* versioning
* expiry dates (optional)
* access permissions
The experience should feel like Dropbox or Google Drive.
Core Requirement 3
==================
Asset Sharing
-------------
Admins should easily:
Assign
* one asset
multiple assets
entire folders
entire collections
to
one client
multiple clients
client groups
partner organizations
Need powerful bulk actions.
Examples:
Duplicate assets from Client A → Client B
Clone entire asset library
Share templates
Bulk assign
Bulk revoke
Bulk update permissions
Everything should require the minimum number of clicks.
Core Requirement 4
==================
Live Preview
------------
Everything should have Preview.
Admin should instantly preview
"What will this look like for the client?"
Exactly the same UI.
No guessing.
Preview should simulate
* desktop
* tablet
* mobile
Admin should confidently know what clients will experience.
Core Requirement 5
==================
Frontend Excellence
-------------------
Every interaction should feel premium.
Examples:
* Smooth animations
* Skeleton loading
* Empty states
* Error states
* Optimistic UI
* Toast notifications
* Keyboard shortcuts
* Search everywhere
* Infinite scrolling where appropriate
* Beautiful tables
* Drag-and-drop interactions
* Responsive layouts
* Accessible components
* Dark mode ready
* Micro interactions
* Motion without distraction
Nothing should feel unfinished.
Core Requirement 6
==================
Enterprise Backend
------------------
Build a scalable backend architecture.
Design proper:
* folder structure
* controllers
* services
* repositories
* validation
* middleware
* logging
* rate limiting
* RBAC
* audit logs
* file storage abstraction
* caching
* queues
* email service
* notification service
* activity tracking
* API versioning
Follow best practices.
Everything should be modular.
Authentication
==============
Implement enterprise authentication.
Requirements:
* Signup
* Signin
* Forgot Password
* Password Reset
* Email Verification
* MFA (TOTP)
* Session Management
* JWT
* Refresh Tokens
* Device Tracking
* Remember Device
* Secure Logout
Prefer free/open-source solutions.
Legal Documents
===============
Integrate:
NDA
---
Users should digitally accept NDA.
Store:
* accepted version
* timestamp
* IP
* audit trail
MSA
---
Same flow.
Version-controlled.
Legally track acceptance.
Dashboard Experience
====================
The client dashboard should feel premium.
Examples:
Welcome section
Recent uploads
Shared resources
Product cards
Training center
Quick links
Announcements
Support
Downloads
Bookmarks
Recently viewed
Search
Activity timeline
Everything should be elegant.
Admin Portal
============
Admin should have:
Partner Management
Asset Management
Permission Management
Preview Mode
Analytics
Audit Logs
Document Management
Legal Management
Notifications
User Management
Everything should be intuitive.
Database
========
Design an enterprise PostgreSQL schema.
Include:
Users
Roles
Permissions
Organizations
Channel Partners
Assets
Folders
Categories
Tags
Collections
Shared Assets
Groups
Documents
NDA Versions
MSA Versions
Accepted Documents
Activity Logs
Audit Logs
Notifications
Sessions
MFA
Refresh Tokens
Design for scalability.
Performance
===========
Target:
95+ Lighthouse
Fast initial load
Lazy loading
Code splitting
Virtualization
Optimized queries
Caching
Compression
CDN-ready assets
Security
========
Implement:
OWASP best practices
XSS protection
CSRF protection
SQL injection prevention
Rate limiting
Input validation
Audit logging
Secure headers
Encryption
Password hashing
Role-based access
Code Quality
============
Everything must be:
Reusable
Scalable
Modular
Typed (where applicable)
Maintainable
Documented
Consistent
No duplicate code.
UI Quality
==========
The UI should feel comparable to:
* Linear
* Notion
* Stripe Dashboard
* Vercel
* Dropbox
* Figma
* Atlassian
Clean.
Minimal.
Premium.
Enterprise.
Final Objective
===============
Do not merely implement features.
Design and build a **best-in-class Channel Partner Onboarding Platform** that could realistically be sold as a commercial SaaS product.
Before writing code, first produce:
1. Complete system architecture.
2. Feature breakdown.
3. User flows.
4. Information architecture.
5. Database schema.
6. API design.
7. Frontend component architecture.
8. Authentication flow.
9. Asset lifecycle.
10. Admin workflow.
11. Client workflow.
12. Security model.
13. Deployment architecture.
14. Folder structure.
15. Development roadmap with milestones.
Then implement the platform incrementally, ensuring every feature is production-ready, fully tested, visually polished, performant, secure, and maintainable. Never take shortcuts—prioritize correctness, scalability, and user experience at every step.

32
README.md Normal file
View File

@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

18
docker-compose.yml Normal file
View File

@ -0,0 +1,18 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: channel_postgres
environment:
POSTGRES_USER: pipeline_admin
POSTGRES_PASSWORD: secure_pipeline_2024
POSTGRES_DB: backend_channel
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
volumes:
postgres_data:

372
package-lock.json generated Normal file
View File

@ -0,0 +1,372 @@
{
"name": "tech4biz-channel-monolith",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tech4biz-channel-monolith",
"version": "1.0.0",
"devDependencies": {
"concurrently": "^8.2.2"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2",
"date-fns": "^2.30.0",
"lodash": "^4.17.21",
"rxjs": "^7.8.1",
"shell-quote": "^1.8.1",
"spawn-command": "0.0.2",
"supports-color": "^8.1.1",
"tree-kill": "^1.2.2",
"yargs": "^17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": "^14.13.0 || >=16.0.0"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/spawn-command": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
"dev": true
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.3",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
"integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}

16
package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "tech4biz-channel-monolith",
"version": "1.0.0",
"description": "Monolithic Channel Partner Onboarding Platform",
"scripts": {
"install:all": "npm install --prefix Channel-Frontend && npm install --prefix Channel-Backend",
"dev:client": "npm run dev --prefix Channel-Frontend",
"dev:server": "npm run dev --prefix Channel-Backend",
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"build": "npm run build --prefix Channel-Frontend && npm run build --prefix Channel-Backend",
"start": "npm start --prefix Channel-Backend"
},
"devDependencies": {
"concurrently": "^8.2.2"
}
}