complete_foundation
This commit is contained in:
parent
f17eb1ea57
commit
aef4a3ec8b
@ -17,18 +17,19 @@ enum DocumentType {
|
||||
}
|
||||
|
||||
model Organization {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
status String @default("ACTIVE")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
users User[]
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
status String @default("ACTIVE")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
users User[]
|
||||
sharedAssets SharedAsset[]
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
role Role @default(PARTNER_USER)
|
||||
mfaEnabled Boolean @default(true)
|
||||
mfaSecret String?
|
||||
@ -37,22 +38,62 @@ model User {
|
||||
organizationId String?
|
||||
onboardingStatus String @default("PENDING_ONBOARDING")
|
||||
organization Organization? @relation(fields: [organizationId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
acceptances LegalAcceptance[]
|
||||
auditLogs AuditLog[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
acceptances LegalAcceptance[]
|
||||
auditLogs AuditLog[]
|
||||
sharedAssets SharedAsset[]
|
||||
downloadRequests DownloadRequest[]
|
||||
}
|
||||
|
||||
model Asset {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
type String
|
||||
size Int
|
||||
url String
|
||||
version Int @default(1)
|
||||
uploadedBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
type String
|
||||
size Int
|
||||
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 {
|
||||
@ -61,28 +102,30 @@ model Folder {
|
||||
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[]
|
||||
}
|
||||
|
||||
model LegalAcceptance {
|
||||
id String @id @default(uuid())
|
||||
docId String
|
||||
userId String
|
||||
ipAddress String
|
||||
signatureHash String?
|
||||
documentUrl String?
|
||||
acceptedAt DateTime @default(now())
|
||||
document LegalDocument @relation(fields: [docId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
id String @id @default(uuid())
|
||||
docId String
|
||||
userId String
|
||||
ipAddress String
|
||||
signatureHash String?
|
||||
documentUrl String?
|
||||
acceptedAt DateTime @default(now())
|
||||
document LegalDocument @relation(fields: [docId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
|
||||
@ -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({
|
||||
data: {
|
||||
type: 'NDA',
|
||||
version: '1.0',
|
||||
content: 'This is the standard Non-Disclosure Agreement content...',
|
||||
isActive: true,
|
||||
}
|
||||
});
|
||||
console.log('Starting database seeding...');
|
||||
|
||||
await prisma.legalDocument.create({
|
||||
data: {
|
||||
type: 'MSA',
|
||||
version: '1.0',
|
||||
content: 'This is the standard Master Services Agreement content...',
|
||||
isActive: true,
|
||||
}
|
||||
// 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. 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.');
|
||||
}
|
||||
|
||||
console.log('Documents seeded.');
|
||||
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. 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.');
|
||||
}
|
||||
|
||||
// 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());
|
||||
|
||||
@ -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 }));
|
||||
|
||||
@ -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); }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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); }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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); }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
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' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) => ({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
accessToken: null,
|
||||
setAuth: (data) => set({
|
||||
user: data.user,
|
||||
accessToken: data.accessToken,
|
||||
isAuthenticated: true
|
||||
}),
|
||||
logout: () => {
|
||||
set({
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
accessToken: null,
|
||||
isAuthenticated: false
|
||||
});
|
||||
}
|
||||
}));
|
||||
isInitializing: true,
|
||||
setAuth: (data) => set({
|
||||
user: data.user,
|
||||
accessToken: data.accessToken,
|
||||
isAuthenticated: true,
|
||||
isInitializing: false,
|
||||
}),
|
||||
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,
|
||||
isInitializing: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 't4b_auth_store',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
accessToken: state.accessToken,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@ -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
@ -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>
|
||||
<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 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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
314
Channel-Frontend/src/pages/admin/LegalTemplatesPage.tsx
Normal file
314
Channel-Frontend/src/pages/admin/LegalTemplatesPage.tsx
Normal 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;
|
||||
@ -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;
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
setAuth({ user, accessToken });
|
||||
|
||||
processQueue(null, accessToken);
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||
|
||||
871
Guide.md
871
Guide.md
@ -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 & Vision](#1-project-overview--vision)
|
||||
2. [Database Schema (Prisma Models)](#2-database-schema-prisma-models)
|
||||
3. [Completed Implementations & 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 & 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
38
run-backend.bat
Normal 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
85
run-backend.sh
Executable 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
16
run-frontend.bat
Normal 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
37
run-frontend.sh
Executable 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
|
||||
Loading…
Reference in New Issue
Block a user