intial commit
This commit is contained in:
commit
f17eb1ea57
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal 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
5
Channel-Backend/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
# Keep environment variables out of version control
|
||||||
|
.env
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
95
Channel-Backend/api-test.js
Normal file
95
Channel-Backend/api-test.js
Normal 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
3274
Channel-Backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
Channel-Backend/package.json
Normal file
39
Channel-Backend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Channel-Backend/prisma.config.ts
Normal file
14
Channel-Backend/prisma.config.ts
Normal 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"],
|
||||||
|
},
|
||||||
|
});
|
||||||
96
Channel-Backend/prisma/schema.prisma
Normal file
96
Channel-Backend/prisma/schema.prisma
Normal 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
26
Channel-Backend/seed.js
Normal 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
27
Channel-Backend/seed.ts
Normal 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());
|
||||||
52
Channel-Backend/src/app.ts
Normal file
52
Channel-Backend/src/app.ts
Normal 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}`);
|
||||||
|
});
|
||||||
38
Channel-Backend/src/controllers/asset.controller.ts
Normal file
38
Channel-Backend/src/controllers/asset.controller.ts
Normal 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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
96
Channel-Backend/src/controllers/auth.controller.ts
Normal file
96
Channel-Backend/src/controllers/auth.controller.ts
Normal 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); }
|
||||||
|
};
|
||||||
|
}
|
||||||
101
Channel-Backend/src/controllers/legal.controller.ts
Normal file
101
Channel-Backend/src/controllers/legal.controller.ts
Normal 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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
49
Channel-Backend/src/controllers/organization.controller.ts
Normal file
49
Channel-Backend/src/controllers/organization.controller.ts
Normal 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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
32
Channel-Backend/src/middleware/auth.middleware.ts
Normal file
32
Channel-Backend/src/middleware/auth.middleware.ts
Normal 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();
|
||||||
|
};
|
||||||
|
};
|
||||||
17
Channel-Backend/src/middleware/error.middleware.ts
Normal file
17
Channel-Backend/src/middleware/error.middleware.ts
Normal 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' });
|
||||||
|
};
|
||||||
19
Channel-Backend/src/middleware/upload.middleware.ts
Normal file
19
Channel-Backend/src/middleware/upload.middleware.ts
Normal 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 });
|
||||||
15
Channel-Backend/src/routes/asset.routes.ts
Normal file
15
Channel-Backend/src/routes/asset.routes.ts
Normal 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;
|
||||||
19
Channel-Backend/src/routes/auth.routes.ts
Normal file
19
Channel-Backend/src/routes/auth.routes.ts
Normal 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;
|
||||||
23
Channel-Backend/src/routes/legal.routes.ts
Normal file
23
Channel-Backend/src/routes/legal.routes.ts
Normal 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;
|
||||||
18
Channel-Backend/src/routes/organization.routes.ts
Normal file
18
Channel-Backend/src/routes/organization.routes.ts
Normal 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;
|
||||||
21
Channel-Backend/src/services/asset.service.ts
Normal file
21
Channel-Backend/src/services/asset.service.ts
Normal 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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
131
Channel-Backend/src/services/auth.service.ts
Normal file
131
Channel-Backend/src/services/auth.service.ts
Normal 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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
93
Channel-Backend/src/services/legal.service.ts
Normal file
93
Channel-Backend/src/services/legal.service.ts
Normal 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' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
32
Channel-Backend/src/services/organization.service.ts
Normal file
32
Channel-Backend/src/services/organization.service.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
9
Channel-Backend/src/utils/db.ts
Normal file
9
Channel-Backend/src/utils/db.ts
Normal 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;
|
||||||
8
Channel-Backend/src/utils/errors.ts
Normal file
8
Channel-Backend/src/utils/errors.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
Channel-Backend/tsconfig.json
Normal file
13
Channel-Backend/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
8
Channel-Frontend/.oxlintrc.json
Normal file
8
Channel-Frontend/.oxlintrc.json
Normal 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 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
17
Channel-Frontend/index.html
Normal file
17
Channel-Frontend/index.html
Normal 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
2610
Channel-Frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
Channel-Frontend/package.json
Normal file
39
Channel-Frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
Channel-Frontend/public/favicon.svg
Normal file
1
Channel-Frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
Channel-Frontend/public/icons.svg
Normal file
24
Channel-Frontend/public/icons.svg
Normal 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 |
1
Channel-Frontend/src/App.css
Normal file
1
Channel-Frontend/src/App.css
Normal file
@ -0,0 +1 @@
|
|||||||
|
/* Boilersplate App.css emptied to avoid styling conflicts with Tailwind */
|
||||||
25
Channel-Frontend/src/App.tsx
Normal file
25
Channel-Frontend/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
170
Channel-Frontend/src/app/layouts/AdminLayout.tsx
Normal file
170
Channel-Frontend/src/app/layouts/AdminLayout.tsx
Normal 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;
|
||||||
169
Channel-Frontend/src/app/layouts/ClientLayout.tsx
Normal file
169
Channel-Frontend/src/app/layouts/ClientLayout.tsx
Normal 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;
|
||||||
37
Channel-Frontend/src/app/router/guards.tsx
Normal file
37
Channel-Frontend/src/app/router/guards.tsx
Normal 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}</>;
|
||||||
|
};
|
||||||
156
Channel-Frontend/src/app/router/index.tsx
Normal file
156
Channel-Frontend/src/app/router/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
BIN
Channel-Frontend/src/assets/hero.png
Normal file
BIN
Channel-Frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
Channel-Frontend/src/assets/react.svg
Normal file
1
Channel-Frontend/src/assets/react.svg
Normal 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 |
1
Channel-Frontend/src/assets/vite.svg
Normal file
1
Channel-Frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
98
Channel-Frontend/src/components/layout/MainLayout.tsx
Normal file
98
Channel-Frontend/src/components/layout/MainLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
386
Channel-Frontend/src/components/organisms/SignatureCapture.tsx
Normal file
386
Channel-Frontend/src/components/organisms/SignatureCapture.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
594
Channel-Frontend/src/features/auth/components/LoginForm.tsx
Normal file
594
Channel-Frontend/src/features/auth/components/LoginForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
135
Channel-Frontend/src/features/auth/store/AuthContext.tsx
Normal file
135
Channel-Frontend/src/features/auth/store/AuthContext.tsx
Normal 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;
|
||||||
|
};
|
||||||
285
Channel-Frontend/src/features/blog/components/BlogCatalog.tsx
Normal file
285
Channel-Frontend/src/features/blog/components/BlogCatalog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
28
Channel-Frontend/src/hooks/use-auth.ts
Normal file
28
Channel-Frontend/src/hooks/use-auth.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
36
Channel-Frontend/src/hooks/use-theme.ts
Normal file
36
Channel-Frontend/src/hooks/use-theme.ts
Normal 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 };
|
||||||
|
})
|
||||||
|
}));
|
||||||
555
Channel-Frontend/src/index.css
Normal file
555
Channel-Frontend/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
344
Channel-Frontend/src/lib/api-client.ts
Normal file
344
Channel-Frontend/src/lib/api-client.ts
Normal 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' };
|
||||||
|
}
|
||||||
|
};
|
||||||
296
Channel-Frontend/src/lib/mock-data.ts
Normal file
296
Channel-Frontend/src/lib/mock-data.ts
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
10
Channel-Frontend/src/main.tsx
Normal file
10
Channel-Frontend/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
118
Channel-Frontend/src/pages/AssetsPage.tsx
Normal file
118
Channel-Frontend/src/pages/AssetsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
118
Channel-Frontend/src/pages/DashboardPage.tsx
Normal file
118
Channel-Frontend/src/pages/DashboardPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
181
Channel-Frontend/src/pages/InvitePage.tsx
Normal file
181
Channel-Frontend/src/pages/InvitePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
130
Channel-Frontend/src/pages/LoginPage.tsx
Normal file
130
Channel-Frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
328
Channel-Frontend/src/pages/OnboardingPage.tsx
Normal file
328
Channel-Frontend/src/pages/OnboardingPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
193
Channel-Frontend/src/pages/admin/ApprovalsPage.tsx
Normal file
193
Channel-Frontend/src/pages/admin/ApprovalsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
266
Channel-Frontend/src/pages/admin/DirectoryPage.tsx
Normal file
266
Channel-Frontend/src/pages/admin/DirectoryPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
23
Channel-Frontend/src/services/auth-api.ts
Normal file
23
Channel-Frontend/src/services/auth-api.ts
Normal 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;
|
||||||
|
};
|
||||||
78
Channel-Frontend/src/services/axios.ts
Normal file
78
Channel-Frontend/src/services/axios.ts
Normal 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);
|
||||||
|
}
|
||||||
|
);
|
||||||
17
Channel-Frontend/src/types/auth.ts
Normal file
17
Channel-Frontend/src/types/auth.ts
Normal 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;
|
||||||
|
}
|
||||||
90
Channel-Frontend/src/types/index.ts
Normal file
90
Channel-Frontend/src/types/index.ts
Normal 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;
|
||||||
|
}
|
||||||
26
Channel-Frontend/tsconfig.app.json
Normal file
26
Channel-Frontend/tsconfig.app.json
Normal 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"]
|
||||||
|
}
|
||||||
7
Channel-Frontend/tsconfig.json
Normal file
7
Channel-Frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
23
Channel-Frontend/tsconfig.node.json
Normal file
23
Channel-Frontend/tsconfig.node.json
Normal 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"]
|
||||||
|
}
|
||||||
14
Channel-Frontend/vite.config.ts
Normal file
14
Channel-Frontend/vite.config.ts
Normal 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
795
Guide.md
Normal 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
32
README.md
Normal 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
18
docker-compose.yml
Normal 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
372
package-lock.json
generated
Normal 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
16
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user