complete_foundation

This commit is contained in:
kenilkb 2026-07-09 15:32:31 +05:30
parent f17eb1ea57
commit aef4a3ec8b
39 changed files with 4014 additions and 1513 deletions

View File

@ -23,6 +23,7 @@ model Organization {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
sharedAssets SharedAsset[]
}
model User {
@ -41,6 +42,8 @@ model User {
updatedAt DateTime @updatedAt
acceptances LegalAcceptance[]
auditLogs AuditLog[]
sharedAssets SharedAsset[]
downloadRequests DownloadRequest[]
}
model Asset {
@ -51,24 +54,64 @@ model Asset {
url String
version Int @default(1)
uploadedBy String
description String? @db.Text
categoryId String?
subcategory String?
tags String[]
downloadsCount Int @default(0)
githubUrl String?
status String @default("published")
isDownloadable Boolean @default(true)
folderId String?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
sharedWith SharedAsset[]
downloadRequests DownloadRequest[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model SharedAsset {
id String @id @default(uuid())
assetId String
organizationId String
userId String?
createdAt DateTime @default(now())
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([assetId, organizationId, userId])
}
model DownloadRequest {
id String @id @default(uuid())
assetId String
userId String
status String @default("PENDING") // PENDING, APPROVED, REJECTED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([assetId, userId])
}
model Folder {
id String @id @default(uuid())
name String
parentId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assets Asset[]
}
model LegalDocument {
id String @id @default(uuid())
type DocumentType
version String
content String @db.Text
content String
isActive Boolean @default(false)
pdfUrl String?
createdAt DateTime @default(now())
acceptances LegalAcceptance[]
}

View File

@ -1,27 +1,185 @@
import dotenv from 'dotenv';
dotenv.config();
import prisma from './src/utils/db';
import bcrypt from 'bcrypt';
async function seed() {
await prisma.legalDocument.create({
console.log('Starting database seeding...');
// 1. Seed Active Legal Documents
let ndaDoc = await prisma.legalDocument.findFirst({
where: { type: 'NDA', version: '1.0' }
});
if (!ndaDoc) {
ndaDoc = await prisma.legalDocument.create({
data: {
type: 'NDA',
version: '1.0',
content: 'This is the standard Non-Disclosure Agreement content...',
content: 'This is the standard Non-Disclosure Agreement content. By signing this document, you agree to keep all technical and business materials confidential.',
isActive: true,
}
});
console.log('Seeded NDA Document.');
} else {
console.log('NDA Document already exists.');
}
await prisma.legalDocument.create({
let msaDoc = await prisma.legalDocument.findFirst({
where: { type: 'MSA', version: '1.0' }
});
if (!msaDoc) {
msaDoc = await prisma.legalDocument.create({
data: {
type: 'MSA',
version: '1.0',
content: 'This is the standard Master Services Agreement content...',
content: 'This is the standard Master Services Agreement content. It defines the core relationship, deliverables, and service levels between Tech4Biz and the partner.',
isActive: true,
}
});
console.log('Seeded MSA Document.');
} else {
console.log('MSA Document already exists.');
}
console.log('Documents seeded.');
// 2. Hash Password
const passwordHash = await bcrypt.hash('Password123', 10);
// Create default Partner organization
let partnerOrg = await prisma.organization.findFirst({
where: { name: 'PARTNER' }
});
if (!partnerOrg) {
partnerOrg = await prisma.organization.create({
data: { name: 'PARTNER' }
});
console.log('Seeded PARTNER organization.');
}
// 3. Seed Admin
const adminEmail = 'admin@tech4biz.com';
let adminUser = await prisma.user.findUnique({ where: { email: adminEmail } });
if (!adminUser) {
adminUser = await prisma.user.create({
data: {
email: adminEmail,
passwordHash,
role: 'ADMIN',
onboardingStatus: 'APPROVED',
mfaEnabled: false,
}
});
console.log(`Seeded Admin User: ${adminEmail}`);
} else {
console.log(`Admin User already exists: ${adminEmail}`);
}
// 4. Seed Partner 1: Awaiting onboarding
const onboardingEmail = 'pending-onboarding@partner.com';
let onboardingUser = await prisma.user.findUnique({ where: { email: onboardingEmail } });
if (!onboardingUser) {
onboardingUser = await prisma.user.create({
data: {
email: onboardingEmail,
passwordHash,
role: 'PARTNER_USER',
onboardingStatus: 'PENDING_ONBOARDING',
mfaEnabled: false,
organizationId: partnerOrg.id
}
});
console.log(`Seeded Partner (Pending Onboarding): ${onboardingEmail}`);
} else {
console.log(`Partner (Pending Onboarding) already exists: ${onboardingEmail}`);
}
// 5. Seed Partner 2: Awaiting admin approval (Signed NDA & MSA)
const pendingApprovalEmail = 'pending-approval@partner.com';
let pendingApprovalUser = await prisma.user.findUnique({ where: { email: pendingApprovalEmail } });
if (!pendingApprovalUser) {
pendingApprovalUser = await prisma.user.create({
data: {
email: pendingApprovalEmail,
passwordHash,
role: 'PARTNER_USER',
onboardingStatus: 'PENDING_APPROVAL',
mfaEnabled: false,
organizationId: partnerOrg.id
}
});
console.log(`Seeded Partner (Pending Approval): ${pendingApprovalEmail}`);
// Seed LegalAcceptance for NDA
await prisma.legalAcceptance.create({
data: {
docId: ndaDoc.id,
userId: pendingApprovalUser.id,
ipAddress: '127.0.0.1',
signatureHash: 'sha256-dummyndaaccept1234567890abcdef',
acceptedAt: new Date()
}
});
// Seed LegalAcceptance for MSA
await prisma.legalAcceptance.create({
data: {
docId: msaDoc.id,
userId: pendingApprovalUser.id,
ipAddress: '127.0.0.1',
signatureHash: 'sha256-dummymsaaccept1234567890abcdef',
acceptedAt: new Date()
}
});
console.log('Seeded Legal Acceptances for NDA/MSA for pending-approval partner.');
} else {
console.log(`Partner (Pending Approval) already exists: ${pendingApprovalEmail}`);
}
// 6. Seed Partner 3: Fully active & approved
const activeEmail = 'active-partner@partner.com';
let activeUser = await prisma.user.findUnique({ where: { email: activeEmail } });
if (!activeUser) {
activeUser = await prisma.user.create({
data: {
email: activeEmail,
passwordHash,
role: 'PARTNER_USER',
onboardingStatus: 'APPROVED',
mfaEnabled: false,
organizationId: partnerOrg.id
}
});
console.log(`Seeded Partner (Approved): ${activeEmail}`);
// Seed LegalAcceptance for NDA
await prisma.legalAcceptance.create({
data: {
docId: ndaDoc.id,
userId: activeUser.id,
ipAddress: '127.0.0.1',
signatureHash: 'sha256-dummyndaacceptactive987654321',
acceptedAt: new Date()
}
});
// Seed LegalAcceptance for MSA
await prisma.legalAcceptance.create({
data: {
docId: msaDoc.id,
userId: activeUser.id,
ipAddress: '127.0.0.1',
signatureHash: 'sha256-dummymsaacceptactive987654321',
acceptedAt: new Date()
}
});
console.log('Seeded Legal Acceptances for NDA/MSA for active partner.');
} else {
console.log(`Partner (Approved) already exists: ${activeEmail}`);
}
console.log('Seeding completed successfully.');
}
seed().catch(console.error).finally(() => prisma.$disconnect());
seed()
.catch((err) => {
console.error('Seeding failed:', err);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@ -16,7 +16,17 @@ import legalRoutes from './routes/legal.routes';
const app: Express = express();
const PORT = process.env.PORT || 5000;
app.use(helmet());
app.use(helmet({
crossOriginResourcePolicy: { policy: "cross-origin" },
contentSecurityPolicy: {
useDefaults: false,
directives: {
"default-src": helmet.contentSecurityPolicy.dangerouslyDisableDefaultSrc,
"frame-ancestors": ["'self'", "http://localhost:5173", "http://localhost:5000"],
},
},
frameguard: false,
}));
app.use(cors({ origin: true, credentials: true }));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

View File

@ -1,4 +1,4 @@
import { Request, Response, NextFunction } from 'express';
import { Response, NextFunction } from 'express';
import { AssetService } from '../services/asset.service';
import { AuthRequest } from '../middleware/auth.middleware';
@ -7,32 +7,123 @@ export class AssetController {
public uploadAsset = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
if (!req.file) throw new Error('No file uploaded');
const isUrlAsset = req.body.isUrlAsset === 'true' || req.body.isUrlAsset === true || req.body.type === 'url';
if (!isUrlAsset && !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,
});
// Parse shares if present
let shares = req.body.shares;
if (typeof shares === 'string' && shares.trim()) {
try { shares = JSON.parse(shares); } catch { shares = undefined; }
}
const assetData = {
title: req.body.title || (req.file ? req.file.originalname : 'URL Asset'),
type: isUrlAsset ? 'url' : req.file!.mimetype,
size: isUrlAsset ? 0 : req.file!.size,
url: isUrlAsset ? req.body.url : `/uploads/${req.file!.filename}`,
uploadedBy: uploaderId,
description: req.body.description || null,
categoryId: req.body.categoryId || null,
subcategory: req.body.subcategory || null,
tags: req.body.tags || [],
githubUrl: req.body.githubUrl || null,
status: req.body.status || 'published',
isDownloadable: req.body.isDownloadable === 'true' || req.body.isDownloadable === true,
shares,
sharedOrgIds: req.body.sharedOrgIds || null,
};
const asset = await this.assetService.createAsset(assetData);
res.status(201).json(asset);
} catch (err) { next(err); }
}
public listAssets = async (req: Request, res: Response, next: NextFunction) => {
public listAssets = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const assets = await this.assetService.getAssets();
const userContext = req.user ? { role: req.user.role, userId: req.user.userId } : undefined;
const assets = await this.assetService.getAssets(userContext);
res.status(200).json(assets);
} catch(err) { next(err); }
}
public deleteAsset = async (req: Request, res: Response, next: NextFunction) => {
public getAsset = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const asset = await this.assetService.getAssetById(req.params.id);
if (!asset) {
return res.status(404).json({ error: 'Asset not found' });
}
res.status(200).json(asset);
} catch (err) { next(err); }
}
public updateAsset = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const asset = await this.assetService.updateAsset(req.params.id, req.body);
res.status(200).json(asset);
} catch (err) { next(err); }
}
public shareAsset = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { organizationIds } = req.body;
if (!Array.isArray(organizationIds)) {
return res.status(400).json({ error: 'organizationIds must be an array' });
}
const asset = await this.assetService.shareAsset(req.params.id, organizationIds);
res.status(200).json(asset);
} catch (err) { next(err); }
}
public unshareAsset = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { organizationIds } = req.body;
if (!Array.isArray(organizationIds)) {
return res.status(400).json({ error: 'organizationIds must be an array' });
}
const asset = await this.assetService.unshareAsset(req.params.id, organizationIds);
res.status(200).json(asset);
} catch (err) { next(err); }
}
public incrementDownload = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const asset = await this.assetService.incrementDownloadCount(req.params.id);
res.status(200).json(asset);
} catch (err) { next(err); }
}
public deleteAsset = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await this.assetService.deleteAsset(req.params.id);
res.status(204).send();
} catch(err) { next(err); }
}
public requestDownload = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const userId = req.user?.userId;
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
const request = await this.assetService.requestDownload(req.params.id, userId);
res.status(200).json(request);
} catch (err) { next(err); }
}
public approveDownload = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const request = await this.assetService.approveDownloadRequest(req.params.requestId);
res.status(200).json(request);
} catch (err) { next(err); }
}
public rejectDownload = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const request = await this.assetService.rejectDownloadRequest(req.params.requestId);
res.status(200).json(request);
} catch (err) { next(err); }
}
}

View File

@ -47,7 +47,7 @@ export class AuthController {
res.cookie('refreshToken', result.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
@ -66,7 +66,7 @@ export class AuthController {
res.cookie('refreshToken', result.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
@ -93,4 +93,19 @@ export class AuthController {
res.status(200).json(partners);
} catch(err) { next(err); }
};
public getCurrentUser = async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = (req as any).user?.userId;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const user = await this.authService.getUserById(userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.status(200).json(user);
} catch(err) { next(err); }
};
}

View File

@ -8,6 +8,7 @@ const docSchema = z.object({
type: z.enum(['NDA', 'MSA']),
version: z.string(),
content: z.string().min(1),
pdfUrl: z.string().optional().nullable(),
});
export class LegalController {
@ -98,4 +99,11 @@ export class LegalController {
res.status(200).json({ message: 'Partner approved successfully' });
} catch(err) { next(err); }
}
public uploadSignedDoc = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
if (!req.file) throw new Error('No file uploaded');
res.status(200).json({ url: `/uploads/${req.file.filename}` });
} catch (err) { next(err); }
}
}

View File

@ -10,6 +10,16 @@ router.use(authenticate);
router.post('/upload', requireRole('ADMIN'), upload.single('file'), assetController.uploadAsset);
router.get('/', assetController.listAssets);
router.get('/:id', assetController.getAsset);
router.patch('/:id', requireRole('ADMIN'), assetController.updateAsset);
router.post('/:id/share', requireRole('ADMIN'), assetController.shareAsset);
router.post('/:id/unshare', requireRole('ADMIN'), assetController.unshareAsset);
router.post('/:id/download', assetController.incrementDownload);
router.delete('/:id', requireRole('ADMIN'), assetController.deleteAsset);
// Download permission request management
router.post('/:id/request-download', assetController.requestDownload);
router.post('/:id/approve-download/:requestId', requireRole('ADMIN'), assetController.approveDownload);
router.post('/:id/reject-download/:requestId', requireRole('ADMIN'), assetController.rejectDownload);
export default router;

View File

@ -11,6 +11,7 @@ router.post('/login', authController.login);
router.post('/refresh', authController.refresh);
// Invite Flow
router.get('/me', authenticate, authController.getCurrentUser);
router.post('/invite', authenticate, requireRole('ADMIN'), authController.invitePartner);
router.get('/invite/:token', authController.validateInvite);
router.post('/invite/accept', authController.acceptInvite);

View File

@ -1,6 +1,7 @@
import { Router } from 'express';
import { LegalController } from '../controllers/legal.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
import { upload } from '../middleware/upload.middleware';
const router = Router();
const legalController = new LegalController();
@ -14,6 +15,7 @@ router.post('/documents', requireRole('ADMIN'), legalController.create);
router.get('/documents/active/:type', legalController.getActive);
router.post('/accept', legalController.accept);
router.post('/sign', legalController.sign);
router.post('/upload', upload.single('file'), legalController.uploadSignedDoc);
router.get('/my-acceptances', legalController.myAcceptances);
// Admin Approval Routes

View File

@ -2,20 +2,315 @@ import prisma from '../utils/db';
export class AssetService {
public async createAsset(data: any) {
return await prisma.asset.create({ data });
const { sharedOrgIds, shares, tags, ...rest } = data;
// Parse tags
let parsedTags: string[] = [];
if (Array.isArray(tags)) {
parsedTags = tags;
} else if (typeof tags === 'string' && tags.trim()) {
try {
parsedTags = JSON.parse(tags);
} catch {
parsedTags = tags.split(',').map((t: string) => t.trim()).filter(Boolean);
}
}
public async getAssets() {
const asset = await prisma.asset.create({
data: {
...rest,
tags: parsedTags,
}
});
// Handle immediate sharing
if (shares) {
let parsedShares: any[] = [];
if (Array.isArray(shares)) {
parsedShares = shares;
} else if (typeof shares === 'string' && shares.trim()) {
try {
parsedShares = JSON.parse(shares);
} catch {
parsedShares = shares.split(',').map((id: string) => ({ organizationId: id.trim(), userId: null })).filter(s => s.organizationId);
}
}
if (parsedShares.length > 0) {
await prisma.sharedAsset.createMany({
data: parsedShares.map(s => ({
assetId: asset.id,
organizationId: s.organizationId,
userId: s.userId || null,
})),
skipDuplicates: true,
});
}
} else if (sharedOrgIds) {
let orgIds: string[] = [];
if (Array.isArray(sharedOrgIds)) {
orgIds = sharedOrgIds;
} else if (typeof sharedOrgIds === 'string' && sharedOrgIds.trim()) {
try {
orgIds = JSON.parse(sharedOrgIds);
} catch {
orgIds = sharedOrgIds.split(',').map((id: string) => id.trim()).filter(Boolean);
}
}
if (orgIds.length > 0) {
await prisma.sharedAsset.createMany({
data: orgIds.map(orgId => ({
assetId: asset.id,
organizationId: orgId,
userId: null,
})),
skipDuplicates: true,
});
}
}
return this.getAssetById(asset.id);
}
public async getAssets(userContext?: { role: string; userId: string }) {
if (!userContext) {
return [];
}
if (userContext.role === 'ADMIN') {
return await prisma.asset.findMany({
include: {
sharedWith: {
include: {
organization: {
select: { id: true, name: true }
},
user: {
select: { id: true, email: true }
}
}
},
downloadRequests: {
include: {
user: {
select: { id: true, email: true }
}
}
}
},
orderBy: { createdAt: 'desc' }
});
}
// For clients/partners, find user organization first
const user = await prisma.user.findUnique({
where: { id: userContext.userId }
});
if (!user || !user.organizationId) {
return [];
}
return await prisma.asset.findMany({
where: {
status: 'published',
sharedWith: {
some: {
organizationId: user.organizationId,
OR: [
{ userId: null },
{ userId: user.id }
]
}
}
},
include: {
sharedWith: {
include: {
organization: {
select: { id: true, name: true }
},
user: {
select: { id: true, email: true }
}
}
},
downloadRequests: {
where: { userId: userContext.userId }
}
},
orderBy: { createdAt: 'desc' }
});
}
public async getAssetById(id: string) {
return await prisma.asset.findUnique({ where: { id } });
return await prisma.asset.findUnique({
where: { id },
include: {
sharedWith: {
include: {
organization: {
select: { id: true, name: true }
},
user: {
select: { id: true, email: true }
}
}
},
downloadRequests: {
include: {
user: {
select: { id: true, email: true }
}
}
}
}
});
}
public async updateAsset(id: string, data: any) {
const { sharedOrgIds, shares, tags, ...rest } = data;
const updateData: any = { ...rest };
if (tags !== undefined) {
let parsedTags: string[] = [];
if (Array.isArray(tags)) {
parsedTags = tags;
} else if (typeof tags === 'string') {
try {
parsedTags = JSON.parse(tags);
} catch {
parsedTags = tags.split(',').map((t: string) => t.trim()).filter(Boolean);
}
}
updateData.tags = parsedTags;
}
await prisma.asset.update({
where: { id },
data: updateData
});
if (shares !== undefined) {
let parsedShares: any[] = [];
if (Array.isArray(shares)) {
parsedShares = shares;
} else if (typeof shares === 'string') {
try {
parsedShares = JSON.parse(shares);
} catch {
parsedShares = shares.split(',').map((oid: string) => ({ organizationId: oid.trim(), userId: null })).filter(s => s.organizationId);
}
}
await prisma.sharedAsset.deleteMany({ where: { assetId: id } });
if (parsedShares.length > 0) {
await prisma.sharedAsset.createMany({
data: parsedShares.map(s => ({
assetId: id,
organizationId: s.organizationId,
userId: s.userId || null,
})),
skipDuplicates: true
});
}
} else if (sharedOrgIds !== undefined) {
let orgIds: string[] = [];
if (Array.isArray(sharedOrgIds)) {
orgIds = sharedOrgIds;
} else if (typeof sharedOrgIds === 'string') {
try {
orgIds = JSON.parse(sharedOrgIds);
} catch {
orgIds = sharedOrgIds.split(',').map((oid: string) => oid.trim()).filter(Boolean);
}
}
await prisma.sharedAsset.deleteMany({ where: { assetId: id } });
if (orgIds.length > 0) {
await prisma.sharedAsset.createMany({
data: orgIds.map(orgId => ({
assetId: id,
organizationId: orgId,
userId: null,
})),
skipDuplicates: true
});
}
}
return this.getAssetById(id);
}
public async shareAsset(assetId: string, organizationIds: string[]) {
await prisma.sharedAsset.createMany({
data: organizationIds.map(orgId => ({
assetId,
organizationId: orgId,
userId: null,
})),
skipDuplicates: true
});
return this.getAssetById(assetId);
}
public async unshareAsset(assetId: string, organizationIds: string[]) {
await prisma.sharedAsset.deleteMany({
where: {
assetId,
organizationId: { in: organizationIds },
userId: null
}
});
return this.getAssetById(assetId);
}
public async incrementDownloadCount(id: string) {
return await prisma.asset.update({
where: { id },
data: {
downloadsCount: { increment: 1 }
}
});
}
public async deleteAsset(id: string) {
return await prisma.asset.delete({ where: { id } });
}
// Download Requests Access Methods
public async requestDownload(assetId: string, userId: string) {
const existing = await prisma.downloadRequest.findFirst({
where: { assetId, userId }
});
if (existing) {
return await prisma.downloadRequest.update({
where: { id: existing.id },
data: { status: 'PENDING' }
});
}
return await prisma.downloadRequest.create({
data: {
assetId,
userId,
status: 'PENDING'
}
});
}
public async approveDownloadRequest(requestId: string) {
return await prisma.downloadRequest.update({
where: { id: requestId },
data: { status: 'APPROVED' }
});
}
public async rejectDownloadRequest(requestId: string) {
return await prisma.downloadRequest.update({
where: { id: requestId },
data: { status: 'REJECTED' }
});
}
}

View File

@ -4,39 +4,68 @@ import jwt from 'jsonwebtoken';
import { AppError } from '../utils/errors';
export class AuthService {
private async getOrCreateOrganizationForEmail(email: string) {
const domain = email.split('@')[1];
if (!domain) return null;
// Ignore generic/public emails or treat them as their own organization
const name = domain.split('.')[0].toUpperCase();
if (!name) return null;
let org = await prisma.organization.findFirst({
where: { name }
});
if (!org) {
org = await prisma.organization.create({
data: { name }
});
}
return org.id;
}
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);
let orgId = data.organizationId || null;
if (!orgId && data.role !== 'ADMIN') {
orgId = await this.getOrCreateOrganizationForEmail(data.email);
}
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,
organizationId: orgId,
onboardingStatus: data.role === 'ADMIN' ? 'APPROVED' : 'PENDING_ONBOARDING'
}
});
const { passwordHash: _, ...userWithoutPassword } = user;
return userWithoutPassword;
return this.getUserById(user.id);
}
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);
let orgId = organizationId || null;
if (!orgId) {
orgId = await this.getOrCreateOrganizationForEmail(email);
}
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({
await prisma.user.create({
data: {
email,
passwordHash: '', // Set on accept
role: 'PARTNER_USER',
organizationId: organizationId || null,
organizationId: orgId,
inviteToken,
inviteTokenExp,
onboardingStatus: 'PENDING_ONBOARDING',
@ -76,8 +105,8 @@ export class AuthService {
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 };
const userWithOrg = await this.getUserById(updatedUser.id);
return { user: userWithOrg, accessToken, refreshToken };
}
public async login(email: string, passwordString: string) {
@ -95,8 +124,8 @@ export class AuthService {
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 };
const userWithOrg = await this.getUserById(user.id);
return { user: userWithOrg, accessToken, refreshToken };
}
public async refresh(refreshToken: string) {
@ -107,7 +136,8 @@ export class AuthService {
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 };
const userWithOrg = await this.getUserById(user.id);
return { accessToken: newAccessToken, user: userWithOrg };
} catch(err) {
throw new AppError('Invalid or expired refresh token', 401);
}
@ -128,4 +158,60 @@ export class AuthService {
orderBy: { createdAt: 'desc' },
});
}
public async getUserById(id: string) {
let user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
email: true,
role: true,
mfaEnabled: true,
organizationId: true,
onboardingStatus: true,
createdAt: true,
updatedAt: true,
organization: {
select: {
id: true,
name: true,
status: true,
}
}
}
});
if (user && user.role === 'PARTNER_USER' && !user.organizationId) {
const orgId = await this.getOrCreateOrganizationForEmail(user.email);
if (orgId) {
await prisma.user.update({
where: { id },
data: { organizationId: orgId }
});
// refetch to get populated organization relation
user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
email: true,
role: true,
mfaEnabled: true,
organizationId: true,
onboardingStatus: true,
createdAt: true,
updatedAt: true,
organization: {
select: {
id: true,
name: true,
status: true,
}
}
}
});
}
}
return user;
}
}

View File

@ -6,6 +6,7 @@ export class LegalService {
type: DocumentType;
version: string;
content: string;
pdfUrl?: string | null;
}) {
// Deprecate older active versions of the same type
if (data.type) {

View File

@ -8,6 +8,13 @@ export class OrganizationService {
public async getAll() {
return await prisma.organization.findMany({
include: {
users: {
select: {
id: true,
email: true,
role: true,
}
},
_count: {
select: { users: true }
}

View File

@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { router } from "./app/router";
import { useEffect } from 'react';
import { useThemeStore } from './hooks/use-theme';
import { useAuthStore } from './hooks/use-auth';
const queryClient = new QueryClient({
defaultOptions: {
@ -12,10 +13,29 @@ const queryClient = new QueryClient({
export const App = () => {
const initTheme = useThemeStore(state => state.initTheme);
const { isInitializing, checkAuth } = useAuthStore();
useEffect(() => {
initTheme();
}, [initTheme]);
checkAuth();
}, [initTheme, checkAuth]);
if (isInitializing) {
return (
<div className="flex h-screen w-full flex-col items-center justify-center bg-ink-50 font-sans text-ink-900 transition-colors duration-500">
<div className="relative flex flex-col items-center gap-6">
<div className="relative">
<div className="w-12 h-12 border-2 border-ink-200 rounded-full"></div>
<div className="absolute inset-0 w-12 h-12 border-2 border-t-ink-900 rounded-full animate-spin"></div>
</div>
<div className="flex flex-col items-center gap-1.5">
<h2 className="text-xs font-bold tracking-[0.2em] text-ink-800 uppercase animate-pulse">Initializing</h2>
<p className="text-[10px] text-ink-400 tracking-wider">Securing connection...</p>
</div>
</div>
</div>
);
}
return (
<QueryClientProvider client={queryClient}>
@ -23,3 +43,4 @@ export const App = () => {
</QueryClientProvider>
);
};
export default App;

View File

@ -1,9 +1,10 @@
import React, { useState } from 'react';
import React, { useState, useEffect } 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';
import { axiosInstance } from '../../services/axios';
export const AdminLayout: React.FC = () => {
const { user, logout } = useAuthStore();
@ -11,35 +12,54 @@ export const AdminLayout: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const [pendingCount, setPendingCount] = useState(0);
const handleLogout = () => {
logout();
navigate('/login');
};
useEffect(() => {
const fetchPendingCount = async () => {
try {
const res = await axiosInstance.get('/legal/pending');
setPendingCount(res.data.length);
} catch (err) {
console.error('Failed to fetch pending count', err);
}
};
fetchPendingCount();
// Poll every 10s for real-time admin indicators
const interval = setInterval(fetchPendingCount, 10000);
return () => clearInterval(interval);
}, []);
const navItems = [
{ name: 'Partners', path: '/admin/partners', icon: Users },
{ name: 'Approvals Queue', path: '/admin/approvals', icon: ClipboardCheck },
{ name: 'Legal Templates', path: '/admin/legal', icon: ShieldCheck },
{ 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">
<div className="min-h-screen flex flex-col md:flex-row bg-ink-50 text-ink-900 font-sans transition-colors duration-500 selection:bg-ink-900/10 overflow-hidden">
{/* ── Desktop Sidebar ── */}
<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">
<aside className="hidden md:flex md:w-[280px] md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20">
{/* Branding */}
<div className="h-24 flex items-center px-8 border-b border-slate-100 dark:border-white/5">
<div className="h-24 flex items-center px-8 border-b border-ink-200">
<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 className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300">
<ShieldCheck className="w-5 h-5 text-ink-0" />
</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>
<span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">Tech4Biz</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Admin Console</span>
</div>
</Link>
</div>
@ -55,43 +75,47 @@ export const AdminLayout: React.FC = () => {
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'
? 'bg-ink-100 text-ink-900 border border-ink-300 shadow-sm'
: 'text-ink-500 hover:text-ink-900 hover:bg-ink-100'
}`}
>
<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'}`} />
<Icon className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? 'text-ink-950' : 'text-ink-400 group-hover:text-ink-900'}`} />
<span>{item.name}</span>
{isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
{item.name === 'Approvals Queue' && pendingCount > 0 && (
<span className="ml-auto flex h-5.5 w-5.5 items-center justify-center rounded-full bg-ink-900 text-[10px] font-extrabold text-ink-0 animate-pulse border border-ink-700 shadow-sm px-1.5 py-0.5">
{pendingCount}
</span>
)}
{isActive && !(item.name === 'Approvals Queue' && pendingCount > 0) && <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="p-5 border-t border-ink-200 bg-ink-50">
<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>
<p className="text-[10px] font-bold text-ink-400 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"
className="p-2 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group"
>
{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">
<div className="flex items-center gap-3 p-3 rounded-2xl bg-ink-0 border border-ink-200 mb-4 shadow-sm">
<div className="w-9 h-9 rounded-full bg-ink-900 flex items-center justify-center text-ink-0 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>
<p className="text-sm font-bold text-ink-900 truncate">{user?.email}</p>
<p className="text-[10px] uppercase font-bold text-ink-500 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"
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 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200"
>
<LogOut className="w-4 h-4" />
<span>Sign Out</span>
@ -100,20 +124,19 @@ export const AdminLayout: React.FC = () => {
</aside>
{/* ── Main Content Area ── */}
<main className="flex-1 flex flex-col relative w-full overflow-y-auto">
<main className="flex-1 flex flex-col relative w-full overflow-y-auto bg-ink-50">
{/* 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" />
<div className="fixed top-0 right-0 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none -z-10" />
{/* Mobile Header */}
<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">
<header className="md:hidden sticky top-0 z-40 h-16 flex items-center justify-between px-4 bg-ink-0 border-b border-ink-200 shadow-sm">
<Link to="/admin" className="flex items-center gap-2">
<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 className="w-8 h-8 rounded-lg flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800">
<ShieldCheck className="w-4 h-4 text-ink-0" />
</div>
<span className="text-sm font-extrabold tracking-tight text-slate-900 dark:text-white">Admin Console</span>
<span className="text-sm font-extrabold tracking-tight text-ink-900">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">
<button onClick={() => setMobileOpen(true)} className="p-2 rounded-lg border border-ink-200 text-ink-600">
<Menu className="w-5 h-5" />
</button>
</header>
@ -122,44 +145,69 @@ export const AdminLayout: React.FC = () => {
<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">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setMobileOpen(false)} className="fixed inset-0 bg-ink-900/40 backdrop-blur-sm z-50 md:hidden" />
<motion.div initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }} transition={{ type: 'spring', damping: 25, stiffness: 200 }} className="fixed right-0 top-0 bottom-0 w-72 bg-ink-0 shadow-2xl z-50 border-l border-ink-200 flex flex-col md:hidden">
<div className="p-4 border-b border-ink-200 flex items-center justify-between">
<span className="font-extrabold text-ink-900">Menu</span>
<button onClick={() => setMobileOpen(false)} className="p-2 rounded-lg bg-ink-100">
<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'}`}>
<Link key={item.path} to={item.path} onClick={() => setMobileOpen(false)} className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold ${location.pathname === item.path ? 'bg-ink-100 text-ink-900 border border-ink-300' : 'text-ink-500 hover:text-ink-900'}`}>
<item.icon className="w-5 h-5" />
{item.name}
<span>{item.name}</span>
{item.name === 'Approvals Queue' && pendingCount > 0 && (
<span className="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-ink-900 text-[10px] font-extrabold text-ink-0">
{pendingCount}
</span>
)}
</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">
<div className="p-4 border-t border-ink-200 space-y-4">
<button onClick={toggleTheme} className="flex items-center justify-between w-full p-3 rounded-xl bg-ink-50 border border-ink-200 text-sm font-bold text-ink-500 hover:text-ink-900">
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>
<button onClick={() => { setMobileOpen(false); handleLogout(); }} className="w-full py-3 rounded-xl bg-red-500/10 text-red-650 font-bold text-sm">Sign Out</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
{/* Dynamic Pending Approvals Banner */}
{pendingCount > 0 && (
<div className="bg-ink-900 text-ink-0 px-6 py-3.5 flex items-center justify-between border-b border-ink-800 shadow-sm relative overflow-hidden shrink-0">
<div className="absolute inset-0 bg-gradient-to-r from-ink-800 via-ink-900 to-ink-800 opacity-50" />
<div className="flex items-center gap-3 relative z-10">
<span className="flex h-2 w-2 rounded-full bg-ink-0 animate-pulse" />
<p className="text-xs font-bold tracking-wide">
Onboarding Queue: {pendingCount} partner{pendingCount > 1 ? 's are' : ' is'} awaiting legal document approval.
</p>
</div>
<Link
to="/admin/approvals"
className="relative z-10 text-[10px] font-extrabold uppercase tracking-widest bg-ink-0 text-ink-900 px-3.5 py-1.5 rounded-lg hover:bg-ink-100 transition-all shadow-sm flex items-center gap-1 shrink-0"
>
<span>Review Now</span>
<ChevronRight className="w-3 h-3" />
</Link>
</div>
)}
<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">
<footer className="border-t border-ink-200 bg-ink-0/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-ink-500">
<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>
<span className="hover:text-ink-900 cursor-pointer transition-colors">Security Compliance</span>
<span className="hover:text-ink-900 cursor-pointer transition-colors">System Status</span>
</div>
</div>
</footer>

View File

@ -24,20 +24,20 @@ export const ClientLayout: React.FC = () => {
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">
<div className="min-h-screen flex flex-col md:flex-row bg-ink-50 text-ink-900 font-sans transition-colors duration-500 selection:bg-ink-900/10 overflow-hidden">
{/* ── Desktop Sidebar ── */}
<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">
<aside className="hidden md:flex md:w-[280px] md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20">
{/* Branding */}
<div className="h-24 flex items-center px-8 border-b border-slate-100 dark:border-white/5">
<div className="h-24 flex items-center px-8 border-b border-ink-200">
<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 className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300">
<ShieldCheck className="w-5 h-5 text-ink-0" />
</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>
<span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">Tech4Biz</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Client Portal</span>
</div>
</Link>
</div>
@ -53,11 +53,11 @@ export const ClientLayout: React.FC = () => {
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'
? 'bg-ink-100 text-ink-900 border border-ink-300 shadow-sm'
: 'text-ink-500 hover:text-ink-900 hover:bg-ink-100'
}`}
>
<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'}`} />
<Icon className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? 'text-ink-950' : 'text-ink-400 group-hover:text-ink-900'}`} />
<span>{item.label}</span>
{isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
</Link>
@ -66,31 +66,31 @@ export const ClientLayout: React.FC = () => {
</nav>
{/* Footer */}
<div className="p-5 border-t border-slate-100 dark:border-white/5 bg-slate-50/50 dark:bg-transparent">
<div className="p-5 border-t border-ink-200 bg-ink-50">
<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>
<p className="text-[10px] font-bold text-ink-400 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"
className="p-2 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group"
>
{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 flex-col gap-2 p-4 rounded-2xl bg-ink-0 border border-ink-200 mb-4 shadow-sm">
<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'}`}>
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-400">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-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400 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>
<p className="text-xs font-bold text-ink-900 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"
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 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200"
>
<LogOut className="w-4 h-4" />
<span>Sign Out</span>
@ -99,20 +99,20 @@ export const ClientLayout: React.FC = () => {
</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]">
<main className="flex-1 flex flex-col relative w-full overflow-y-auto bg-ink-50">
{/* 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" />
<div className="fixed top-0 right-0 w-[500px] h-[500px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none -z-10" />
{/* Mobile Header */}
<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">
<header className="md:hidden sticky top-0 z-40 h-16 flex items-center justify-between px-4 bg-ink-0 border-b border-ink-200 shadow-sm">
<Link to="/client" className="flex items-center gap-2">
<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 className="w-8 h-8 rounded-lg flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800">
<ShieldCheck className="w-4 h-4 text-ink-0" />
</div>
<span className="text-sm font-extrabold tracking-tight text-slate-900 dark:text-white">Client Portal</span>
<span className="text-sm font-extrabold tracking-tight text-ink-900">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">
<button onClick={() => setMobileOpen(true)} className="p-2 rounded-lg border border-ink-200 text-ink-600">
<Menu className="w-5 h-5" />
</button>
</header>
@ -121,27 +121,27 @@ export const ClientLayout: React.FC = () => {
<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">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setMobileOpen(false)} className="fixed inset-0 bg-ink-900/40 backdrop-blur-sm z-50 md:hidden" />
<motion.div initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }} transition={{ type: 'spring', damping: 25, stiffness: 200 }} className="fixed right-0 top-0 bottom-0 w-72 bg-ink-0 shadow-2xl z-50 border-l border-ink-200 flex flex-col md:hidden">
<div className="p-4 border-b border-ink-200 flex items-center justify-between">
<span className="font-extrabold text-ink-900">Menu</span>
<button onClick={() => setMobileOpen(false)} className="p-2 rounded-lg bg-ink-100">
<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'}`}>
<Link key={item.path} to={item.path} onClick={() => setMobileOpen(false)} className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold ${location.pathname === item.path ? 'bg-ink-100 text-ink-900 border border-ink-300' : 'text-ink-650'}`}>
<item.icon className="w-5 h-5" />
{item.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">
<div className="p-4 border-t border-ink-200 space-y-4">
<button onClick={toggleTheme} className="flex items-center justify-between w-full p-3 rounded-xl bg-ink-50 border border-ink-200 text-sm font-bold text-ink-600">
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>
<button onClick={() => { setMobileOpen(false); handleLogout(); }} className="w-full py-3 rounded-xl bg-red-500/10 text-red-600 font-bold text-sm">Sign Out</button>
</div>
</motion.div>
</>
@ -153,12 +153,12 @@ export const ClientLayout: React.FC = () => {
</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">
<footer className="border-t border-ink-200 bg-ink-0/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-ink-500">
<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>
<span className="hover:text-ink-900 cursor-pointer transition-colors">Security</span>
<span className="hover:text-ink-900 cursor-pointer transition-colors">Terms</span>
</div>
</div>
</footer>

View File

@ -14,11 +14,12 @@ const AssetsPage = React.lazy(() => import('../../pages/AssetsPage').then(m => (
const ApprovalsPage = React.lazy(() => import('../../pages/admin/ApprovalsPage').then(m => ({ default: m.ApprovalsPage })));
const DirectoryPage = React.lazy(() => import('../../pages/admin/DirectoryPage').then(m => ({ default: m.DirectoryPage })));
const OnboardingPage = React.lazy(() => import('../../pages/OnboardingPage').then(m => ({ default: m.OnboardingPage })));
const LegalTemplatesPage = React.lazy(() => import('../../pages/admin/LegalTemplatesPage').then(m => ({ default: m.LegalTemplatesPage })));
// 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 className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin"></div>
</div>
);
@ -125,7 +126,11 @@ export const router = createBrowserRouter([
},
{
path: 'legal',
element: <LegalPage />,
element: (
<Suspense fallback={<LoadingFallback />}>
<LegalTemplatesPage />
</Suspense>
),
},
{
path: 'analytics',

View File

@ -20,19 +20,19 @@ export const MainLayout = () => {
];
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">
<div className="flex h-screen bg-ink-50 text-ink-900 overflow-hidden font-sans 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"
className="w-72 bg-ink-0 border-r border-ink-200 flex flex-col relative z-20"
>
<div className="h-24 flex items-center px-8 border-b border-slate-200 dark:border-white/5">
<div className="h-24 flex items-center px-8 border-b border-ink-200">
<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 className="relative flex items-center justify-center w-11 h-11 rounded-2xl bg-gradient-to-tr from-ink-900 to-ink-800 shadow-lg">
<Hexagon className="text-ink-0 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>
<span className="font-extrabold text-xl tracking-tight text-ink-900">Tech4Biz</span>
</div>
</div>
@ -41,8 +41,8 @@ export const MainLayout = () => {
<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)]' }}
className="flex items-center gap-4 px-4 py-3.5 rounded-xl transition-all duration-300 text-ink-500 hover:text-ink-900 hover:bg-ink-100 group"
activeProps={{ className: 'bg-ink-100 text-ink-900 border border-ink-300 shadow-sm' }}
activeOptions={{ exact: item.path === '/' }}
>
<item.icon className="w-5 h-5 transition-transform group-hover:scale-110" />
@ -51,23 +51,23 @@ export const MainLayout = () => {
))}
</nav>
<div className="p-5 border-t border-slate-200 dark:border-white/5 bg-slate-50 dark:bg-[#0A0A0A]">
<div className="p-5 border-t border-ink-200 bg-ink-50">
<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>
<p className="text-[10px] font-bold text-ink-400 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"
className="p-2 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group"
>
{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">
<div className="flex items-center gap-4 p-3.5 rounded-2xl bg-ink-0 border border-ink-200 mb-4 shadow-sm">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 text-sm font-bold shadow-lg">
{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>
<p className="text-sm font-bold text-ink-900 truncate">{user?.email}</p>
<p className="text-xs text-ink-500 font-medium truncate tracking-wide">{user?.role}</p>
</div>
</div>
<button
@ -75,7 +75,7 @@ export const MainLayout = () => {
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"
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 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200"
>
<LogOut className="w-4 h-4" />
SECURE LOGOUT
@ -84,10 +84,10 @@ export const MainLayout = () => {
</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" />
<main className="flex-1 flex flex-col relative overflow-hidden bg-ink-50 transition-colors duration-500">
{/* Soft Monochromatic Accent Glows */}
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" />
<div className="absolute bottom-0 left-1/4 w-[400px] h-[400px] bg-ink-900/5 rounded-full blur-[120px] pointer-events-none" />
<div className="flex-1 overflow-y-auto p-10 relative z-10">
<Outlet />
@ -96,3 +96,4 @@ export const MainLayout = () => {
</div>
);
};
export default MainLayout;

View File

@ -37,12 +37,12 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
: "[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">
<div className="flex flex-col rounded-xl border border-ink-200 bg-ink-50 shadow-premium overflow-hidden transition-premium w-full text-ink-900">
{/* 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 flex-wrap items-center justify-between border-b border-ink-200 bg-ink-0 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 className="flex h-10 w-10 items-center justify-center rounded-lg bg-ink-50 border border-ink-200">
<FileText className="h-5 w-5 text-ink-900" />
</div>
<div>
<span className="block text-sm font-bold text-ink-800">
@ -56,16 +56,16 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
</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">
<div className="hidden sm:flex items-center gap-1.5 bg-emerald-500/10 border border-emerald-500/20 px-3 py-1 rounded-full text-xs font-semibold text-emerald-600 dark:text-emerald-400">
<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">
<div className="flex items-center gap-1.5 bg-ink-50 rounded-lg p-1 border border-ink-200">
<button
type="button"
onClick={handleZoomOut}
className="rounded p-1.5 hover:bg-white text-ink-600 hover:text-ink-800 transition-colors cursor-pointer"
className="rounded p-1.5 hover:bg-ink-0 text-ink-600 hover:text-ink-800 transition-colors cursor-pointer"
title="Zoom Out"
>
<ZoomOut className="h-4 w-4" />
@ -76,7 +76,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
<button
type="button"
onClick={handleZoomIn}
className="rounded p-1.5 hover:bg-white text-ink-600 hover:text-ink-800 transition-colors cursor-pointer"
className="rounded p-1.5 hover:bg-ink-0 text-ink-600 hover:text-ink-800 transition-colors cursor-pointer"
title="Zoom In"
>
<ZoomIn className="h-4 w-4" />
@ -84,7 +84,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
<button
type="button"
onClick={handleZoomReset}
className="rounded p-1.5 hover:bg-white text-ink-600 hover:text-ink-800 transition-colors cursor-pointer"
className="rounded p-1.5 hover:bg-ink-0 text-ink-600 hover:text-ink-800 transition-colors cursor-pointer"
title="Reset Zoom"
>
<RotateCcw className="h-4 w-4" />
@ -96,7 +96,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
{/* 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"
className="bg-white p-12 md:p-16 shadow-lg border border-ink-250 rounded-md text-sm leading-relaxed text-ink-800 font-serif origin-top transition-all duration-200 w-full h-fit self-start"
style={{
maxWidth: "780px",
transform: `scale(${zoom / 100})`,
@ -105,13 +105,13 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
}}
>
{/* 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">
<div className="text-center mb-10 pb-6 border-b-2 border-ink-950">
<h1 className="text-xl md:text-2xl font-bold uppercase tracking-wider text-ink-900 font-sans mb-2">
{documentType === "NDA"
? "Mutual Non-Disclosure Agreement"
: "Master Services Agreement"}
</h1>
<p className="text-xs uppercase tracking-widest text-slate-500 font-sans font-bold">
<p className="text-xs uppercase tracking-widest text-ink-500 font-sans font-bold">
Tech4Biz Technology Integration Portal
</p>
</div>
@ -125,7 +125,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
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">
<div className="space-y-3 pl-6 border-l-2 border-ink-200 py-1 font-sans text-xs text-ink-700">
<p>
<strong>TECH4BIZ SOLUTIONS INC.</strong>, a corporation
organized and existing under the laws of Delaware, with its
@ -143,7 +143,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
</p>
</div>
<p className="italic text-xs border-y border-slate-100 py-2">
<p className="italic text-xs border-y border-ink-200 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
@ -158,7 +158,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
</p>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
<h3 className="font-sans font-bold text-ink-900 text-xs tracking-wider uppercase mb-1">
1. Definition of Confidential Information
</h3>
<p>
@ -174,7 +174,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
</div>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
<h3 className="font-sans font-bold text-ink-900 text-xs tracking-wider uppercase mb-1">
2. Obligations of Confidentiality and Non-Use
</h3>
<p>
@ -190,7 +190,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
</div>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
<h3 className="font-sans font-bold text-ink-900 text-xs tracking-wider uppercase mb-1">
3. Permitted Disclosures
</h3>
<p>
@ -203,7 +203,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
</div>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
<h3 className="font-sans font-bold text-ink-900 text-xs tracking-wider uppercase mb-1">
4. Term and Survival
</h3>
<p>
@ -216,7 +216,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
</div>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
<h3 className="font-sans font-bold text-ink-900 text-xs tracking-wider uppercase mb-1">
5. Governing Law and Jurisdiction
</h3>
<p>
@ -237,7 +237,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
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">
<div className="space-y-3 pl-6 border-l-2 border-ink-200 py-1 font-sans text-xs text-ink-700">
<p>
<strong>TECH4BIZ SOLUTIONS INC.</strong>, a Delaware
corporation, with its principal office at 100 Innovation Way,
@ -252,7 +252,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
</p>
</div>
<p className="italic text-xs border-y border-slate-100 py-2">
<p className="italic text-xs border-y border-ink-200 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
@ -265,7 +265,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
</p>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
<h3 className="font-sans font-bold text-ink-900 text-xs tracking-wider uppercase mb-1">
1. Scope of Work and Deliverables
</h3>
<p>
@ -280,7 +280,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
</div>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
<h3 className="font-sans font-bold text-ink-900 text-xs tracking-wider uppercase mb-1">
2. Intellectual Property and Licensing
</h3>
<p>
@ -295,7 +295,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
</div>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
<h3 className="font-sans font-bold text-ink-900 text-xs tracking-wider uppercase mb-1">
3. Payment Terms and Financial Covenants
</h3>
<p>
@ -308,7 +308,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
</div>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
<h3 className="font-sans font-bold text-ink-900 text-xs tracking-wider uppercase mb-1">
4. Limitation of Liability
</h3>
<p>
@ -321,7 +321,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
</div>
<div>
<h3 className="font-sans font-bold text-slate-900 text-xs tracking-wider uppercase mb-1">
<h3 className="font-sans font-bold text-ink-900 text-xs tracking-wider uppercase mb-1">
5. Confidentiality
</h3>
<p>
@ -336,22 +336,22 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
)}
{/* 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">
<div className="mt-14 pt-8 border-t border-ink-300">
<p className="text-xs uppercase text-ink-500 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">
<p className="font-bold text-ink-900 border-b border-ink-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">
<div className="h-16 flex items-end pb-1 border-b border-ink-300 relative">
<span className="font-serif italic text-base text-ink-950 select-none pb-1">
Yasha Khandelwal{" "}
</span>
</div>
<div className="space-y-1 text-slate-600 text-[11px]">
<div className="space-y-1 text-ink-600 text-[11px]">
<p>
<strong>Name:</strong> Yasha Khandelwal
</p>
@ -365,23 +365,23 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
</div>
<div className="space-y-4">
<p className="font-bold text-slate-900 border-b border-slate-200 pb-1 uppercase tracking-wider">
<p className="font-bold text-ink-900 border-b border-ink-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">
<div className="h-16 flex items-end justify-center pb-1 border-b border-ink-300 relative overflow-hidden">
{signatureDataUrl ? (
<img
src={signatureDataUrl}
alt="Client Signature"
className="max-h-14 object-contain mix-blend-multiply pb-1 animate-premium"
className="max-h-14 object-contain mix-blend-multiply pb-1"
/>
) : (
<span className="text-[11px] italic text-slate-400 pb-2">
<span className="text-[11px] italic text-ink-400 pb-2">
[Awaiting Signature]
</span>
)}
</div>
<div className="space-y-1 text-slate-600 text-[11px]">
<div className="space-y-1 text-ink-600 text-[11px]">
<p>
<strong>Name:</strong> Authorized Representative
</p>
@ -400,3 +400,4 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
</div>
);
};
export default DocumentPreview;

View File

@ -24,24 +24,33 @@ export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Set styling for the signature line
ctx.strokeStyle = '#050505'; // slate-900 or dark ink
// Detect theme for stroke color
const isDark = document.documentElement.classList.contains('dark');
ctx.strokeStyle = isDark ? '#fafafa' : '#09090b';
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]);
// Adjust stroke color if theme toggles during active view
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const isDark = document.documentElement.classList.contains('dark');
ctx.strokeStyle = isDark ? '#fafafa' : '#09090b';
});
const startDrawing = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault();
const canvas = canvasRef.current;
@ -111,19 +120,18 @@ export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
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">
<div className="relative rounded-2xl bg-ink-50 border-2 border-dashed border-ink-200 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">
<p className="text-ink-400 font-medium text-sm">
Draw your signature here
</p>
</div>
@ -148,7 +156,7 @@ export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
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"
className="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-ink-500 hover:text-red-600 hover:bg-red-500/10 rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Eraser className="w-4 h-4" />
Clear
@ -158,7 +166,7 @@ export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
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"
className="flex items-center gap-2 px-6 py-2.5 text-sm font-bold text-ink-0 bg-ink-900 hover:bg-ink-800 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

View File

@ -78,11 +78,9 @@ export const AssetExplorer: React.FC = () => {
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);
@ -92,7 +90,6 @@ export const AssetExplorer: React.FC = () => {
}
};
// Helper icons mapping
const getCategoryIcon = (catId: string) => {
switch (catId) {
case 'silicon': return <Cpu className="h-5 w-5" />;
@ -106,33 +103,33 @@ export const AssetExplorer: React.FC = () => {
const currentCategory = categories.find(c => c.id === selectedCategory);
return (
<div className="space-y-6">
<div className="space-y-6 text-ink-900">
{/* 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" />
<div className="fixed bottom-5 right-5 z-50 rounded-xl bg-ink-900 text-ink-0 px-5 py-3 text-sm shadow-premium flex items-center gap-2 border border-ink-700 animate-slide-up">
<Download className="h-4 w-4 text-ink-0 animate-bounce" />
<span className="font-semibold text-ink-50">{toastMessage}</span>
</div>
)}
{/* Hero section */}
<div className="rounded-2xl bg-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="rounded-2xl bg-ink-0 p-6 md:p-8 border border-ink-200 flex flex-col md:flex-row items-center justify-between gap-6">
<div className="space-y-2">
<h1 className="text-2xl font-bold text-ink-800 md:text-3xl">Tech4Biz Asset Explorer</h1>
<h1 className="text-2xl font-bold text-ink-900 md:text-3xl">Tech4Biz Asset Explorer</h1>
<p className="text-sm 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>
<div className="bg-ink-50 rounded-xl border border-ink-200 p-4 text-center min-w-[6.5rem]">
<p className="text-2xl font-bold text-ink-900">{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">
<div className="space-y-4 rounded-xl border border-ink-200 bg-ink-0 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">
@ -142,7 +139,7 @@ export const AssetExplorer: React.FC = () => {
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"
className="w-full rounded-lg border border-ink-200 pl-10 pr-4 py-2 text-sm focus:border-ink-900/50 focus:outline-none transition-colors bg-ink-50 text-ink-900 placeholder-ink-400"
/>
</div>
{currentCategory && currentCategory.subcategories.length > 0 && (
@ -150,7 +147,7 @@ export const AssetExplorer: React.FC = () => {
<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"
className="w-full rounded-lg border border-ink-200 bg-ink-50 px-3 py-2 text-sm focus:border-ink-900/50 focus:outline-none text-ink-900"
>
<option value="all">All Subcategories</option>
{currentCategory.subcategories.map(sub => (
@ -170,8 +167,8 @@ export const AssetExplorer: React.FC = () => {
}}
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'
? 'bg-ink-900 text-ink-0 shadow-sm'
: 'bg-ink-50 text-ink-700 border border-ink-200 hover:bg-ink-100'
}`}
>
All Resources
@ -185,8 +182,8 @@ export const AssetExplorer: React.FC = () => {
}}
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'
? 'bg-ink-900 text-ink-0 shadow-sm'
: 'bg-ink-50 text-ink-700 border border-ink-200 hover:bg-ink-100'
}`}
>
{getCategoryIcon(cat.id)}
@ -200,7 +197,7 @@ export const AssetExplorer: React.FC = () => {
{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 key={n} className="animate-pulse rounded-xl border border-ink-200 bg-ink-0 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" />
@ -209,7 +206,7 @@ export const AssetExplorer: React.FC = () => {
))}
</div>
) : assets.length === 0 ? (
<div className="rounded-xl border border-ink-100 bg-white p-12 text-center">
<div className="rounded-xl border border-ink-200 bg-ink-0 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>
@ -218,7 +215,7 @@ export const AssetExplorer: React.FC = () => {
{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"
className="group rounded-xl border border-ink-200 bg-ink-0 shadow-sm overflow-hidden flex flex-col hover:border-ink-400 transition-all duration-300"
>
{/* Card Image banner */}
<div className="h-44 w-full relative overflow-hidden bg-ink-100">
@ -227,7 +224,7 @@ export const AssetExplorer: React.FC = () => {
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">
<span className="absolute left-3 top-3 rounded-full bg-ink-0/90 backdrop-blur px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-ink-900 shadow-sm border border-ink-200 flex items-center gap-1.5">
{getCategoryIcon(asset.categoryId)}
{asset.subcategory}
</span>
@ -236,7 +233,7 @@ export const AssetExplorer: React.FC = () => {
{/* 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">
<h3 className="text-base font-bold text-ink-800 group-hover:text-ink-900 transition-colors line-clamp-1">
{asset.title}
</h3>
<p className="text-xs text-ink-600 line-clamp-2 leading-relaxed">
@ -248,17 +245,17 @@ export const AssetExplorer: React.FC = () => {
{/* 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">
<span key={tag} className="rounded bg-ink-50 px-2 py-0.5 text-[9px] font-semibold text-ink-700 border border-ink-200">
#{tag}
</span>
))}
</div>
{/* Actions */}
<div className="flex items-center justify-between border-t border-ink-50 pt-3">
<div className="flex items-center justify-between border-t border-ink-100 pt-3">
<button
onClick={() => setSelectedAsset(asset)}
className="text-xs font-bold text-ink-800 hover:text-primary-700 transition-colors"
className="text-xs font-bold text-ink-800 hover:text-ink-950 transition-colors"
>
View Details
</button>
@ -268,7 +265,7 @@ export const AssetExplorer: React.FC = () => {
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"
className="rounded-lg border border-ink-200 p-2 text-ink-600 hover:text-ink-800 hover:bg-ink-50 transition-colors"
title="Open Repository"
>
<GithubIcon className="h-4 w-4" />
@ -276,7 +273,7 @@ export const AssetExplorer: React.FC = () => {
)}
<button
onClick={() => handleDownload(asset)}
className="rounded-lg bg-primary-400 p-2 text-ink-800 hover:bg-primary-300 transition-colors shadow-sm"
className="rounded-lg bg-ink-900 p-2 text-ink-0 hover:bg-ink-800 transition-colors shadow-sm"
title="Download Asset"
>
<Download className="h-4 w-4" />
@ -292,8 +289,8 @@ export const AssetExplorer: React.FC = () => {
{/* 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">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/20 backdrop-blur-sm p-4 animate-fade-in">
<div className="w-full max-w-2xl rounded-2xl border border-ink-200 bg-ink-0 p-6 shadow-xl max-h-[90vh] overflow-y-auto space-y-6 relative animate-scale-up">
<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"
@ -308,7 +305,7 @@ export const AssetExplorer: React.FC = () => {
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">
<span className="absolute left-4 top-4 rounded-full bg-ink-0/95 px-3 py-1.5 text-xs font-bold uppercase tracking-wider text-ink-900 shadow-sm border border-ink-200 flex items-center gap-1.5">
{getCategoryIcon(selectedAsset.categoryId)}
{selectedAsset.subcategory}
</span>
@ -336,7 +333,7 @@ export const AssetExplorer: React.FC = () => {
{/* 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">
<p className="text-sm text-ink-700 leading-relaxed bg-ink-50 p-4 rounded-lg border border-ink-200">
{selectedAsset.description}
</p>
</div>
@ -348,7 +345,7 @@ export const AssetExplorer: React.FC = () => {
</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">
<span key={tag} className="rounded bg-ink-50 px-2.5 py-1 text-xs font-semibold text-ink-700 border border-ink-200">
{tag}
</span>
))}
@ -362,7 +359,7 @@ export const AssetExplorer: React.FC = () => {
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"
className="flex items-center gap-2 rounded-lg border border-ink-200 bg-ink-0 px-4 py-2 text-sm font-semibold text-ink-700 hover:bg-ink-50 transition-colors"
>
<GithubIcon className="h-4 w-4" />
Repository URL
@ -370,7 +367,7 @@ export const AssetExplorer: React.FC = () => {
)}
<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"
className="flex items-center gap-2 rounded-lg bg-ink-900 px-5 py-2 text-sm font-semibold text-ink-0 hover:bg-ink-800 transition-colors"
>
<Download className="h-4 w-4" />
Download Files
@ -382,3 +379,4 @@ export const AssetExplorer: React.FC = () => {
</div>
);
};
export default AssetExplorer;

View File

@ -74,7 +74,7 @@ export const BlogCatalog: React.FC = () => {
};
return (
<div className="space-y-6">
<div className="space-y-6 text-ink-900">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-ink-800">Engineering Blog & Insights</h2>
@ -83,7 +83,7 @@ export const BlogCatalog: React.FC = () => {
{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"
className="flex items-center gap-1.5 rounded-lg bg-ink-900 px-4 py-2.5 text-xs font-semibold text-ink-0 hover:bg-ink-800 transition-all shadow-sm"
>
<Plus className="h-4 w-4" />
Write Post
@ -94,7 +94,7 @@ export const BlogCatalog: React.FC = () => {
{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 key={n} className="animate-pulse rounded-xl border border-ink-200 bg-ink-0 p-5 space-y-4">
<div className="h-48 rounded-lg bg-ink-100" />
<div className="h-4 w-3/4 rounded bg-ink-100" />
<div className="h-20 rounded bg-ink-100" />
@ -102,7 +102,7 @@ export const BlogCatalog: React.FC = () => {
))}
</div>
) : posts.length === 0 ? (
<div className="rounded-xl border border-ink-100 bg-white p-12 text-center">
<div className="rounded-xl border border-ink-200 bg-ink-0 p-12 text-center">
<BookOpen className="h-8 w-8 text-ink-300 mx-auto mb-2" />
<p className="text-sm text-ink-600">No blog posts published yet.</p>
</div>
@ -111,7 +111,7 @@ export const BlogCatalog: React.FC = () => {
{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"
className="rounded-xl border border-ink-200 bg-ink-0 shadow-sm overflow-hidden flex flex-col hover:border-ink-400 transition-all duration-300"
>
<div className="h-48 w-full bg-ink-100 relative">
<img
@ -121,7 +121,7 @@ export const BlogCatalog: React.FC = () => {
/>
{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 === 'published' ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20' : 'bg-ink-100 text-ink-700 border-ink-200'
}`}>
{post.status}
</span>
@ -146,9 +146,9 @@ export const BlogCatalog: React.FC = () => {
<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">
<div className="flex flex-wrap gap-1.5 pt-2 border-t border-ink-100">
{post.tags.map(t => (
<span key={t} className="rounded bg-ink-50 px-2 py-0.5 text-[9px] font-semibold text-ink-700 border border-ink-100">
<span key={t} className="rounded bg-ink-50 px-2 py-0.5 text-[9px] font-semibold text-ink-700 border border-ink-200">
#{t}
</span>
))}
@ -161,8 +161,8 @@ export const BlogCatalog: React.FC = () => {
{/* 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">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/20 backdrop-blur-sm p-4 animate-fade-in">
<div className="w-full max-w-lg rounded-2xl border border-ink-200 bg-ink-0 p-6 shadow-xl max-h-[90vh] overflow-y-auto relative animate-scale-up">
<button
onClick={() => setIsOpen(false)}
className="absolute right-4 top-4 rounded-full p-1.5 text-ink-600 hover:bg-ink-100 hover:text-ink-800 transition-colors"
@ -172,14 +172,14 @@ export const BlogCatalog: React.FC = () => {
<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" />
<Sparkles className="h-5 w-5 text-ink-900" />
Write Blog Article
</h3>
<p className="text-xs text-ink-600">Draft or publish a technical write-up for the developer channel.</p>
</div>
{formError && (
<div className="mb-4 rounded-lg bg-danger/5 border border-danger/20 p-3 text-xs font-semibold text-danger">
<div className="mb-4 rounded-lg bg-red-500/10 border border-red-500/20 p-3 text-xs font-semibold text-red-600 dark:text-red-400">
{formError}
</div>
)}
@ -192,7 +192,7 @@ export const BlogCatalog: React.FC = () => {
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"
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
required
/>
</div>
@ -204,7 +204,7 @@ export const BlogCatalog: React.FC = () => {
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"
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none resize-y bg-ink-50 text-ink-900 placeholder-ink-400"
required
/>
</div>
@ -217,7 +217,7 @@ export const BlogCatalog: React.FC = () => {
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"
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
/>
</div>
@ -228,7 +228,7 @@ export const BlogCatalog: React.FC = () => {
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"
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
/>
</div>
</div>
@ -242,7 +242,7 @@ export const BlogCatalog: React.FC = () => {
name="blogStatus"
checked={status === 'draft'}
onChange={() => setStatus('draft')}
className="text-primary-500 focus:ring-primary-500"
className="text-ink-900 focus:ring-ink-900/20"
/>
Draft
</label>
@ -252,7 +252,7 @@ export const BlogCatalog: React.FC = () => {
name="blogStatus"
checked={status === 'published'}
onChange={() => setStatus('published')}
className="text-primary-500 focus:ring-primary-500"
className="text-ink-900 focus:ring-ink-900/20"
/>
Published
</label>
@ -263,14 +263,14 @@ export const BlogCatalog: React.FC = () => {
<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"
className="rounded-lg border border-ink-200 bg-ink-0 px-4 py-2 text-sm font-semibold text-ink-600 hover:bg-ink-50"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-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"
className="rounded-lg bg-ink-900 px-5 py-2 text-sm font-semibold text-ink-0 hover:bg-ink-800 flex items-center gap-1.5"
>
<Send className="h-4 w-4" />
{submitting ? 'Publishing...' : 'Publish Article'}
@ -283,3 +283,4 @@ export const BlogCatalog: React.FC = () => {
</div>
);
};
export default BlogCatalog;

View File

@ -1,28 +1,87 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { User, AuthResponse } from '../types/auth';
import { refreshAuthToken } from '../services/auth-api';
import { axiosInstance } from '../services/axios';
interface AuthState {
user: User | null;
isAuthenticated: boolean;
accessToken: string | null;
isInitializing: boolean;
setAuth: (data: AuthResponse) => void;
logout: () => void;
checkAuth: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
isAuthenticated: false,
accessToken: null,
isInitializing: true,
setAuth: (data) => set({
user: data.user,
accessToken: data.accessToken,
isAuthenticated: true
isAuthenticated: true,
isInitializing: false,
}),
logout: () => {
logout: () => set({
user: null,
accessToken: null,
isAuthenticated: false,
isInitializing: false,
}),
checkAuth: async () => {
// If we have an existing token, validate it by fetching current user details
const state = get();
if (state.accessToken && state.user) {
try {
const res = await axiosInstance.get('/auth/me');
set({
user: res.data,
isAuthenticated: true,
isInitializing: false,
});
return;
} catch (err: any) {
console.error('Session validation failed, trying refresh token...', err);
// If it failed due to network error and not 401/403, we don't clear the session immediately
if (err.response && err.response.status !== 401 && err.response.status !== 403) {
set({ isInitializing: false });
return;
}
}
}
// Try refreshing token
try {
const data = await refreshAuthToken();
set({
user: data.user,
accessToken: data.accessToken,
isAuthenticated: true,
isInitializing: false,
});
} catch (err) {
set({
user: null,
accessToken: null,
isAuthenticated: false
isAuthenticated: false,
isInitializing: false,
});
}
}));
}
}),
{
name: 't4b_auth_store',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
isAuthenticated: state.isAuthenticated,
}),
}
)
);

View File

@ -1,41 +1,43 @@
@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
@custom-variant dark (&:is(.dark *));
/* ============================================================
TECH4BIZ DESIGN SYSTEM MONOCHROME PREMIUM
Brand Primary: #18181B (Zinc / Charcoal)
Philosophy: Clean contrast, neutral slate variables, fluid transitions
============================================================ */
@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;
/* ── Brand Primary — Zinc / Charcoal ── */
--color-primary-50: #fafafa;
--color-primary-100: #f4f4f5;
--color-primary-200: #e4e4e7;
--color-primary-300: #d4d4d8;
--color-primary-400: #a1a1aa;
--color-primary-500: #71717a;
--color-primary-600: #52525b;
--color-primary-700: #3f3f46;
--color-primary-800: #27272a;
--color-primary-900: #18181b;
/* ── 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;
/* ── Ink Neutrals (Zinc Grays) ── */
--color-ink-0: #ffffff;
--color-ink-50: #fafafa;
--color-ink-100: #f4f4f5;
--color-ink-200: #e4e4e7;
--color-ink-300: #d4d4d8;
--color-ink-400: #a1a1aa;
--color-ink-500: #71717a;
--color-ink-600: #52525b;
--color-ink-700: #3f3f46;
--color-ink-800: #27272a;
--color-ink-900: #09090b;
/* ── Semantic Colors ── */
--color-success: #3FAE5C;
--color-warning: #E8A93F;
--color-danger: #E5484D;
--color-info: #4C8DF0;
--color-success: #10b981;
--color-warning: #f59e0b;
--color-danger: #ef4444;
--color-info: #3b82f6;
/* ── Typography ── */
--font-sans: 'Outfit', 'Inter', ui-sans-serif, system-ui, sans-serif;
@ -46,14 +48,14 @@
--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);
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.05);
--shadow-premium: 0 4px 20px -2px rgba(0, 0, 0, 0.05), 0 2px 8px -1px rgba(0, 0, 0, 0.03);
--shadow-glow: 0 0 30px rgba(113, 113, 122, 0.1), 0 0 60px rgba(113, 113, 122, 0.03);
--shadow-glow-sm: 0 0 15px rgba(113, 113, 122, 0.05);
--shadow-inner-glow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
--shadow-3d: 0 20px 40px -10px rgba(0, 0, 0, 0.1);
/* ── Border Radius ── */
--radius-2xl: 1rem;
@ -66,10 +68,10 @@
--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-fade-in: fade-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
--animate-scale-up: scale-up 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
--animate-slide-up: slide-up 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
--animate-slide-right: slide-right 0.35s 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;
@ -77,36 +79,36 @@
/* ── Keyframes ── */
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); }
33% { transform: translateY(-12px) rotate(1deg); }
66% { transform: translateY(-6px) rotate(-1deg); }
33% { transform: translateY(-8px) rotate(0.5deg); }
66% { transform: translateY(-4px) rotate(-0.5deg); }
}
@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); }
0%, 100% { box-shadow: 0 0 15px rgba(113, 113, 122, 0.1); }
50% { box-shadow: 0 0 30px rgba(113, 113, 122, 0.2); }
}
@keyframes shimmer {
from { background-position: -200% center; }
to { background-position: 200% center; }
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(12px); }
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scale-up {
from { opacity: 0; transform: scale(0.92) translateY(8px); }
from { opacity: 0; transform: scale(0.96) translateY(4px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(20px); }
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slide-right {
from { opacity: 0; transform: translateX(-20px); }
from { opacity: 0; transform: translateX(-12px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes orbit {
from { transform: rotate(0deg) translateX(120px) rotate(0deg); }
to { transform: rotate(360deg) translateX(120px) rotate(-360deg); }
from { transform: rotate(0deg) translateX(100px) rotate(0deg); }
to { transform: rotate(360deg) translateX(100px) rotate(-360deg); }
}
@keyframes morph {
0%, 100% { border-radius: 40% 60% 70% 30% / 40% 50% 60% 50%; }
@ -119,6 +121,58 @@
}
}
:root {
/* Gentle Slate theme for Light Mode - Softened Contrast */
--color-primary-50: #f8fafc;
--color-primary-100: #f1f5f9;
--color-primary-200: #e2e8f0;
--color-primary-300: #cbd5e1;
--color-primary-400: #94a3b8;
--color-primary-500: #64748b;
--color-primary-600: #475569;
--color-primary-700: #334155;
--color-primary-800: #1e293b;
--color-primary-900: #0f172a;
--color-ink-0: #ffffff;
--color-ink-50: #f8fafc;
--color-ink-100: #f1f5f9;
--color-ink-200: #e2e8f0;
--color-ink-300: #cbd5e1;
--color-ink-400: #94a3b8;
--color-ink-500: #64748b;
--color-ink-600: #475569;
--color-ink-700: #334155;
--color-ink-800: #1e293b;
--color-ink-900: #0f172a;
}
.dark {
/* Obsidian/Charcoal theme for Dark Mode - Correctly Inverted Scales */
--color-primary-50: #050507;
--color-primary-100: #141417;
--color-primary-200: #27272a;
--color-primary-300: #3f3f46;
--color-primary-400: #52525b;
--color-primary-500: #71717a;
--color-primary-600: #a1a1aa;
--color-primary-700: #d4d4d8;
--color-primary-800: #e4e4e7;
--color-primary-900: #ffffff;
--color-ink-0: #0d0d10; /* Soft charcoal-slate card background */
--color-ink-50: #050507; /* Deep obsidian canvas background */
--color-ink-100: #141417; /* Panels/hover states */
--color-ink-200: #27272a; /* Borders */
--color-ink-300: #3f3f46;
--color-ink-400: #52525b;
--color-ink-500: #71717a; /* Muted texts */
--color-ink-600: #a1a1aa;
--color-ink-700: #d4d4d8;
--color-ink-800: #e4e4e7; /* Body texts */
--color-ink-900: #ffffff; /* Primary texts/headers */
}
/* ============================================================
BASE STYLES
============================================================ */
@ -143,12 +197,12 @@ body {
/* 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); }
::-webkit-scrollbar-thumb { background: var(--color-ink-200); border-radius: 9999px; }
::-webkit-scrollbar-thumb:hover { background: var(--color-ink-400); }
/* Focus ring */
:focus-visible {
outline: 2px solid var(--color-primary-400);
outline: 2px solid var(--color-ink-900);
outline-offset: 2px;
border-radius: 6px;
}
@ -167,56 +221,56 @@ body {
transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.card-3d:hover {
transform: rotateY(-4deg) rotateX(2deg) translateZ(8px);
transform: rotateY(-2deg) rotateX(1deg) translateZ(4px);
}
/* ============================================================
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);
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(228, 228, 231, 0.5);
}
.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);
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(228, 228, 231, 0.8);
}
.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);
background: rgba(9, 9, 11, 0.75);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(39, 39, 42, 0.4);
}
/* ============================================================
GRADIENT UTILITIES
============================================================ */
.gradient-brand {
background: linear-gradient(135deg, var(--color-primary-300) 0%, var(--color-primary-500) 100%);
background: linear-gradient(135deg, var(--color-ink-800) 0%, var(--color-ink-900) 100%);
}
.gradient-brand-soft {
background: linear-gradient(135deg, var(--color-primary-50) 0%, var(--color-primary-100) 100%);
background: linear-gradient(135deg, var(--color-ink-50) 0%, var(--color-ink-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%),
radial-gradient(at 40% 20%, rgba(113, 113, 122, 0.05) 0px, transparent 50%),
radial-gradient(at 80% 0%, rgba(82, 82, 91, 0.03) 0px, transparent 50%),
radial-gradient(at 0% 50%, rgba(113, 113, 122, 0.04) 0px, transparent 50%),
radial-gradient(at 80% 50%, rgba(39, 39, 42, 0.02) 0px, transparent 50%),
radial-gradient(at 0% 100%, rgba(113, 113, 122, 0.05) 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: linear-gradient(135deg, var(--color-ink-900), var(--color-ink-600), var(--color-ink-800));
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
@ -225,11 +279,11 @@ body {
.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%
var(--color-ink-600) 0%,
var(--color-ink-800) 25%,
var(--color-ink-600) 50%,
var(--color-ink-800) 75%,
var(--color-ink-600) 100%
);
background-size: 200% auto;
background-clip: text;
@ -242,54 +296,43 @@ body {
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;
background: var(--color-ink-900);
color: var(--color-ink-0);
font-weight: 550;
padding: 0.625rem 1.25rem;
border-radius: 0.625rem;
border: none;
border-radius: 0.5rem;
border: 1px solid transparent;
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;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.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);
background: var(--color-ink-800);
}
.btn-primary:active {
transform: scale(0.98);
}
.btn-primary:hover::before { opacity: 1; }
.btn-primary:active { transform: translateY(0); }
.btn-ghost {
background: transparent;
border: 1.5px solid var(--color-ink-200);
border: 1px solid var(--color-ink-200);
color: var(--color-ink-700);
font-weight: 600;
font-weight: 550;
padding: 0.625rem 1.25rem;
border-radius: 0.625rem;
border-radius: 0.5rem;
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;
background: var(--color-ink-0);
border: 1px solid var(--color-ink-200);
border-radius: 0.5rem;
padding: 0.625rem 1rem;
font-size: 0.875rem;
color: var(--color-ink-800);
@ -304,30 +347,28 @@ body {
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);
border-color: var(--color-ink-900);
box-shadow: 0 0 0 3px rgba(9, 9, 11, 0.06), 0 1px 2px rgba(0, 0, 0, 0.03);
}
.input-field::placeholder { color: var(--color-ink-400); }
.card {
background: white;
border: 1px solid var(--color-ink-100);
border-radius: 1rem;
border-radius: 0.75rem;
box-shadow: var(--shadow-sm);
transition: box-shadow 0.3s, transform 0.3s;
transition: box-shadow 0.2s, transform 0.2s;
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.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);
border: 1px solid rgba(228, 228, 231, 0.4);
border-radius: 0.75rem;
box-shadow: var(--shadow-premium);
}
/* ============================================================
@ -341,15 +382,15 @@ body {
animation: var(--animate-float);
}
.orb-primary {
background: radial-gradient(circle, rgba(162, 231, 113, 0.35) 0%, rgba(162, 231, 113, 0.05) 70%);
background: radial-gradient(circle, rgba(113, 113, 122, 0.08) 0%, rgba(113, 113, 122, 0) 70%);
}
.orb-secondary {
background: radial-gradient(circle, rgba(117, 191, 70, 0.25) 0%, rgba(117, 191, 70, 0.03) 70%);
background: radial-gradient(circle, rgba(82, 82, 91, 0.06) 0%, rgba(82, 82, 91, 0) 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%);
background: radial-gradient(circle, rgba(161, 161, 170, 0.04) 0%, rgba(161, 161, 170, 0) 70%);
animation-delay: -6s;
animation-duration: 11s;
}
@ -368,11 +409,11 @@ body {
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); }
.badge-success { background: rgba(16, 185, 129, 0.08); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.15); }
.badge-warning { background: rgba(245, 158, 11, 0.08); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.15); }
.badge-danger { background: rgba(239, 68, 68, 0.08); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.15); }
.badge-neutral { background: rgba(82, 82, 91, 0.08); color: #52525b; border: 1px solid rgba(82, 82, 91, 0.15); }
.badge-primary { background: rgba(9, 9, 11, 0.06); color: #09090b; border: 1px solid rgba(9, 9, 11, 0.12); }
/* ============================================================
ANIMATION UTILITY CLASSES
@ -382,10 +423,10 @@ body {
.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-fade-in { animation: fade-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
.animate-scale-up { animation: scale-up 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
.animate-slide-up { animation: slide-up 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
.animate-slide-right { animation: slide-right 0.35s 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; }
@ -399,14 +440,14 @@ body {
/* ============================================================
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); }
.transition-premium { transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); }
.transition-slow { transition: all 0.4s 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-lift { transition: transform 0.2s, box-shadow 0.2s; }
.hover-lift:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
.hover-glow:hover { box-shadow: var(--shadow-glow); }
@ -419,7 +460,7 @@ body {
content: '';
position: absolute;
inset: -50%;
background: radial-gradient(circle, rgba(162, 231, 113, 0.3) 0%, transparent 70%);
background: radial-gradient(circle, rgba(113, 113, 122, 0.1) 0%, transparent 70%);
opacity: 0;
transition: opacity 0.4s;
}
@ -451,7 +492,7 @@ body {
vertical-align: middle;
transition: background 0.15s;
}
.table-premium tr:hover td { background: rgba(162, 231, 113, 0.03); }
.table-premium tr:hover td { background: rgba(113, 113, 122, 0.03); }
.table-premium tr:last-child td { border-bottom: none; }
/* ============================================================
@ -462,7 +503,7 @@ body {
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border-radius: 0.625rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-ink-600);
@ -481,9 +522,9 @@ body {
.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);
background: var(--color-ink-900);
color: var(--color-ink-0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.nav-link.active::before { display: none; }
@ -542,7 +583,7 @@ body {
.progress-bar-fill {
height: 100%;
border-radius: 9999px;
background: linear-gradient(90deg, var(--color-primary-400), var(--color-primary-600));
background: var(--color-ink-900);
transition: width 0.6s cubic-bezier(0.16, 1, 0.3, 1);
position: relative;
}
@ -550,6 +591,6 @@ body {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
animation: shimmer 1.5s linear infinite;
}

File diff suppressed because it is too large Load Diff

View File

@ -17,45 +17,57 @@ const itemVariants: Variants = {
export const DashboardPage = () => {
const user = useAuthStore((state) => state.user);
const CARDS = [
const CARDS = user?.role === 'ADMIN' ? [
{
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'
path: '/admin/assets',
metrics: 'View Catalog'
},
{
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'
path: '/admin/legal',
metrics: 'Legal Templates'
},
{
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'
path: '/admin/partners',
metrics: 'Manage Partners'
}
] : [
{
title: 'Asset Library',
description: 'Browse, search, and download your assigned enterprise resources and pitch decks.',
icon: FolderKanban,
path: '/client/assets',
metrics: 'My Assets'
},
{
title: 'Legal Agreements',
description: 'View signed compliance documentation and download copies of active agreements.',
icon: FileSignature,
path: '/client/agreements',
metrics: 'My Agreements'
}
];
return (
<motion.div variants={containerVariants} initial="hidden" animate="show" className="max-w-[1400px] mx-auto w-full space-y-10">
<motion.div variants={containerVariants} initial="hidden" animate="show" className="max-w-[1400px] mx-auto w-full space-y-10 text-ink-900 animate-fade-in">
<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 className="inline-flex items-center gap-2.5 px-4 py-1.5 rounded-full bg-ink-900 border border-ink-800 w-fit shadow-sm text-ink-0">
<div className="w-2 h-2 rounded-full bg-ink-0 animate-pulse" />
<span className="text-[11px] font-bold tracking-widest uppercase">System Active</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">
<h1 className="text-5xl font-extrabold text-ink-900 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 className="text-xl text-ink-500 max-w-2xl leading-relaxed mt-2 font-medium">
You are authenticated as <span className="text-ink-900 font-extrabold uppercase tracking-wider">{user?.role}</span>. Manage your channel network, monitor compliance, and distribute assets globally.
</p>
</motion.div>
@ -66,14 +78,14 @@ export const DashboardPage = () => {
{ 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 key={i} className="relative group overflow-hidden rounded-3xl bg-ink-0 border border-ink-200 p-8 transition-all duration-300 shadow-sm hover:shadow-md">
<div className="absolute top-0 right-0 p-6 opacity-5 group-hover:opacity-10 transition-opacity duration-500 group-hover:scale-110 transform">
<stat.icon className="w-24 h-24 text-ink-900" />
</div>
<p className="text-sm font-bold uppercase tracking-wider text-slate-500 dark:text-white/30 mb-2">{stat.label}</p>
<p className="text-sm font-bold uppercase tracking-wider text-ink-500 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>
<h3 className="text-5xl font-extrabold text-ink-900 tracking-tighter">{stat.value}</h3>
<span className="text-xs font-bold text-ink-900 bg-ink-100 px-2.5 py-1.5 rounded-lg mb-1.5 border border-ink-200">{stat.trend}</span>
</div>
</div>
))}
@ -82,30 +94,26 @@ export const DashboardPage = () => {
{/* 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" />
<Link key={idx} to={card.path} className="group relative block h-full">
<div className="relative h-full bg-ink-0 border border-ink-200 rounded-[2rem] p-10 hover:border-ink-400 transition-all duration-300 overflow-hidden shadow-sm hover:shadow-md hover:-translate-y-1 flex flex-col justify-between">
<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 className="w-16 h-16 rounded-2xl bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-350 flex items-center justify-center text-ink-0">
<card.icon className="w-8 h-8" />
</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 className="w-12 h-12 rounded-full bg-ink-50 flex items-center justify-center group-hover:bg-ink-100 transition-all duration-300 border border-ink-200 group-hover:border-ink-300">
<ArrowUpRight className="w-6 h-6 text-ink-400 group-hover:text-ink-900 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">
<div className="relative z-10 mt-auto">
<div className="inline-block px-3.5 py-1.5 rounded-full bg-ink-50 border border-ink-200 text-xs font-bold text-ink-500 mb-5 shadow-sm">
{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">
<h3 className="text-3xl font-extrabold text-ink-900 mb-4 tracking-tight">
{card.title}
</h3>
<p className="text-slate-500 dark:text-white/40 leading-relaxed text-sm font-medium">
<p className="text-ink-500 leading-relaxed text-sm font-medium">
{card.description}
</p>
</div>
@ -116,3 +124,4 @@ export const DashboardPage = () => {
</motion.div>
);
};
export default DashboardPage;

View File

@ -27,7 +27,7 @@ export const InvitePage: React.FC = () => {
const validateToken = async () => {
try {
const response = await axios.get(`${import.meta.env.VITE_API_URL || 'http://localhost:5001/api/v1'}/auth/invite/${token}`);
const response = await axios.get(`${import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1'}/auth/invite/${token}`);
setEmail(response.data.email);
setStatus('valid');
} catch (err) {
@ -59,10 +59,7 @@ export const InvitePage: React.FC = () => {
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');
@ -72,24 +69,24 @@ export const InvitePage: React.FC = () => {
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 className="min-h-screen bg-ink-50 flex items-center justify-center">
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin" />
</div>
);
}
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">
<div className="min-h-screen bg-ink-50 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-ink-0 rounded-3xl p-8 border border-ink-200 text-center shadow-2xl">
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertCircle className="w-8 h-8 text-red-600 dark:text-red-500" />
</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">
<h2 className="text-2xl font-bold text-ink-900 mb-2">Invalid or Expired Link</h2>
<p className="text-ink-500 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">
<button onClick={() => navigate('/login')} className="text-ink-900 font-extrabold hover:underline">
Return to Login
</button>
</div>
@ -98,44 +95,45 @@ export const InvitePage: React.FC = () => {
}
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="min-h-screen bg-ink-50 text-ink-900 font-sans flex items-center justify-center p-4">
{/* Monochromatic background accent */}
<div className="fixed top-0 right-0 w-[500px] h-[500px] bg-ink-900/5 rounded-full blur-[120px] pointer-events-none" />
<div className="w-full max-w-md z-10">
<div className="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 className="w-12 h-12 rounded-2xl bg-gradient-to-br from-ink-900 to-ink-800 shadow-xl flex items-center justify-center mx-auto mb-6">
<Shield className="w-6 h-6 text-ink-0" />
</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 className="text-sm font-medium text-ink-500">
Set up your partner account for <span className="text-ink-900 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"
className="bg-ink-0 rounded-[2rem] p-8 shadow-2xl border border-ink-200"
>
<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">
<div className="p-4 bg-red-500/10 border 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>
<p className="text-xs font-bold text-red-600 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>
<label className="text-xs font-bold uppercase tracking-wider text-ink-400">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" />
<KeyRound className="w-4 h-4 text-ink-400" />
</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"
className="w-full pl-11 pr-4 py-3.5 bg-ink-50 border border-ink-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-ink-900/10 outline-none transition-all placeholder-ink-400 text-ink-900"
placeholder="Enter a secure password"
required
/>
@ -143,16 +141,16 @@ export const InvitePage: React.FC = () => {
</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>
<label className="text-xs font-bold uppercase tracking-wider text-ink-400">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" />
<CheckCircle className="w-4 h-4 text-ink-400" />
</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"
className="w-full pl-11 pr-4 py-3.5 bg-ink-50 border border-ink-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-ink-900/10 outline-none transition-all placeholder-ink-400 text-ink-900"
placeholder="Confirm your password"
required
/>
@ -162,10 +160,10 @@ export const InvitePage: React.FC = () => {
<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"
className="w-full flex items-center justify-center gap-2 py-4 rounded-xl bg-ink-900 text-ink-0 font-bold hover:bg-ink-800 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" />
<div className="w-5 h-5 border-2 border-ink-0/30 border-t-ink-0 rounded-full animate-spin" />
) : (
<>
Create Account & Continue
@ -179,3 +177,4 @@ export const InvitePage: React.FC = () => {
</div>
);
};
export default InvitePage;

View File

@ -33,7 +33,6 @@ export const LoginPage = () => {
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 {
@ -45,10 +44,10 @@ export const LoginPage = () => {
};
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" />
<div className="min-h-screen bg-ink-50 flex flex-col justify-center items-center p-4 relative overflow-hidden font-sans transition-colors duration-500">
{/* Monochromatic Soft Background Blurs */}
<div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" />
<div className="absolute bottom-1/4 right-1/4 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" />
<motion.div
initial={{ opacity: 0, y: 30 }}
@ -56,35 +55,35 @@ export const LoginPage = () => {
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="bg-ink-0 border border-ink-200 rounded-[2rem] shadow-2xl p-12 relative overflow-hidden">
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-ink-300 to-transparent" />
<div className="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 className="relative flex items-center justify-center w-20 h-20 rounded-[1.5rem] bg-gradient-to-tr from-ink-900 to-ink-800 shadow-lg mb-8">
<Hexagon className="text-ink-0 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>
<h2 className="text-3xl font-extrabold text-ink-900 tracking-tight">Channel Portal</h2>
<p className="text-ink-500 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)]" />
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="bg-red-500/10 border border-red-500/20 text-red-600 dark:text-red-400 p-4 rounded-xl text-sm mb-8 flex items-center gap-3 font-medium shadow-sm">
<div className="w-2 h-2 rounded-full bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]" />
{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>
<label className="block text-[11px] font-bold text-ink-500 uppercase tracking-widest">Work Email</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Mail className={`w-5 h-5 transition-colors ${errors.email ? 'text-red-400' : 'text-slate-400 dark:text-white/20 group-focus-within:text-blue-500 dark:group-focus-within:text-blue-400'}`} />
<Mail className={`w-5 h-5 transition-colors ${errors.email ? 'text-red-400' : 'text-ink-400 group-focus-within:text-ink-900'}`} />
</div>
<input
type="email"
{...register('email')}
className={`w-full bg-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`}
className={`w-full bg-ink-50 border ${errors.email ? 'border-red-500/50 focus:border-red-500' : 'border-ink-200 focus:border-ink-900/50'} rounded-2xl py-4 pl-12 pr-4 text-ink-900 placeholder-ink-400 outline-none transition-all focus:bg-ink-0 focus:ring-4 ring-ink-900/10 font-medium`}
placeholder="admin@tech4biz.com"
/>
</div>
@ -93,17 +92,17 @@ export const LoginPage = () => {
<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>
<label className="block text-[11px] font-bold text-ink-500 uppercase tracking-widest">Password</label>
<a href="#" className="text-[11px] font-bold text-ink-500 hover:text-ink-900 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'}`} />
<Lock className={`w-5 h-5 transition-colors ${errors.password ? 'text-red-400' : 'text-ink-400 group-focus-within:text-ink-900'}`} />
</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`}
className={`w-full bg-ink-50 border ${errors.password ? 'border-red-500/50 focus:border-red-500' : 'border-ink-200 focus:border-ink-900/50'} rounded-2xl py-4 pl-12 pr-4 text-ink-900 placeholder-ink-400 outline-none transition-all focus:bg-ink-0 focus:ring-4 ring-ink-900/10 font-medium`}
placeholder="••••••••"
/>
</div>
@ -113,7 +112,7 @@ export const LoginPage = () => {
<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)]"
className="group relative w-full bg-ink-900 text-ink-0 font-extrabold tracking-wide py-4 px-4 rounded-2xl hover:bg-ink-800 transition-all duration-300 disabled:opacity-70 disabled:cursor-not-allowed mt-4 overflow-hidden flex items-center justify-center gap-3 shadow-lg"
>
{isSubmitting ? 'AUTHENTICATING...' : 'SECURE SIGN IN'}
{!isSubmitting && <ArrowRight className="w-5 h-5 group-hover:translate-x-1.5 transition-transform" />}
@ -121,10 +120,11 @@ export const LoginPage = () => {
</form>
</div>
<p className="text-center text-slate-500 dark:text-white/20 text-xs mt-10 font-bold tracking-widest uppercase">
<p className="text-center text-ink-400 text-xs mt-10 font-bold tracking-widest uppercase">
© 2026 Tech4Biz Solutions.
</p>
</motion.div>
</div>
);
};
export default LoginPage;

View File

@ -9,7 +9,7 @@ import { axiosInstance } from '../services/axios';
type DocumentType = 'NDA' | 'MSA';
export const OnboardingPage: React.FC = () => {
const { user } = useAuthStore();
const { user, checkAuth } = useAuthStore();
const navigate = useNavigate();
const [step, setStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
@ -26,6 +26,118 @@ export const OnboardingPage: React.FC = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [ndaContent, setNdaContent] = useState<string>('');
const [ndaPdfUrl, setNdaPdfUrl] = useState<string | null>(null);
const [msaContent, setMsaContent] = useState<string>('');
const [msaPdfUrl, setMsaPdfUrl] = useState<string | null>(null);
const [loadingDocs, setLoadingDocs] = useState(true);
useEffect(() => {
const fetchDocs = async () => {
try {
const [ndaRes, msaRes] = await Promise.all([
axiosInstance.get('/legal/documents/active/NDA'),
axiosInstance.get('/legal/documents/active/MSA')
]);
setNdaContent(ndaRes.data.content);
setNdaPdfUrl(ndaRes.data.pdfUrl || null);
setMsaContent(msaRes.data.content);
setMsaPdfUrl(msaRes.data.pdfUrl || null);
} catch (err) {
console.error('Failed to fetch legal documents:', err);
} finally {
setLoadingDocs(false);
}
};
fetchDocs();
}, []);
const handlePrint = (type: DocumentType) => {
const content = type === 'NDA' ? ndaContent : msaContent;
const printWindow = window.open('', '_blank');
if (!printWindow) return;
printWindow.document.write(`
<html>
<head>
<title>Standard ${type} Agreement</title>
<style>
body { font-family: serif; line-height: 1.6; padding: 50px; color: #111; max-width: 800px; margin: 0 auto; }
h1 { font-family: sans-serif; text-align: center; border-bottom: 2px solid #111; padding-bottom: 10px; margin-bottom: 30px; }
.content { white-space: pre-wrap; font-size: 15px; margin-bottom: 50px; }
.footer { border-top: 1px solid #ccc; padding-top: 20px; font-size: 12px; color: #666; text-align: center; }
</style>
</head>
<body>
<h1>Tech4Biz Standard ${type} Agreement</h1>
<div class="content">${content}</div>
<div class="footer">Confidential document. Tech4Biz Solutions Inc.</div>
<script>
window.onload = function() {
window.print();
}
</script>
</body>
</html>
`);
printWindow.document.close();
};
const renderDocumentViewer = (type: DocumentType) => {
const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', '');
const pdfUrl = type === 'NDA' ? ndaPdfUrl : msaPdfUrl;
if (pdfUrl) {
return (
<div className="mb-6 flex flex-col animate-fade-in">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold uppercase tracking-wider text-ink-400">Agreement Terms (PDF)</span>
<a
href={`${fileHost}${pdfUrl}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold rounded-lg border border-ink-200 hover:bg-ink-100 text-ink-700 hover:text-ink-900 transition-colors"
>
<FileText className="w-3.5 h-3.5" />
Open / Print PDF
</a>
</div>
<div className="h-64 rounded-xl border border-ink-200 overflow-hidden bg-ink-50 shadow-inner">
<iframe
src={`${fileHost}${pdfUrl}#toolbar=1`}
className="w-full h-full border-0"
title={`Standard ${type} Document`}
/>
</div>
</div>
);
}
return (
<div className="mb-6 flex flex-col">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold uppercase tracking-wider text-ink-400">Agreement Terms</span>
<button
type="button"
onClick={() => handlePrint(type)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold rounded-lg border border-ink-200 hover:bg-ink-100 text-ink-700 hover:text-ink-900 transition-colors"
>
<FileText className="w-3.5 h-3.5" />
Print / Save PDF
</button>
</div>
<div className="h-64 overflow-y-auto p-6 bg-ink-0 rounded-xl border border-ink-200 text-sm leading-relaxed text-ink-800 font-serif whitespace-pre-wrap tracking-wide shadow-sm scrollbar-thin scrollbar-thumb-ink-300">
{loadingDocs ? (
<div className="flex h-full items-center justify-center">
<div className="w-6 h-6 border-2 border-ink-900 border-t-transparent rounded-full animate-spin"></div>
</div>
) : (
type === 'NDA' ? ndaContent : msaContent
)}
</div>
</div>
);
};
useEffect(() => {
if (user?.onboardingStatus === 'APPROVED') {
navigate('/client');
@ -42,6 +154,24 @@ export const OnboardingPage: React.FC = () => {
}
}, [user, navigate]);
useEffect(() => {
let intervalId: any;
if (user?.onboardingStatus === 'PENDING_APPROVAL' || step === 3) {
intervalId = setInterval(async () => {
try {
await checkAuth();
} catch (err) {
console.error('Failed to poll auth status:', err);
}
}, 5000); // Poll every 5s
}
return () => {
if (intervalId) clearInterval(intervalId);
};
}, [user?.onboardingStatus, step, checkAuth]);
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>, type: DocumentType) => {
const file = e.target.files?.[0];
if (!file) return;
@ -52,7 +182,7 @@ export const OnboardingPage: React.FC = () => {
formData.append('file', file);
formData.append('title', `Signed ${type} - ${user?.email}`);
const response = await axiosInstance.post('/assets/upload', formData, {
const response = await axiosInstance.post('/legal/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
@ -80,9 +210,8 @@ export const OnboardingPage: React.FC = () => {
if (type === 'NDA') {
setStep(2);
} else {
await checkAuth();
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);
@ -93,16 +222,16 @@ export const OnboardingPage: React.FC = () => {
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">
<div className="flex bg-ink-100 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'}`}
className={`flex-1 py-2 text-sm font-bold rounded-lg transition-all ${mode === 'draw' ? 'bg-ink-0 shadow text-ink-900' : 'text-ink-500 hover:text-ink-900'}`}
>
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'}`}
className={`flex-1 py-2 text-sm font-bold rounded-lg transition-all ${mode === 'upload' ? 'bg-ink-0 shadow text-ink-900' : 'text-ink-500 hover:text-ink-900'}`}
>
Upload PDF
</button>
@ -112,17 +241,17 @@ export const OnboardingPage: React.FC = () => {
<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="mt-6 flex items-center justify-between p-4 bg-ink-100 border border-ink-300 rounded-xl">
<div className="flex items-center gap-3">
<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>
<CheckCircle className="w-5 h-5 text-ink-900" />
<span className="font-semibold text-ink-900 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>
<button onClick={() => setSignature(null)} className="text-xs font-bold text-ink-900 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">
<div className="flex-1 flex flex-col justify-center items-center p-12 border-2 border-dashed border-ink-200 rounded-2xl bg-ink-50 transition-colors hover:bg-ink-100">
<input
type="file"
ref={fileInputRef}
@ -133,23 +262,23 @@ export const OnboardingPage: React.FC = () => {
{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 className="w-16 h-16 bg-ink-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-ink-900" />
</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>
<h3 className="font-bold text-ink-900 mb-2">Document Uploaded</h3>
<button onClick={() => setUploadUrl(null)} className="text-sm font-bold text-ink-500 hover:text-ink-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 className="w-16 h-16 bg-ink-100 rounded-full flex items-center justify-center mx-auto mb-4">
<UploadCloud className="w-8 h-8 text-ink-500" />
</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>
<h3 className="font-bold text-ink-900 mb-2">Upload Signed Document</h3>
<p className="text-sm text-ink-500 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"
className="px-6 py-2.5 bg-ink-900 text-ink-0 font-bold rounded-xl hover:shadow-lg transition-all"
>
{isSubmitting ? 'Uploading...' : 'Browse Files'}
</button>
@ -161,70 +290,71 @@ export const OnboardingPage: React.FC = () => {
);
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="min-h-screen bg-ink-50 text-ink-900 font-sans flex items-center justify-center p-4">
{/* Dynamic Background Accents using monochromatic themes */}
<div className="fixed top-0 right-0 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" />
<div className="fixed bottom-0 left-[10%] w-[500px] h-[500px] bg-ink-900/5 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]">
<div className="w-full max-w-5xl bg-ink-0 rounded-[2rem] shadow-2xl border border-ink-200 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="w-full md:w-1/3 bg-ink-50/50 border-r border-ink-200 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 className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg">
<ShieldCheck className="w-5 h-5 text-ink-0" />
</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 className="absolute left-0 top-1 w-6 h-6 rounded-full bg-ink-900 flex items-center justify-center shadow-md">
<CheckCircle className="w-3.5 h-3.5 text-ink-0" />
</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 className="absolute left-3 top-7 bottom-[-20px] w-0.5 bg-ink-900/20"></div>
<h3 className="font-bold text-ink-900">Account Created</h3>
<p className="text-xs font-medium text-ink-500 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 className={`absolute left-0 top-1 w-6 h-6 rounded-full flex items-center justify-center shadow-md transition-colors duration-300 ${step > 1 ? 'bg-ink-900' : step === 1 ? 'border-2 border-ink-900 bg-ink-0' : 'bg-ink-200'}`}>
{step > 1 ? <CheckCircle className="w-3.5 h-3.5 text-ink-0" /> : <div className="w-2 h-2 rounded-full bg-ink-900" />}
</div>
<div 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 className="absolute left-3 top-7 bottom-[-20px] w-0.5 bg-ink-200">
<div className="w-full bg-ink-900/20 transition-all duration-500" style={{ height: step > 1 ? '100%' : '0%' }}></div>
</div>
<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>
<h3 className={`font-bold transition-colors ${step >= 1 ? 'text-ink-900' : 'text-ink-400'}`}>NDA Agreement</h3>
<p className="text-xs font-medium text-ink-500 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 className={`absolute left-0 top-1 w-6 h-6 rounded-full flex items-center justify-center shadow-md transition-colors duration-300 ${step > 2 ? 'bg-ink-900' : step === 2 ? 'border-2 border-ink-900 bg-ink-0' : 'bg-ink-200'}`}>
{step > 2 ? <CheckCircle className="w-3.5 h-3.5 text-ink-0" /> : step === 2 ? <div className="w-2 h-2 rounded-full bg-ink-900" /> : <div className="w-2 h-2 rounded-full bg-transparent" />}
</div>
<div 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 className="absolute left-3 top-7 bottom-[-20px] w-0.5 bg-ink-200">
<div className="w-full bg-ink-900/20 transition-all duration-500" style={{ height: step > 2 ? '100%' : '0%' }}></div>
</div>
<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>
<h3 className={`font-bold transition-colors ${step >= 2 ? 'text-ink-900' : 'text-ink-400'}`}>MSA Agreement</h3>
<p className="text-xs font-medium text-ink-500 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 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-ink-900 text-ink-0' : 'bg-ink-200'}`}>
{step === 3 ? <Clock className="w-3.5 h-3.5 text-ink-0" /> : <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>
<h3 className={`font-bold transition-colors ${step === 3 ? 'text-ink-900' : 'text-ink-400'}`}>Admin Approval</h3>
<p className="text-xs font-medium text-ink-500 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="mt-8 pt-8 border-t border-ink-200">
<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">
<div className="w-8 h-8 rounded-full bg-ink-200 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>
<p className="text-[10px] uppercase text-ink-500 font-bold tracking-wider">Pending Partner</p>
</div>
</div>
</div>
@ -236,23 +366,25 @@ export const OnboardingPage: React.FC = () => {
{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 className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-ink-900 text-ink-0 mb-4">
<Lock className="w-3.5 h-3.5 text-ink-0" />
<span className="text-[10px] font-bold text-ink-0 tracking-widest uppercase">Action Required</span>
</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">
<p className="text-sm font-medium text-ink-500">
Please provide your signature or upload a signed copy of our standard NDA to proceed.
</p>
</div>
{renderDocumentViewer('NDA')}
{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">
<div className="mt-auto pt-6 border-t border-ink-200 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"
className="flex items-center gap-2 px-8 py-3.5 rounded-xl bg-ink-900 text-ink-0 font-bold hover:bg-ink-800 hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Processing...' : 'Continue to MSA'}
<ChevronRight className="w-4 h-4" />
@ -264,26 +396,28 @@ export const OnboardingPage: React.FC = () => {
{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 className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-ink-100 border border-ink-200 mb-4">
<FileText className="w-3.5 h-3.5 text-ink-600" />
<span className="text-[10px] font-bold text-ink-700 tracking-widest uppercase">Final Agreement</span>
</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">
<p className="text-sm font-medium text-ink-500">
Sign the MSA to finalize your compliance requirements and enter the approval queue.
</p>
</div>
{renderDocumentViewer('MSA')}
{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">
<div className="mt-auto pt-6 border-t border-ink-200 flex justify-between items-center">
<button onClick={() => setStep(1)} className="text-sm font-bold text-ink-500 hover:text-ink-900 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"
className="flex items-center gap-2 px-8 py-3.5 rounded-xl bg-ink-900 text-ink-0 font-bold hover:bg-ink-800 hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Processing...' : 'Submit for Approval'}
<ChevronRight className="w-4 h-4" />
@ -294,28 +428,28 @@ export const OnboardingPage: React.FC = () => {
{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 className="w-24 h-24 rounded-full bg-ink-100 flex items-center justify-center mb-8 relative">
<div className="absolute inset-0 rounded-full border-4 border-ink-900/30 animate-[spin_3s_linear_infinite]" style={{ borderStyle: 'dashed' }}></div>
<Clock className="w-10 h-10 text-ink-900 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">
<p className="text-ink-500 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="p-6 bg-ink-50 rounded-2xl border border-ink-200 max-w-sm w-full">
<div className="flex justify-between items-center mb-3">
<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>
<span className="text-sm font-medium text-ink-500">NDA Status</span>
<span className="text-xs font-bold text-ink-900 bg-ink-100 px-2 py-1 rounded-md">Signed</span>
</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>
<span className="text-sm font-medium text-ink-500">MSA Status</span>
<span className="text-xs font-bold text-ink-900 bg-ink-100 px-2 py-1 rounded-md">Signed</span>
</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 className="flex justify-between items-center pt-3 border-t border-ink-200">
<span className="text-sm font-medium text-ink-500">Account Access</span>
<span className="text-xs font-bold text-ink-0 bg-ink-900 px-2 py-1 rounded-md">Locked</span>
</div>
</div>
</motion.div>
@ -326,3 +460,4 @@ export const OnboardingPage: React.FC = () => {
</div>
);
};
export default OnboardingPage;

View File

@ -53,42 +53,42 @@ export const ApprovalsPage: React.FC = () => {
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 className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin" />
</div>
);
}
return (
<div className="max-w-6xl mx-auto space-y-8">
<div className="max-w-6xl mx-auto space-y-8 animate-fade-in text-ink-900">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-extrabold text-slate-900 dark:text-white tracking-tight flex items-center gap-3">
<h1 className="text-3xl font-extrabold text-ink-900 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">
<span className="bg-ink-900 text-ink-0 text-xs px-2.5 py-1 rounded-full font-bold border border-ink-700 shadow-sm">
{partners.length} Pending
</span>
</h1>
<p className="text-slate-500 dark:text-white/50 text-sm mt-1">
<p className="text-ink-500 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" />
<Search className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="Search pending partners..."
className="pl-9 pr-4 py-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"
className="pl-9 pr-4 py-2 bg-ink-0 border border-ink-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 w-full sm: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="bg-ink-0 rounded-2xl border border-ink-200 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">
<thead className="bg-ink-50 border-b border-ink-200 text-ink-500 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>
@ -96,16 +96,16 @@ export const ApprovalsPage: React.FC = () => {
<th className="px-6 py-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-white/10">
<tbody className="divide-y divide-ink-200">
<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 className="w-12 h-12 rounded-full bg-ink-100 flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-6 h-6 text-ink-900" />
</div>
<p className="text-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>
<p className="text-ink-900 font-bold">Queue is empty</p>
<p className="text-ink-500 text-xs mt-1">All partners have been reviewed.</p>
</td>
</tr>
) : (
@ -117,17 +117,17 @@ export const ApprovalsPage: React.FC = () => {
<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"
exit={{ opacity: 0, x: -20, backgroundColor: 'rgba(0, 0, 0, 0.02)' }}
className="hover:bg-ink-50 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">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 font-bold text-xs shadow-md">
{partner.email.charAt(0).toUpperCase()}
</div>
<div>
<p className="font-bold text-slate-900 dark:text-white">{partner.email}</p>
<p className="text-xs text-slate-500 dark:text-white/40 flex items-center gap-1">
<p className="font-bold text-ink-900">{partner.email}</p>
<p className="text-xs text-ink-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(partner.createdAt).toLocaleDateString()}
</p>
@ -137,36 +137,36 @@ export const ApprovalsPage: React.FC = () => {
<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">
<CheckCircle className="w-4 h-4 text-ink-900" />
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-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>
<a href={nda.documentUrl} target="_blank" rel="noreferrer" className="text-xs font-bold text-ink-600 hover:text-ink-900 underline ml-2">View</a>
)}
</div>
) : (
<div className="flex items-center gap-2 text-amber-500">
<div className="flex items-center gap-2 text-ink-400">
<XCircle className="w-4 h-4" />
<span className="text-xs font-bold">Missing</span>
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-1 rounded-md">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">
<CheckCircle className="w-4 h-4 text-ink-900" />
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-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>
<a href={msa.documentUrl} target="_blank" rel="noreferrer" className="text-xs font-bold text-ink-600 hover:text-ink-900 underline ml-2">View</a>
)}
</div>
) : (
<div className="flex items-center gap-2 text-amber-500">
<div className="flex items-center gap-2 text-ink-400">
<XCircle className="w-4 h-4" />
<span className="text-xs font-bold">Missing</span>
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-1 rounded-md">Missing</span>
</div>
)}
</td>
@ -174,7 +174,7 @@ export const ApprovalsPage: React.FC = () => {
<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"
className="px-4 py-2 bg-ink-900 text-ink-0 text-xs font-bold rounded-lg hover:bg-ink-800 transition-colors shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{processingId === partner.id ? 'Approving...' : 'Approve Access'}
</button>
@ -191,3 +191,4 @@ export const ApprovalsPage: React.FC = () => {
</div>
);
};
export default ApprovalsPage;

View File

@ -2,6 +2,7 @@ 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';
import { Link } from 'react-router-dom';
interface Partner {
id: string;
@ -14,29 +15,29 @@ interface Partner {
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',
color: 'text-ink-500',
bg: 'bg-ink-50',
border: 'border-ink-200',
},
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',
color: 'text-ink-0 bg-ink-900',
bg: 'bg-ink-900',
border: 'border-ink-800',
},
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',
color: 'text-ink-900 font-extrabold',
bg: 'bg-ink-100',
border: 'border-ink-300',
},
};
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',
color: 'text-ink-500',
bg: 'bg-ink-100',
border: 'border-ink-200',
};
export const DirectoryPage: React.FC = () => {
@ -71,7 +72,6 @@ export const DirectoryPage: React.FC = () => {
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' });
@ -83,43 +83,82 @@ export const DirectoryPage: React.FC = () => {
const counts = {
total: partners.length,
active: partners.filter(p => p.onboardingStatus === 'APPROVED').length,
pending: partners.filter(p => p.onboardingStatus !== 'APPROVED').length,
pendingOnboarding: partners.filter(p => p.onboardingStatus === 'PENDING_ONBOARDING').length,
awaitingApproval: partners.filter(p => p.onboardingStatus === 'PENDING_APPROVAL').length,
};
const statCards = [
{ label: 'Total Partners', value: counts.total, icon: Users, accentClass: '' },
{ label: 'Active', value: counts.active, icon: ShieldCheck, accentClass: '' },
{ label: 'Pending Onboarding', value: counts.pendingOnboarding, icon: Clock, accentClass: '' },
];
if (counts.awaitingApproval > 0) {
statCards.push({
label: 'Awaiting Approval',
value: counts.awaitingApproval,
icon: AlertCircle,
accentClass: 'border-ink-900/30 bg-ink-900/5 shadow-[0_0_15px_rgba(0,0,0,0.01)] border-dashed animate-pulse'
});
}
return (
<div className="max-w-6xl mx-auto space-y-8">
<div className="max-w-6xl mx-auto space-y-8 animate-fade-in text-ink-900">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-extrabold text-slate-900 dark:text-white tracking-tight">
<h1 className="text-3xl font-extrabold text-ink-900 tracking-tight">
Partner Directory
</h1>
<p className="text-slate-500 dark:text-white/50 text-sm mt-1">
<p className="text-ink-500 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"
className="p-2.5 rounded-xl bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 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" />
{/* Dynamic Real-time Approval Notification Banner */}
{counts.awaitingApproval > 0 && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between p-5 bg-ink-900 border border-ink-800 rounded-2xl shadow-sm text-ink-0 relative overflow-hidden"
>
<div className="absolute inset-0 bg-gradient-to-r from-ink-800 via-ink-900 to-ink-800 opacity-50" />
<div className="flex items-center gap-3 relative z-10">
<div className="w-10 h-10 rounded-xl bg-ink-0/10 flex items-center justify-center text-ink-0 shrink-0">
<AlertCircle className="w-5 h-5 animate-bounce" />
</div>
<p className="text-3xl font-extrabold text-slate-900 dark:text-white tracking-tight">{stat.value}</p>
<div>
<h4 className="font-bold text-sm text-ink-0">Partner approvals pending</h4>
<p className="text-xs text-ink-300 mt-0.5">There are {counts.awaitingApproval} partners awaiting document review and access authorization.</p>
</div>
</div>
<Link
to="/admin/approvals"
className="relative z-10 px-4 py-2 bg-ink-0 hover:bg-ink-100 text-ink-900 text-xs font-bold rounded-xl transition-all shadow-sm flex items-center gap-1.5 shrink-0"
>
Review Queue
<ChevronRight className="w-3.5 h-3.5" />
</Link>
</motion.div>
)}
{/* Stats Row */}
<div className={`grid gap-4 ${counts.awaitingApproval > 0 ? 'grid-cols-2 md:grid-cols-4' : 'grid-cols-1 md:grid-cols-3'}`}>
{statCards.map((stat, i) => (
<div key={i} className={`bg-ink-0 rounded-2xl border p-5 shadow-sm transition-all duration-300 ${stat.accentClass || 'border-ink-200'}`}>
<div className="flex items-center justify-between mb-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-500">{stat.label}</p>
<stat.icon className={`w-4 h-4 ${stat.accentClass ? 'text-ink-900 animate-pulse' : 'text-ink-400'}`} />
</div>
<p className={`text-3xl font-extrabold tracking-tight ${stat.accentClass ? 'text-ink-900' : 'text-ink-900'}`}>{stat.value}</p>
</div>
))}
</div>
@ -127,28 +166,28 @@ export const DirectoryPage: React.FC = () => {
<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="bg-ink-0 rounded-2xl border border-ink-200 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" />
<UserPlus className="w-24 h-24 text-ink-900" />
</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">
<h3 className="text-lg font-bold text-ink-900 mb-2 relative z-10">Invite Partner</h3>
<p className="text-xs text-ink-500 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>
<label className="text-[10px] font-bold uppercase tracking-wider text-ink-500 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" />
<Mail className="w-4 h-4 text-ink-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"
className="w-full pl-9 pr-4 py-2.5 bg-ink-50 border border-ink-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 transition-all"
/>
</div>
</div>
@ -156,7 +195,7 @@ export const DirectoryPage: React.FC = () => {
<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"
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-ink-900 text-ink-0 font-bold text-sm hover:bg-ink-800 hover:shadow-lg transition-all disabled:opacity-50"
>
{isSubmitting ? 'Generating...' : 'Generate Invite Link'}
<ChevronRight className="w-4 h-4" />
@ -171,18 +210,18 @@ export const DirectoryPage: React.FC = () => {
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 className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-650 shrink-0 mt-0.5" />
<p className="text-xs font-bold text-red-650">{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="p-4 bg-ink-100 border border-ink-300 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>
<CheckCircle className="w-4 h-4 text-ink-900" />
<span className="text-xs font-bold text-ink-900">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">
<p className="text-[10px] text-ink-500 mb-2 font-medium">Send this secure link to the partner:</p>
<div className="p-2 bg-ink-0 border border-ink-300 rounded-lg text-xs break-all font-mono text-ink-900">
{window.location.origin}/invite?token={inviteResult.token}
</div>
</div>
@ -195,10 +234,10 @@ export const DirectoryPage: React.FC = () => {
{/* 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="bg-ink-0 rounded-2xl border border-ink-200 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">
<thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs">
<tr>
<th className="px-6 py-4">Partner</th>
<th className="px-6 py-4">Status</th>
@ -206,34 +245,34 @@ export const DirectoryPage: React.FC = () => {
<th className="px-6 py-4">Joined</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-white/10">
<tbody className="divide-y divide-ink-200">
{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" />
<div className="w-6 h-6 border-4 border-ink-900/30 border-t-ink-900 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 className="w-12 h-12 bg-ink-50 rounded-full flex items-center justify-center mx-auto mb-3">
<Users className="w-6 h-6 text-ink-400" />
</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>
<p className="text-sm font-bold text-ink-900">No partners yet</p>
<p className="text-xs text-ink-500 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">
<tr key={partner.id} className="hover:bg-ink-50 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">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 font-bold text-xs shadow-md">
{partner.email.charAt(0).toUpperCase()}
</div>
<span className="font-bold text-slate-900 dark:text-white">{partner.email}</span>
<span className="font-bold text-ink-900">{partner.email}</span>
</div>
</td>
<td className="px-6 py-4">
@ -243,12 +282,12 @@ export const DirectoryPage: React.FC = () => {
</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-ink-900">Enabled</span>
) : (
<span className="text-xs font-bold text-slate-400 dark:text-white/30">Disabled</span>
<span className="text-xs font-bold text-ink-400">Disabled</span>
)}
</td>
<td className="px-6 py-4 text-xs text-slate-500 dark:text-white/40 font-medium">
<td className="px-6 py-4 text-xs text-ink-500 font-medium">
{new Date(partner.createdAt).toLocaleDateString()}
</td>
</tr>
@ -264,3 +303,4 @@ export const DirectoryPage: React.FC = () => {
</div>
);
};
export default DirectoryPage;

View File

@ -0,0 +1,314 @@
import React, { useState, useEffect, useRef } from 'react';
import { axiosInstance } from '../../services/axios';
import { Shield, FileText, Upload, CheckCircle, AlertTriangle, ArrowRight, Eye, RefreshCw } from 'lucide-react';
interface LegalDoc {
id: string;
type: 'NDA' | 'MSA';
version: string;
content: string;
pdfUrl: string | null;
isActive: boolean;
createdAt: string;
}
export const LegalTemplatesPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'NDA' | 'MSA'>('NDA');
const [ndaDoc, setNdaDoc] = useState<LegalDoc | null>(null);
const [msaDoc, setMsaDoc] = useState<LegalDoc | null>(null);
const [loading, setLoading] = useState(true);
// Form states
const [version, setVersion] = useState('');
const [content, setContent] = useState('');
const [pdfFile, setPdfFile] = useState<File | null>(null);
const [uploadingPdf, setUploadingPdf] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const fetchActiveDocuments = async () => {
try {
setLoading(true);
const [ndaRes, msaRes] = await Promise.all([
axiosInstance.get('/legal/documents/active/NDA').catch(() => null),
axiosInstance.get('/legal/documents/active/MSA').catch(() => null)
]);
setNdaDoc(ndaRes?.data || null);
setMsaDoc(msaRes?.data || null);
// Populate defaults for form based on active selection
const activeDoc = activeTab === 'NDA' ? ndaRes?.data : msaRes?.data;
if (activeDoc) {
setVersion(getNextVersion(activeDoc.version));
setContent(activeDoc.content);
} else {
setVersion('1.0');
setContent('');
}
} catch (err) {
console.error('Failed to load active legal templates:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchActiveDocuments();
}, [activeTab]);
const getNextVersion = (current: string) => {
const num = parseFloat(current);
if (isNaN(num)) return '1.0';
return (num + 0.1).toFixed(1);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setPdfFile(e.target.files[0]);
}
};
const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!version || !content) {
setMessage({ type: 'error', text: 'Please fill in both the version and textual content.' });
return;
}
try {
setSubmitting(true);
setMessage(null);
let uploadedUrl: string | null = null;
// 1. Upload PDF if selected
if (pdfFile) {
setUploadingPdf(true);
const formData = new FormData();
formData.append('file', pdfFile);
formData.append('title', `${activeTab} Agreement Template v${version}`);
const assetRes = await axiosInstance.post('/assets/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
uploadedUrl = assetRes.data.url;
setUploadingPdf(false);
}
// 2. Save New Document Version
await axiosInstance.post('/legal/documents', {
type: activeTab,
version,
content,
pdfUrl: uploadedUrl
});
setMessage({ type: 'success', text: `Successfully published ${activeTab} version ${version}!` });
setPdfFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
await fetchActiveDocuments();
} catch (err: any) {
console.error('Failed to publish legal document template:', err);
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to publish legal template.' });
} finally {
setSubmitting(false);
setUploadingPdf(false);
}
};
const currentDoc = activeTab === 'NDA' ? ndaDoc : msaDoc;
const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', '');
return (
<div className="max-w-[1400px] mx-auto w-full p-4 md:p-8 animate-fade-in text-ink-900">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div>
<div className="flex items-center gap-2 mb-2">
<Shield className="w-5 h-5 text-ink-400" />
<span className="text-[10px] uppercase font-bold text-ink-500 tracking-wider">Compliance Panel</span>
</div>
<h1 className="text-4xl font-extrabold text-ink-900 tracking-tight">Legal Agreements</h1>
<p className="text-sm font-medium text-ink-500 mt-1">Configure active documents required during partner onboarding.</p>
</div>
{/* Tab switchers */}
<div className="inline-flex p-1 rounded-xl bg-ink-100 border border-ink-200">
<button
onClick={() => setActiveTab('NDA')}
className={`px-4 py-2 rounded-lg text-xs font-bold transition-all ${activeTab === 'NDA' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500 hover:text-ink-900'}`}
>
Non-Disclosure (NDA)
</button>
<button
onClick={() => setActiveTab('MSA')}
className={`px-4 py-2 rounded-lg text-xs font-bold transition-all ${activeTab === 'MSA' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500 hover:text-ink-900'}`}
>
Master Services (MSA)
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Side: Active Status Details */}
<div className="lg:col-span-1 flex flex-col gap-6">
<div className="p-6 bg-ink-0 border border-ink-200 shadow-sm rounded-2xl">
<h2 className="text-lg font-bold text-ink-900 mb-4">Active Configuration</h2>
{loading ? (
<div className="flex flex-col items-center justify-center py-12">
<RefreshCw className="w-8 h-8 text-ink-400 animate-spin mb-3" />
<span className="text-xs font-bold text-ink-400">Loading document state...</span>
</div>
) : currentDoc ? (
<div className="space-y-4">
<div className="flex items-center justify-between p-3 rounded-xl bg-ink-50 border border-ink-100">
<div>
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-wider">Version</p>
<p className="text-lg font-extrabold text-ink-900">v{currentDoc.version}</p>
</div>
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold bg-ink-900 text-ink-0 border border-ink-700 shadow-sm">
<CheckCircle className="w-3.5 h-3.5" />
Active
</span>
</div>
<div>
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-wider mb-2">Display Mode</p>
{currentDoc.pdfUrl ? (
<div className="flex items-center gap-2 text-xs font-semibold text-ink-900">
<FileText className="w-4 h-4" />
<span>Custom PDF Viewer Active</span>
</div>
) : (
<div className="flex items-center gap-2 text-xs font-semibold text-ink-500">
<Eye className="w-4 h-4" />
<span>Text fallback mode active</span>
</div>
)}
</div>
{currentDoc.pdfUrl && (
<div className="pt-2">
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-wider mb-2">File Source</p>
<a
href={`${fileHost}${currentDoc.pdfUrl}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between p-3.5 rounded-xl border border-dashed border-ink-200 hover:bg-ink-50 hover:border-ink-400 transition-all text-xs font-bold text-ink-700"
>
<span className="truncate max-w-[150px]">{currentDoc.pdfUrl.split('/').pop()}</span>
<ArrowRight className="w-3.5 h-3.5" />
</a>
</div>
)}
<div className="pt-4 border-t border-ink-100">
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-wider mb-2">Text Content Sample</p>
<p className="text-xs text-ink-500 font-mono leading-relaxed line-clamp-4 bg-ink-50 p-3 rounded-lg border border-ink-100">
{currentDoc.content}
</p>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center text-center py-8">
<AlertTriangle className="w-8 h-8 text-ink-500 mb-2" />
<p className="text-sm font-bold text-ink-900">No Document Configured</p>
<p className="text-xs text-ink-500 mt-1">Configure and publish a version to enable partner signatures.</p>
</div>
)}
</div>
</div>
{/* Right Side: Publish New Revision form */}
<div className="lg:col-span-2">
<form onSubmit={handleFormSubmit} className="p-6 md:p-8 bg-ink-0 border border-ink-200 shadow-sm rounded-2xl flex flex-col gap-6">
<div>
<h2 className="text-xl font-bold text-ink-900">Publish New Revision</h2>
<p className="text-xs text-ink-500 mt-1">
Saving a new template automatically deactivates the current active version.
</p>
</div>
{message && (
<div className={`p-4 rounded-xl flex items-start gap-3 border ${message.type === 'success' ? 'bg-ink-100 border-ink-200 text-ink-900' : 'bg-red-500/10 border-red-500/20 text-red-650'}`}>
{message.type === 'success' && <CheckCircle className="w-5 h-5 shrink-0" />}
<p className="text-xs font-semibold">{message.text}</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="md:col-span-1">
<label className="block text-xs font-bold uppercase tracking-wider text-ink-400 mb-2">Version String</label>
<input
type="text"
placeholder="e.g. 1.1"
value={version}
onChange={(e) => setVersion(e.target.value)}
className="w-full px-4 py-2.5 bg-ink-50 border border-ink-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
required
/>
</div>
<div className="md:col-span-3">
<label className="block text-xs font-bold uppercase tracking-wider text-ink-400 mb-2">PDF Template (Optional)</label>
<div
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-3 p-3 rounded-lg border border-dashed border-ink-200 hover:border-ink-400 cursor-pointer bg-ink-50 hover:bg-ink-100/50 transition-colors"
>
<div className="w-8 h-8 rounded-lg bg-ink-0 border border-ink-200 flex items-center justify-center text-ink-500">
<Upload className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-bold text-ink-700 truncate">
{pdfFile ? pdfFile.name : 'Select custom PDF document'}
</p>
<p className="text-[10px] text-ink-500 font-medium">Click to select file</p>
</div>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".pdf"
className="hidden"
/>
</div>
</div>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-ink-400 mb-2">Text Content Fallback</label>
<textarea
rows={8}
placeholder="Insert standard text content here..."
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full px-4 py-3 bg-ink-50 border border-ink-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-mono text-xs leading-relaxed"
required
/>
<p className="text-[10px] text-ink-500 font-medium mt-1.5">
This content will display to partners if no custom PDF has been uploaded.
</p>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-ink-100">
<button
type="submit"
disabled={submitting || uploadingPdf}
className="px-5 py-3 rounded-xl bg-ink-900 text-ink-0 font-bold text-sm hover:bg-ink-800 transition-all disabled:opacity-50 flex items-center gap-2 shadow-sm"
>
{uploadingPdf ? 'Uploading PDF...' : submitting ? 'Publishing...' : 'Publish Template'}
<CheckCircle className="w-4 h-4" />
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default LegalTemplatesPage;

View File

@ -15,8 +15,8 @@ export const loginUser = async (params: LoginParams): Promise<AuthResponse> => {
return response.data;
};
export const refreshAuthToken = async (): Promise<{ accessToken: string }> => {
const response = await axiosInstance.post<{ accessToken: string }>(
export const refreshAuthToken = async (): Promise<AuthResponse> => {
const response = await axiosInstance.post<AuthResponse>(
AUTH_API_ROUTES.refresh
);
return response.data;

View File

@ -1,7 +1,7 @@
import axios from 'axios';
export const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:5001/api/v1',
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1',
headers: {
'Content-Type': 'application/json',
},
@ -41,7 +41,12 @@ axiosInstance.interceptors.response.use(
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (
error.response?.status === 401 &&
!originalRequest._retry &&
!originalRequest.url?.includes('/auth/refresh') &&
!originalRequest.url?.includes('/auth/login')
) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
@ -55,12 +60,10 @@ axiosInstance.interceptors.response.use(
isRefreshing = true;
try {
const { accessToken } = await refreshAuthToken();
const { setAuth, user } = useAuthStore.getState();
const { accessToken, user } = await refreshAuthToken();
const { setAuth } = useAuthStore.getState();
if (user) {
setAuth({ user, accessToken });
}
processQueue(null, accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;

871
Guide.md
View File

@ -1,795 +1,186 @@
Full Stack Architecture & Implementation Guide
===========================================================
# Channel Partner Portal & Secure Asset Management
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.
## Enterprise Architecture & Implementation Guide
Your mindset is **perfection over shortcuts**.
This guide details the complete system architecture, visual design specs, existing code evidence, and future implementation roadmap for the secure, multi-tenant Channel Partner Onboarding Platform.
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.
## 📖 Table of Contents
Store:
1. [Project Overview &amp; Vision](#1-project-overview--vision)
2. [Database Schema (Prisma Models)](#2-database-schema-prisma-models)
3. [Completed Implementations &amp; Code Evidence](#3-completed-implementations--code-evidence)
4. [MinIO S3-Compatible Storage Migration Guide](#4-minio-s3-compatible-storage-migration-guide)
5. [Future Development Roadmap &amp; Milestones](#5-future-development-roadmap--milestones)
* accepted version
* timestamp
* IP
* audit trail
MSA
---
Same flow.
## 1. Project Overview & Vision
Version-controlled.
The Channel Partner Portal is a premium SaaS onboarding and secure document hub designed to look like modern workspaces (Notion/Linear) rather than standard tabular dashboards.
Legally track acceptance.
### Core Product Pillars
Dashboard Experience
====================
* **Secure Document Hub**: Multi-format document rendering engine with sandbox protections.
* **Role-Based Access Control (RBAC)**: Fine-grained permissions defining view-only vs. downloadable scopes.
* **Dynamic Sharing Scopes**: Direct share targeting to specific organizations or filtered to singular organization users.
* **Onboarding legal flow**: Gated portal requiring formal signature and tracking for NDA/MSA documents.
The client dashboard should feel premium.
---
Examples:
## 2. Database Schema (Prisma Models)
Welcome section
Our active PostgreSQL schema in `/Channel-Backend/prisma/schema.prisma` is designed around multi-tenancy and audit compliance:
Recent uploads
| Model | Purpose | Key Relations |
| :------------------ | :--------------------------------------- | :------------------------------------------------------------------- |
| `Organization` | Tenant container | Has many`User` & `SharedAsset` |
| `User` | Authenticated users (Admin/Partner) | Belongs to`Organization`, Has many `DownloadRequest` |
| `Asset` | Physical/URL resource registry | Belongs to`Folder`, Has many `SharedAsset` & `DownloadRequest` |
| `SharedAsset` | Connects assets to organizations/users | Unique on`[assetId, organizationId, userId]` |
| `DownloadRequest` | Pending/Approved requests for download | Unique on`[assetId, userId]` |
| `LegalDocument` | Repository of NDA/MSA text templates | Versioned, Has many`LegalAcceptance` |
| `LegalAcceptance` | Tracks IP/hashes of accepted legal forms | Connected to`User` and `LegalDocument` |
| `AuditLog` | Immutable event tracker | Linked to actor (`User`) |
Shared resources
---
Product cards
## 3. Completed Implementations & Code Evidence
Training center
Here is the exact code evidence showing which files and configuration settings run the current production-ready portal features.
Quick links
### A. Backend security & CSP Configuration
Announcements
We modified **[Channel-Backend/src/app.ts](file:///home/tech4biz/work/channel-partner/Tech4biz-channel/Channel-Backend/src/app.ts#L18-L30)** to support secure document embedding inside frontend iframes without exposing vulnerabilities:
Support
* **Disabled Frameguard**: Removed default `X-Frame-Options: SAMEORIGIN` block.
* **Custom Content Security Policy**: Added `Content-Security-Policy` with the `frame-ancestors` directive allowlisting `http://localhost:5173` and `http://localhost:5000` to satisfy modern Chrome iframe sandbox checks.
* **TypeScript Safety**: Avoided default fallback blockers using `dangerouslyDisableDefaultSrc` and `useDefaults: false`.
Downloads
```typescript
app.use(helmet({
crossOriginResourcePolicy: { policy: "cross-origin" },
contentSecurityPolicy: {
useDefaults: false,
directives: {
"default-src": helmet.contentSecurityPolicy.dangerouslyDisableDefaultSrc,
"frame-ancestors": ["'self'", "http://localhost:5173", "http://localhost:5000"],
},
},
frameguard: false,
}));
```
Bookmarks
### B. Expandable Preview Modal & Multi-Engine Document Reader
Recently viewed
We overhauled the modal system in **[Channel-Frontend/src/pages/AssetsPage.tsx](file:///home/tech4biz/work/channel-partner/Tech4biz-channel/Channel-Frontend/src/pages/AssetsPage.tsx#L1250-L1356)** to support multi-format previews:
Search
* **Expandable Viewport Layout**: Added a Maximize/Minimize toggle button in the modal header. Maximizing updates the classes to `w-[96vw] h-[92vh] max-w-none` to prevent layout clipping and display long documents natively.
* **GitHub Repository Markdown Fetcher**: Added a regex parser (`getGithubRawUrl`) and hook to automatically extract the repository name, fetch the `README.md` raw file in the background, and output the text directly inside a clean, scrollable Inter-font document reader.
* **Office Online Document Preview**: Embedded a Microsoft Office Web Viewer wrapper for PowerPoint, Excel, and Word files, with a clean local-mode notice for developers working on `localhost` endpoints.
* **Secure Image/PDF Viewer**: Uses the standard PDF frame and constrained image container (`max-h-full object-contain`).
Activity timeline
### C. Access Control and Protected Links
Everything should be elegant.
* **Removed Direct Links**: Removed the "Open Native Viewer" button to prevent users from bypassing access controls and downloading documents directly.
* **Protected Website URLs**: Blocked the "Open Link in New Tab" action for URL-based assets. It is now hidden behind the `isDownloadable` or approved download request validation, prompting unapproved users to ask for access first.
* **Download request reset**: Client request states are fully synchronized. When an admin rejects a download request, the frontend allows the user to re-submit a request by resetting the request status back to `PENDING` in the database.
Admin Portal
============
---
Admin should have:
## 4. MinIO S3-Compatible Storage Migration Guide
Partner Management
Currently, files are uploaded to `/uploads` on the backend's local directory. To support scaling, high-performance CDN setups, and enterprise security, we will migrate the static storage engine to **MinIO** (an open-source S3-compatible object storage).
Asset Management
### A. MinIO Deployment Setup (Docker)
Permission Management
Add the MinIO service to your local developer containers or run it in the background:
Preview Mode
```bash
docker run -d \
-p 9000:9000 \
-p 9001:9001 \
--name minio-storage \
-e "MINIO_ROOT_USER=admin" \
-e "MINIO_ROOT_PASSWORD=SuperSecretPassword123" \
minio/minio server /data --console-address ":9001"
```
Analytics
Log in to `http://localhost:9001` and create a private bucket named `secure-assets`.
Audit Logs
### B. Backend Node SDK Integration
Document Management
1. Install AWS S3 SDK packages:
```bash
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner multer-s3
```
2. Create an S3 configuration client (`/src/utils/s3.ts`):
```typescript
import { S3Client } from "@aws-sdk/client-s3";
Legal Management
export const s3Client = new S3Client({
endpoint: process.env.MINIO_ENDPOINT || "http://localhost:9000",
region: "us-east-1", // MinIO defaults
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY || "admin",
secretAccessKey: process.env.MINIO_SECRET_KEY || "SuperSecretPassword123",
},
forcePathStyle: true, // Crucial for MinIO path style endpoints
});
```
Notifications
### C. Securing Pre-Signed Preview URLs
User Management
To prevent unauthorized users from sharing document links, the files inside the MinIO bucket must remain **private**.
Everything should be intuitive.
When a user opens the preview modal:
Database
========
1. The frontend requests a temporary view link from the backend.
2. The backend generates a **pre-signed URL** that expires in **5 minutes**:
```typescript
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { s3Client } from "./s3";
Design an enterprise PostgreSQL schema.
export const generateAssetPreviewUrl = async (fileKey: string): Promise<string> => {
const command = new GetObjectCommand({
Bucket: "secure-assets",
Key: fileKey,
});
Include:
// Generates a link that becomes useless after 300 seconds
return await getSignedUrl(s3Client, command, { expiresIn: 300 });
};
```
3. The frontend renders this temporary link inside the `<iframe>`. Even if the client extracts the URL from source, it will expire and become invalid shortly after.
Users
---
Roles
## 5. Future Development Roadmap & Milestones
Permissions
To finish the product and prepare it for production release, follow these step-by-step milestones.
Organizations
### Milestone 1: MinIO Storage Migration
Channel Partners
- [ ] Install S3 SDK on the backend.
- [ ] Replace file write helper in `asset.controller.ts` with S3 uploads using `PutObjectCommand`.
- [ ] Implement an endpoint on the backend (`/api/v1/assets/:id/preview`) to return a short-term pre-signed URL for document iframes.
Assets
### Milestone 2: Gated Legal Sign-off (NDA & MSA)
Folders
- [ ] Create a `LegalAgreementsModal` on the frontend that displays the active `LegalDocument` of type `NDA` or `MSA`.
- [ ] If a user's `onboardingStatus` is `PENDING_ONBOARDING`, block dashboard navigation and display the modal forcing them to accept.
- [ ] Create backend controllers to record acceptances with actor ID, IP address, and cryptographic hashes in the `LegalAcceptance` table.
Categories
### Milestone 3: Nested Folders & Bulk Operations
Tags
- [ ] Update the assets sidebar to display the nested `Folder` tree structure.
- [ ] Implement bulk share/revoke controllers that let admins select multiple assets and assign them to an entire organization or individual users in a single operation.
Collections
### Milestone 4: Partner Activity Timeline & Audit Logs
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.
- [ ] Hook the `AuditLog` database table into all controller actions.
- [ ] Display an interactive activity timeline on the Admin dashboard showing:
- *Who downloaded what file and when.*
- *Organization onboardings and pending request queues.*

38
run-backend.bat Normal file
View File

@ -0,0 +1,38 @@
@echo off
REM run-backend.bat - Windows startup script for Channel Backend.
echo [INFO] Verifying Docker installation...
where docker >nul 2>nul
if %errorlevel% neq 0 (
echo [ERROR] Docker is not installed or not in PATH. Please install Docker.
exit /b 1
)
echo [INFO] Starting PostgreSQL container...
docker compose up -d postgres || docker-compose up -d postgres
echo [INFO] Checking if database configuration exists...
if not exist Channel-Backend\.env (
echo [INFO] Generating Channel-Backend\.env config...
(
echo PORT=5000
echo DATABASE_URL="postgresql://pipeline_admin:secure_pipeline_2024@localhost:5433/backend_channel?schema=public"
echo JWT_SECRET="super-secret-jwt-key-2026-world-class"
echo NODE_ENV="development"
) > Channel-Backend\.env
)
echo [INFO] Installing backend dependencies...
cd Channel-Backend
call npm install
echo [INFO] Generating Prisma client...
call npx prisma generate
echo [INFO] Aligning database schema with Prisma...
call npx prisma db push
echo [INFO] Seeding database...
call npx ts-node seed.ts
echo [INFO] Starting Express backend dev server...
call npm run dev

85
run-backend.sh Executable file
View File

@ -0,0 +1,85 @@
#!/usr/bin/env bash
# run-backend.sh - Idempotent script to run the Channel Backend.
# This script starts PostgreSQL, configures env vars, runs database setup/seeding, and boots the backend.
set -e
# Visual formatting helper
info() {
echo -e "\033[1;34m[INFO]\033[0m $1"
}
error() {
echo -e "\033[1;31m[ERROR]\033[0m $1"
}
# 1. Verify Docker Installation
if ! command -v docker &> /dev/null; then
error "Docker is not installed. Please install Docker to spin up PostgreSQL."
exit 1
fi
# 2. Verify Docker Daemon is running
if ! docker info &> /dev/null; then
error "Docker daemon is not running. Please start Docker."
exit 1
fi
# 3. Spin up PostgreSQL container
info "Starting PostgreSQL container via docker-compose..."
docker compose up -d postgres || docker-compose up -d postgres
# 4. Wait for PostgreSQL to be ready inside container
info "Waiting for database to accept connections..."
for i in {1..30}; do
if docker exec channel_postgres pg_isready -U pipeline_admin -d backend_channel &> /dev/null; then
info "Database is ready!"
break
fi
if [ $i -eq 30 ]; then
error "Database ready timeout exceeded."
exit 1
fi
sleep 1
done
# 5. Generate Environment Config
if [ ! -f Channel-Backend/.env ]; then
info "Generating Channel-Backend/.env config file..."
cat <<EOT > Channel-Backend/.env
PORT=5000
DATABASE_URL="postgresql://pipeline_admin:secure_pipeline_2024@localhost:5433/backend_channel?schema=public"
JWT_SECRET="super-secret-jwt-key-2026-world-class"
NODE_ENV="development"
EOT
else
info "Channel-Backend/.env file already exists."
fi
# 6. Install Node dependencies
info "Installing backend node packages..."
cd Channel-Backend
npm install
# 7. Generate Prisma Client
info "Generating Prisma client..."
npx prisma generate
# 8. Apply database schema alignments
info "Syncing database schema with Prisma..."
npx prisma db push
# 9. Seed the database (idempotent user / document inserts)
info "Running database seeding..."
npx ts-node seed.ts
# Check if port 5000 is occupied and free it
if lsof -i :5000 &> /dev/null; then
PORT_PID=$(lsof -t -i :5000)
info "Port 5000 is occupied by process $PORT_PID. Freeing port..."
kill -9 $PORT_PID || true
sleep 1
fi
# 9. Start server
info "Starting Express backend dev server..."
npm run dev

16
run-frontend.bat Normal file
View File

@ -0,0 +1,16 @@
@echo off
REM run-frontend.bat - Windows startup script for Channel Frontend.
echo [INFO] Checking if frontend configuration exists...
if not exist Channel-Frontend\.env (
echo [INFO] Generating Channel-Frontend\.env config...
(
echo VITE_API_URL=http://localhost:5000/api/v1
) > Channel-Frontend\.env
)
echo [INFO] Installing frontend dependencies...
cd Channel-Frontend
call npm install
echo [INFO] Starting Vite frontend dev server...
call npm run dev

37
run-frontend.sh Executable file
View File

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# run-frontend.sh - Idempotent script to run the Channel Frontend.
# This script configures env vars, installs packages, and boots the frontend.
set -e
# Visual formatting helper
info() {
echo -e "\033[1;34m[INFO]\033[0m $1"
}
# 1. Generate Environment Config
if [ ! -f Channel-Frontend/.env ]; then
info "Generating Channel-Frontend/.env config file..."
cat <<EOT > Channel-Frontend/.env
VITE_API_URL=http://localhost:5000/api/v1
EOT
else
info "Channel-Frontend/.env file already exists."
fi
# 2. Install Node dependencies
info "Installing frontend node packages..."
cd Channel-Frontend
npm install
# Check if port 5173 is occupied and free it
if lsof -i :5173 &> /dev/null; then
PORT_PID=$(lsof -t -i :5173)
info "Port 5173 is occupied by process $PORT_PID. Freeing port..."
kill -9 $PORT_PID || true
sleep 1
fi
# 3. Start Vite Dev Server
info "Starting Vite frontend dev server..."
npm run dev