complete_foundation
This commit is contained in:
parent
f17eb1ea57
commit
aef4a3ec8b
@ -17,18 +17,19 @@ enum DocumentType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Organization {
|
model Organization {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
status String @default("ACTIVE")
|
status String @default("ACTIVE")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
users User[]
|
users User[]
|
||||||
|
sharedAssets SharedAsset[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
passwordHash String
|
passwordHash String
|
||||||
role Role @default(PARTNER_USER)
|
role Role @default(PARTNER_USER)
|
||||||
mfaEnabled Boolean @default(true)
|
mfaEnabled Boolean @default(true)
|
||||||
mfaSecret String?
|
mfaSecret String?
|
||||||
@ -37,22 +38,62 @@ model User {
|
|||||||
organizationId String?
|
organizationId String?
|
||||||
onboardingStatus String @default("PENDING_ONBOARDING")
|
onboardingStatus String @default("PENDING_ONBOARDING")
|
||||||
organization Organization? @relation(fields: [organizationId], references: [id])
|
organization Organization? @relation(fields: [organizationId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
acceptances LegalAcceptance[]
|
acceptances LegalAcceptance[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
|
sharedAssets SharedAsset[]
|
||||||
|
downloadRequests DownloadRequest[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Asset {
|
model Asset {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
title String
|
title String
|
||||||
type String
|
type String
|
||||||
size Int
|
size Int
|
||||||
url String
|
url String
|
||||||
version Int @default(1)
|
version Int @default(1)
|
||||||
uploadedBy String
|
uploadedBy String
|
||||||
createdAt DateTime @default(now())
|
description String? @db.Text
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
model Folder {
|
||||||
@ -61,28 +102,30 @@ model Folder {
|
|||||||
parentId String?
|
parentId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
assets Asset[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model LegalDocument {
|
model LegalDocument {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
type DocumentType
|
type DocumentType
|
||||||
version String
|
version String
|
||||||
content String @db.Text
|
content String
|
||||||
isActive Boolean @default(false)
|
isActive Boolean @default(false)
|
||||||
|
pdfUrl String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
acceptances LegalAcceptance[]
|
acceptances LegalAcceptance[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model LegalAcceptance {
|
model LegalAcceptance {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
docId String
|
docId String
|
||||||
userId String
|
userId String
|
||||||
ipAddress String
|
ipAddress String
|
||||||
signatureHash String?
|
signatureHash String?
|
||||||
documentUrl String?
|
documentUrl String?
|
||||||
acceptedAt DateTime @default(now())
|
acceptedAt DateTime @default(now())
|
||||||
document LegalDocument @relation(fields: [docId], references: [id])
|
document LegalDocument @relation(fields: [docId], references: [id])
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
model AuditLog {
|
model AuditLog {
|
||||||
|
|||||||
@ -1,27 +1,185 @@
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
import prisma from './src/utils/db';
|
import prisma from './src/utils/db';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
async function seed() {
|
async function seed() {
|
||||||
await prisma.legalDocument.create({
|
console.log('Starting database seeding...');
|
||||||
data: {
|
|
||||||
type: 'NDA',
|
|
||||||
version: '1.0',
|
|
||||||
content: 'This is the standard Non-Disclosure Agreement content...',
|
|
||||||
isActive: true,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.legalDocument.create({
|
// 1. Seed Active Legal Documents
|
||||||
data: {
|
let ndaDoc = await prisma.legalDocument.findFirst({
|
||||||
type: 'MSA',
|
where: { type: 'NDA', version: '1.0' }
|
||||||
version: '1.0',
|
|
||||||
content: 'This is the standard Master Services Agreement content...',
|
|
||||||
isActive: true,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
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 app: Express = express();
|
||||||
const PORT = process.env.PORT || 5000;
|
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(cors({ origin: true, credentials: true }));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
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 { AssetService } from '../services/asset.service';
|
||||||
import { AuthRequest } from '../middleware/auth.middleware';
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
|
|
||||||
@ -7,32 +7,123 @@ export class AssetController {
|
|||||||
|
|
||||||
public uploadAsset = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
public uploadAsset = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
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 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);
|
res.status(201).json(asset);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
}
|
}
|
||||||
|
|
||||||
public listAssets = async (req: Request, res: Response, next: NextFunction) => {
|
public listAssets = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
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);
|
res.status(200).json(assets);
|
||||||
} catch(err) { next(err); }
|
} 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 {
|
try {
|
||||||
await this.assetService.deleteAsset(req.params.id);
|
await this.assetService.deleteAsset(req.params.id);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch(err) { next(err); }
|
} 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, {
|
res.cookie('refreshToken', result.refreshToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: 'strict',
|
sameSite: 'lax',
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
|
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -66,7 +66,7 @@ export class AuthController {
|
|||||||
res.cookie('refreshToken', result.refreshToken, {
|
res.cookie('refreshToken', result.refreshToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: 'strict',
|
sameSite: 'lax',
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
|
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -93,4 +93,19 @@ export class AuthController {
|
|||||||
res.status(200).json(partners);
|
res.status(200).json(partners);
|
||||||
} catch(err) { next(err); }
|
} 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']),
|
type: z.enum(['NDA', 'MSA']),
|
||||||
version: z.string(),
|
version: z.string(),
|
||||||
content: z.string().min(1),
|
content: z.string().min(1),
|
||||||
|
pdfUrl: z.string().optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export class LegalController {
|
export class LegalController {
|
||||||
@ -98,4 +99,11 @@ export class LegalController {
|
|||||||
res.status(200).json({ message: 'Partner approved successfully' });
|
res.status(200).json({ message: 'Partner approved successfully' });
|
||||||
} catch(err) { next(err); }
|
} 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.post('/upload', requireRole('ADMIN'), upload.single('file'), assetController.uploadAsset);
|
||||||
router.get('/', assetController.listAssets);
|
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);
|
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;
|
export default router;
|
||||||
|
|||||||
@ -11,6 +11,7 @@ router.post('/login', authController.login);
|
|||||||
router.post('/refresh', authController.refresh);
|
router.post('/refresh', authController.refresh);
|
||||||
|
|
||||||
// Invite Flow
|
// Invite Flow
|
||||||
|
router.get('/me', authenticate, authController.getCurrentUser);
|
||||||
router.post('/invite', authenticate, requireRole('ADMIN'), authController.invitePartner);
|
router.post('/invite', authenticate, requireRole('ADMIN'), authController.invitePartner);
|
||||||
router.get('/invite/:token', authController.validateInvite);
|
router.get('/invite/:token', authController.validateInvite);
|
||||||
router.post('/invite/accept', authController.acceptInvite);
|
router.post('/invite/accept', authController.acceptInvite);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { LegalController } from '../controllers/legal.controller';
|
import { LegalController } from '../controllers/legal.controller';
|
||||||
import { authenticate, requireRole } from '../middleware/auth.middleware';
|
import { authenticate, requireRole } from '../middleware/auth.middleware';
|
||||||
|
import { upload } from '../middleware/upload.middleware';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const legalController = new LegalController();
|
const legalController = new LegalController();
|
||||||
@ -14,6 +15,7 @@ router.post('/documents', requireRole('ADMIN'), legalController.create);
|
|||||||
router.get('/documents/active/:type', legalController.getActive);
|
router.get('/documents/active/:type', legalController.getActive);
|
||||||
router.post('/accept', legalController.accept);
|
router.post('/accept', legalController.accept);
|
||||||
router.post('/sign', legalController.sign);
|
router.post('/sign', legalController.sign);
|
||||||
|
router.post('/upload', upload.single('file'), legalController.uploadSignedDoc);
|
||||||
router.get('/my-acceptances', legalController.myAcceptances);
|
router.get('/my-acceptances', legalController.myAcceptances);
|
||||||
|
|
||||||
// Admin Approval Routes
|
// Admin Approval Routes
|
||||||
|
|||||||
@ -2,20 +2,315 @@ import prisma from '../utils/db';
|
|||||||
|
|
||||||
export class AssetService {
|
export class AssetService {
|
||||||
public async createAsset(data: any) {
|
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({
|
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' }
|
orderBy: { createdAt: 'desc' }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAssetById(id: string) {
|
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) {
|
public async deleteAsset(id: string) {
|
||||||
return await prisma.asset.delete({ where: { id } });
|
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';
|
import { AppError } from '../utils/errors';
|
||||||
|
|
||||||
export class AuthService {
|
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) {
|
public async register(data: any) {
|
||||||
const existing = await prisma.user.findUnique({ where: { email: data.email } });
|
const existing = await prisma.user.findUnique({ where: { email: data.email } });
|
||||||
if (existing) throw new AppError('Email already in use', 400);
|
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 passwordHash = await bcrypt.hash(data.password, 10);
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: data.email,
|
email: data.email,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
role: data.role || 'PARTNER_USER',
|
role: data.role || 'PARTNER_USER',
|
||||||
organizationId: data.organizationId || null,
|
organizationId: orgId,
|
||||||
onboardingStatus: data.role === 'ADMIN' ? 'APPROVED' : 'PENDING_ONBOARDING'
|
onboardingStatus: data.role === 'ADMIN' ? 'APPROVED' : 'PENDING_ONBOARDING'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const { passwordHash: _, ...userWithoutPassword } = user;
|
return this.getUserById(user.id);
|
||||||
return userWithoutPassword;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async invitePartner(email: string, organizationId?: string) {
|
public async invitePartner(email: string, organizationId?: string) {
|
||||||
const existing = await prisma.user.findUnique({ where: { email } });
|
const existing = await prisma.user.findUnique({ where: { email } });
|
||||||
if (existing) throw new AppError('Email already in use', 400);
|
if (existing) throw new AppError('Email already in use', 400);
|
||||||
|
|
||||||
|
let orgId = organizationId || null;
|
||||||
|
if (!orgId) {
|
||||||
|
orgId = await this.getOrCreateOrganizationForEmail(email);
|
||||||
|
}
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const inviteToken = crypto.randomBytes(32).toString('hex');
|
const inviteToken = crypto.randomBytes(32).toString('hex');
|
||||||
const inviteTokenExp = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
const inviteTokenExp = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
passwordHash: '', // Set on accept
|
passwordHash: '', // Set on accept
|
||||||
role: 'PARTNER_USER',
|
role: 'PARTNER_USER',
|
||||||
organizationId: organizationId || null,
|
organizationId: orgId,
|
||||||
inviteToken,
|
inviteToken,
|
||||||
inviteTokenExp,
|
inviteTokenExp,
|
||||||
onboardingStatus: 'PENDING_ONBOARDING',
|
onboardingStatus: 'PENDING_ONBOARDING',
|
||||||
@ -76,8 +105,8 @@ export class AuthService {
|
|||||||
const accessToken = jwt.sign({ userId: updatedUser.id, role: updatedUser.role }, secret, { expiresIn: '15m' });
|
const accessToken = jwt.sign({ userId: updatedUser.id, role: updatedUser.role }, secret, { expiresIn: '15m' });
|
||||||
const refreshToken = jwt.sign({ userId: updatedUser.id }, secret, { expiresIn: '7d' });
|
const refreshToken = jwt.sign({ userId: updatedUser.id }, secret, { expiresIn: '7d' });
|
||||||
|
|
||||||
const { passwordHash: _, ...userWithoutPassword } = updatedUser;
|
const userWithOrg = await this.getUserById(updatedUser.id);
|
||||||
return { user: userWithoutPassword, accessToken, refreshToken };
|
return { user: userWithOrg, accessToken, refreshToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async login(email: string, passwordString: string) {
|
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 accessToken = jwt.sign({ userId: user.id, role: user.role }, secret, { expiresIn: '15m' });
|
||||||
const refreshToken = jwt.sign({ userId: user.id }, secret, { expiresIn: '7d' });
|
const refreshToken = jwt.sign({ userId: user.id }, secret, { expiresIn: '7d' });
|
||||||
|
|
||||||
const { passwordHash: _, ...userWithoutPassword } = user;
|
const userWithOrg = await this.getUserById(user.id);
|
||||||
return { user: userWithoutPassword, accessToken, refreshToken };
|
return { user: userWithOrg, accessToken, refreshToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async refresh(refreshToken: string) {
|
public async refresh(refreshToken: string) {
|
||||||
@ -107,7 +136,8 @@ export class AuthService {
|
|||||||
if (!user) throw new AppError('Invalid refresh token', 401);
|
if (!user) throw new AppError('Invalid refresh token', 401);
|
||||||
|
|
||||||
const newAccessToken = jwt.sign({ userId: user.id, role: user.role }, secret, { expiresIn: '15m' });
|
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) {
|
} catch(err) {
|
||||||
throw new AppError('Invalid or expired refresh token', 401);
|
throw new AppError('Invalid or expired refresh token', 401);
|
||||||
}
|
}
|
||||||
@ -128,4 +158,60 @@ export class AuthService {
|
|||||||
orderBy: { createdAt: 'desc' },
|
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;
|
type: DocumentType;
|
||||||
version: string;
|
version: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
pdfUrl?: string | null;
|
||||||
}) {
|
}) {
|
||||||
// Deprecate older active versions of the same type
|
// Deprecate older active versions of the same type
|
||||||
if (data.type) {
|
if (data.type) {
|
||||||
|
|||||||
@ -8,6 +8,13 @@ export class OrganizationService {
|
|||||||
public async getAll() {
|
public async getAll() {
|
||||||
return await prisma.organization.findMany({
|
return await prisma.organization.findMany({
|
||||||
include: {
|
include: {
|
||||||
|
users: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
_count: {
|
_count: {
|
||||||
select: { users: true }
|
select: { users: true }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { router } from "./app/router";
|
import { router } from "./app/router";
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useThemeStore } from './hooks/use-theme';
|
import { useThemeStore } from './hooks/use-theme';
|
||||||
|
import { useAuthStore } from './hooks/use-auth';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@ -12,10 +13,29 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
const initTheme = useThemeStore(state => state.initTheme);
|
const initTheme = useThemeStore(state => state.initTheme);
|
||||||
|
const { isInitializing, checkAuth } = useAuthStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initTheme();
|
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 (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@ -23,3 +43,4 @@ export const App = () => {
|
|||||||
</QueryClientProvider>
|
</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 { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useThemeStore } from '../../hooks/use-theme';
|
import { useThemeStore } from '../../hooks/use-theme';
|
||||||
import { useAuthStore } from '../../hooks/use-auth';
|
import { useAuthStore } from '../../hooks/use-auth';
|
||||||
import { ShieldCheck, BarChart3, ClipboardCheck, FolderGit2, BookCopy, Users, LogOut, Menu, X, Sun, Moon, ChevronRight } from 'lucide-react';
|
import { ShieldCheck, BarChart3, ClipboardCheck, FolderGit2, BookCopy, Users, LogOut, Menu, X, Sun, Moon, ChevronRight } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { axiosInstance } from '../../services/axios';
|
||||||
|
|
||||||
export const AdminLayout: React.FC = () => {
|
export const AdminLayout: React.FC = () => {
|
||||||
const { user, logout } = useAuthStore();
|
const { user, logout } = useAuthStore();
|
||||||
@ -11,39 +12,58 @@ export const AdminLayout: React.FC = () => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
navigate('/login');
|
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 = [
|
const navItems = [
|
||||||
{ name: 'Partners', path: '/admin/partners', icon: Users },
|
{ name: 'Partners', path: '/admin/partners', icon: Users },
|
||||||
{ name: 'Approvals Queue', path: '/admin/approvals', icon: ClipboardCheck },
|
{ name: 'Approvals Queue', path: '/admin/approvals', icon: ClipboardCheck },
|
||||||
|
{ name: 'Legal Templates', path: '/admin/legal', icon: ShieldCheck },
|
||||||
{ name: 'Manage Catalog', path: '/admin/assets', icon: FolderGit2 },
|
{ name: 'Manage Catalog', path: '/admin/assets', icon: FolderGit2 },
|
||||||
{ name: 'Analytics', path: '/admin/analytics', icon: BarChart3 },
|
{ name: 'Analytics', path: '/admin/analytics', icon: BarChart3 },
|
||||||
{ name: 'Blog CMS', path: '/admin/blog', icon: BookCopy }
|
{ name: 'Blog CMS', path: '/admin/blog', icon: BookCopy }
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
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 ── */}
|
{/* ── 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 */}
|
{/* 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">
|
<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">
|
<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-white dark:text-slate-900" />
|
<ShieldCheck className="w-5 h-5 text-ink-0" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<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-lg font-extrabold tracking-tight leading-none text-ink-900">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-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Admin Console</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 px-4 py-8 space-y-2 overflow-y-auto">
|
<nav className="flex-1 px-4 py-8 space-y-2 overflow-y-auto">
|
||||||
{navItems.map(item => {
|
{navItems.map(item => {
|
||||||
@ -55,43 +75,47 @@ export const AdminLayout: React.FC = () => {
|
|||||||
to={item.path}
|
to={item.path}
|
||||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group ${
|
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-slate-900 dark:bg-white text-white dark:text-slate-900 shadow-md'
|
? 'bg-ink-100 text-ink-900 border border-ink-300 shadow-sm'
|
||||||
: 'text-slate-500 dark:text-white/50 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5'
|
: '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>
|
<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>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* 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">
|
<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
|
<button
|
||||||
onClick={toggleTheme}
|
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" />}
|
{theme === 'dark' ? <Sun className="w-4 h-4 group-hover:rotate-90 transition-transform" /> : <Moon className="w-4 h-4 group-hover:-rotate-12 transition-transform" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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="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-slate-900 dark:bg-white flex items-center justify-center text-white dark:text-slate-900 font-bold 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()}
|
{user?.email?.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-bold text-slate-900 dark:text-white truncate">{user?.email}</p>
|
<p className="text-sm font-bold text-ink-900 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-[10px] uppercase font-bold text-ink-500 tracking-wider truncate">Administrator</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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" />
|
<LogOut className="w-4 h-4" />
|
||||||
<span>Sign Out</span>
|
<span>Sign Out</span>
|
||||||
@ -100,20 +124,19 @@ export const AdminLayout: React.FC = () => {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* ── Main Content Area ── */}
|
{/* ── Main Content Area ── */}
|
||||||
<main className="flex-1 flex flex-col relative w-full overflow-y-auto">
|
<main className="flex-1 flex flex-col relative w-full overflow-y-auto bg-ink-50">
|
||||||
{/* Ambient Background Glows */}
|
{/* 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 top-0 right-0 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none -z-10" />
|
||||||
<div className="fixed bottom-0 left-[20%] w-[500px] h-[500px] bg-slate-200/30 dark:bg-white/5 rounded-full blur-[100px] pointer-events-none -z-10" />
|
|
||||||
|
|
||||||
{/* Mobile Header */}
|
{/* 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">
|
<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">
|
<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-white dark:text-slate-900" />
|
<ShieldCheck className="w-4 h-4 text-ink-0" />
|
||||||
</div>
|
</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>
|
</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" />
|
<Menu className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
@ -122,44 +145,69 @@ export const AdminLayout: React.FC = () => {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{mobileOpen && (
|
{mobileOpen && (
|
||||||
<>
|
<>
|
||||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setMobileOpen(false)} className="fixed inset-0 bg-slate-900/40 dark:bg-black/60 backdrop-blur-sm z-50 md:hidden" />
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setMobileOpen(false)} className="fixed inset-0 bg-ink-900/40 backdrop-blur-sm z-50 md:hidden" />
|
||||||
<motion.div initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }} transition={{ type: 'spring', damping: 25, stiffness: 200 }} className="fixed right-0 top-0 bottom-0 w-72 bg-white dark:bg-[#0A0A0A] shadow-2xl z-50 border-l border-slate-200 dark:border-white/10 flex flex-col md:hidden">
|
<motion.div initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }} transition={{ type: 'spring', damping: 25, stiffness: 200 }} className="fixed right-0 top-0 bottom-0 w-72 bg-ink-0 shadow-2xl z-50 border-l border-ink-200 flex flex-col md:hidden">
|
||||||
<div className="p-4 border-b border-slate-100 dark:border-white/10 flex items-center justify-between">
|
<div className="p-4 border-b border-ink-200 flex items-center justify-between">
|
||||||
<span className="font-extrabold text-slate-900 dark:text-white">Menu</span>
|
<span className="font-extrabold text-ink-900">Menu</span>
|
||||||
<button onClick={() => setMobileOpen(false)} className="p-2 rounded-lg bg-slate-100 dark:bg-white/10">
|
<button onClick={() => setMobileOpen(false)} className="p-2 rounded-lg bg-ink-100">
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
|
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||||
{navItems.map(item => (
|
{navItems.map(item => (
|
||||||
<Link key={item.path} to={item.path} onClick={() => setMobileOpen(false)} className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold ${location.pathname === item.path ? 'bg-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.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>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="p-4 border-t border-slate-100 dark:border-white/10 space-y-4">
|
<div className="p-4 border-t border-ink-200 space-y-4">
|
||||||
<button onClick={toggleTheme} className="flex items-center justify-between w-full p-3 rounded-xl bg-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">
|
<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" />}
|
Theme {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => { setMobileOpen(false); handleLogout(); }} className="w-full py-3 rounded-xl bg-red-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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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">
|
<div className="flex-1 w-full max-w-[1600px] px-6 py-10 md:px-12 mx-auto relative z-10">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* 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">
|
<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-slate-500 dark:text-white/40">
|
<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>
|
<p>© 2026 Tech4Biz Solutions Inc. Admin Console.</p>
|
||||||
<div className="flex gap-6">
|
<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-ink-900 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">System Status</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@ -24,20 +24,20 @@ export const ClientLayout: React.FC = () => {
|
|||||||
const isApproved = user?.onboardingStatus === 'APPROVED';
|
const isApproved = user?.onboardingStatus === 'APPROVED';
|
||||||
|
|
||||||
return (
|
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 ── */}
|
{/* ── 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 */}
|
{/* 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">
|
<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">
|
<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-white" />
|
<ShieldCheck className="w-5 h-5 text-ink-0" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<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-lg font-extrabold tracking-tight leading-none text-ink-900">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-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Client Portal</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -53,11 +53,11 @@ export const ClientLayout: React.FC = () => {
|
|||||||
to={item.path}
|
to={item.path}
|
||||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group ${
|
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group ${
|
||||||
isActive
|
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'
|
? 'bg-ink-100 text-ink-900 border border-ink-300 shadow-sm'
|
||||||
: 'text-slate-500 dark:text-white/50 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/5'
|
: '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>
|
<span>{item.label}</span>
|
||||||
{isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
|
{isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
|
||||||
</Link>
|
</Link>
|
||||||
@ -66,31 +66,31 @@ export const ClientLayout: React.FC = () => {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* 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">
|
<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
|
<button
|
||||||
onClick={toggleTheme}
|
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" />}
|
{theme === 'dark' ? <Sun className="w-4 h-4 group-hover:rotate-90 transition-transform" /> : <Moon className="w-4 h-4 group-hover:-rotate-12 transition-transform" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
<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>
|
<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-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'}`}>
|
<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 ? <CheckCircle className="w-3 h-3" /> : <Clock className="w-3 h-3" />}
|
||||||
{isApproved ? 'Verified' : 'Pending'}
|
{isApproved ? 'Verified' : 'Pending'}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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" />
|
<LogOut className="w-4 h-4" />
|
||||||
<span>Sign Out</span>
|
<span>Sign Out</span>
|
||||||
@ -99,20 +99,20 @@ export const ClientLayout: React.FC = () => {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* ── Main Content Area ── */}
|
{/* ── 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 */}
|
{/* 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 */}
|
{/* 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">
|
<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">
|
<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-white" />
|
<ShieldCheck className="w-4 h-4 text-ink-0" />
|
||||||
</div>
|
</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>
|
</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" />
|
<Menu className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
@ -121,27 +121,27 @@ export const ClientLayout: React.FC = () => {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{mobileOpen && (
|
{mobileOpen && (
|
||||||
<>
|
<>
|
||||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setMobileOpen(false)} className="fixed inset-0 bg-slate-900/40 dark:bg-black/60 backdrop-blur-sm z-50 md:hidden" />
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setMobileOpen(false)} className="fixed inset-0 bg-ink-900/40 backdrop-blur-sm z-50 md:hidden" />
|
||||||
<motion.div initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }} transition={{ type: 'spring', damping: 25, stiffness: 200 }} className="fixed right-0 top-0 bottom-0 w-72 bg-white dark:bg-[#0A0A0A] shadow-2xl z-50 border-l border-slate-200 dark:border-white/10 flex flex-col md:hidden">
|
<motion.div initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }} transition={{ type: 'spring', damping: 25, stiffness: 200 }} className="fixed right-0 top-0 bottom-0 w-72 bg-ink-0 shadow-2xl z-50 border-l border-ink-200 flex flex-col md:hidden">
|
||||||
<div className="p-4 border-b border-slate-100 dark:border-white/10 flex items-center justify-between">
|
<div className="p-4 border-b border-ink-200 flex items-center justify-between">
|
||||||
<span className="font-extrabold text-slate-900 dark:text-white">Menu</span>
|
<span className="font-extrabold text-ink-900">Menu</span>
|
||||||
<button onClick={() => setMobileOpen(false)} className="p-2 rounded-lg bg-slate-100 dark:bg-white/10">
|
<button onClick={() => setMobileOpen(false)} className="p-2 rounded-lg bg-ink-100">
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
|
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||||
{navItems.map(item => (
|
{navItems.map(item => (
|
||||||
<Link key={item.path} to={item.path} onClick={() => setMobileOpen(false)} className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold ${location.pathname === item.path ? 'bg-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.icon className="w-5 h-5" />
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="p-4 border-t border-slate-100 dark:border-white/10 space-y-4">
|
<div className="p-4 border-t border-ink-200 space-y-4">
|
||||||
<button onClick={toggleTheme} className="flex items-center justify-between w-full p-3 rounded-xl bg-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">
|
<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" />}
|
Theme {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => { setMobileOpen(false); handleLogout(); }} className="w-full py-3 rounded-xl bg-red-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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
@ -153,12 +153,12 @@ export const ClientLayout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* 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">
|
<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-slate-500 dark:text-white/40">
|
<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>
|
<p>© 2026 Tech4Biz Solutions Inc. All rights reserved.</p>
|
||||||
<div className="flex gap-6">
|
<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-ink-900 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">Terms</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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 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 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 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
|
// Dummy Components for routing
|
||||||
const LoadingFallback = () => (
|
const LoadingFallback = () => (
|
||||||
<div className="flex h-screen w-full items-center justify-center bg-ink-50">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -125,7 +126,11 @@ export const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'legal',
|
path: 'legal',
|
||||||
element: <LegalPage />,
|
element: (
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<LegalTemplatesPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'analytics',
|
path: 'analytics',
|
||||||
|
|||||||
@ -20,19 +20,19 @@ export const MainLayout = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Sidebar */}
|
||||||
<motion.aside
|
<motion.aside
|
||||||
initial={{ x: -300 }}
|
initial={{ x: -300 }}
|
||||||
animate={{ x: 0 }}
|
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="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)]">
|
<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-white w-6 h-6 absolute" />
|
<Hexagon className="text-ink-0 w-6 h-6 absolute" />
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -41,8 +41,8 @@ export const MainLayout = () => {
|
|||||||
<Link
|
<Link
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={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"
|
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-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)]' }}
|
activeProps={{ className: 'bg-ink-100 text-ink-900 border border-ink-300 shadow-sm' }}
|
||||||
activeOptions={{ exact: item.path === '/' }}
|
activeOptions={{ exact: item.path === '/' }}
|
||||||
>
|
>
|
||||||
<item.icon className="w-5 h-5 transition-transform group-hover:scale-110" />
|
<item.icon className="w-5 h-5 transition-transform group-hover:scale-110" />
|
||||||
@ -51,23 +51,23 @@ export const MainLayout = () => {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</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">
|
<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
|
<button
|
||||||
onClick={toggleTheme}
|
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" />}
|
{theme === 'dark' ? <Sun className="w-4 h-4 group-hover:rotate-90 transition-transform" /> : <Moon className="w-4 h-4 group-hover:-rotate-12 transition-transform" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<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="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-purple-500 to-blue-500 flex items-center justify-center text-white text-sm font-bold shadow-lg border border-white/10">
|
<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()}
|
{user?.email?.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-bold text-slate-900 dark:text-white truncate">{user?.email}</p>
|
<p className="text-sm font-bold text-ink-900 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-xs text-ink-500 font-medium truncate tracking-wide">{user?.role}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -75,7 +75,7 @@ export const MainLayout = () => {
|
|||||||
logout();
|
logout();
|
||||||
window.location.href = '/login';
|
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" />
|
<LogOut className="w-4 h-4" />
|
||||||
SECURE LOGOUT
|
SECURE LOGOUT
|
||||||
@ -84,10 +84,10 @@ export const MainLayout = () => {
|
|||||||
</motion.aside>
|
</motion.aside>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* 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">
|
<main className="flex-1 flex flex-col relative overflow-hidden bg-ink-50 transition-colors duration-500">
|
||||||
{/* Ambient Glow */}
|
{/* Soft Monochromatic Accent Glows */}
|
||||||
<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 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-purple-500/5 dark:bg-purple-500/5 rounded-full blur-[120px] 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">
|
<div className="flex-1 overflow-y-auto p-10 relative z-10">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
@ -96,3 +96,4 @@ export const MainLayout = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
export default MainLayout;
|
||||||
|
|||||||
@ -37,12 +37,12 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
: "[Pending Signature]";
|
: "[Pending Signature]";
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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 items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-50 border border-primary-100">
|
<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-primary-700" />
|
<FileText className="h-5 w-5 text-ink-900" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-sm font-bold text-ink-800">
|
<span className="block text-sm font-bold text-ink-800">
|
||||||
@ -56,16 +56,16 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{signatureDataUrl && (
|
{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" />
|
<ShieldCheck className="h-3.5 w-3.5" />
|
||||||
<span>Cryptographically Signed</span>
|
<span>Cryptographically Signed</span>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleZoomOut}
|
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"
|
title="Zoom Out"
|
||||||
>
|
>
|
||||||
<ZoomOut className="h-4 w-4" />
|
<ZoomOut className="h-4 w-4" />
|
||||||
@ -76,7 +76,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleZoomIn}
|
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"
|
title="Zoom In"
|
||||||
>
|
>
|
||||||
<ZoomIn className="h-4 w-4" />
|
<ZoomIn className="h-4 w-4" />
|
||||||
@ -84,7 +84,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleZoomReset}
|
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"
|
title="Reset Zoom"
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
@ -96,7 +96,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
{/* Doc page area */}
|
{/* 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="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
|
<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={{
|
style={{
|
||||||
maxWidth: "780px",
|
maxWidth: "780px",
|
||||||
transform: `scale(${zoom / 100})`,
|
transform: `scale(${zoom / 100})`,
|
||||||
@ -105,13 +105,13 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Document Header / Letterhead */}
|
{/* Document Header / Letterhead */}
|
||||||
<div className="text-center mb-10 pb-6 border-b-2 border-slate-900">
|
<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-slate-900 font-sans mb-2">
|
<h1 className="text-xl md:text-2xl font-bold uppercase tracking-wider text-ink-900 font-sans mb-2">
|
||||||
{documentType === "NDA"
|
{documentType === "NDA"
|
||||||
? "Mutual Non-Disclosure Agreement"
|
? "Mutual Non-Disclosure Agreement"
|
||||||
: "Master Services Agreement"}
|
: "Master Services Agreement"}
|
||||||
</h1>
|
</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
|
Tech4Biz Technology Integration Portal
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -125,7 +125,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
execution below (the "Effective Date"), by and between:
|
execution below (the "Effective Date"), by and between:
|
||||||
</p>
|
</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>
|
<p>
|
||||||
<strong>TECH4BIZ SOLUTIONS INC.</strong>, a corporation
|
<strong>TECH4BIZ SOLUTIONS INC.</strong>, a corporation
|
||||||
organized and existing under the laws of Delaware, with its
|
organized and existing under the laws of Delaware, with its
|
||||||
@ -143,7 +143,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
WHEREAS, Tech4Biz and the Company (collectively referred to as
|
||||||
the "Parties" and individually as a "Party") desire to share
|
the "Parties" and individually as a "Party") desire to share
|
||||||
proprietary information to evaluate a potential business
|
proprietary information to evaluate a potential business
|
||||||
@ -158,7 +158,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<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">
|
||||||
1. Definition of Confidential Information
|
1. Definition of Confidential Information
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
@ -174,7 +174,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
2. Obligations of Confidentiality and Non-Use
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
@ -190,7 +190,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
3. Permitted Disclosures
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
@ -203,7 +203,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
4. Term and Survival
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
@ -216,7 +216,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
5. Governing Law and Jurisdiction
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
@ -237,7 +237,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
by and between:
|
by and between:
|
||||||
</p>
|
</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>
|
<p>
|
||||||
<strong>TECH4BIZ SOLUTIONS INC.</strong>, a Delaware
|
<strong>TECH4BIZ SOLUTIONS INC.</strong>, a Delaware
|
||||||
corporation, with its principal office at 100 Innovation Way,
|
corporation, with its principal office at 100 Innovation Way,
|
||||||
@ -252,7 +252,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
WHEREAS, Tech4Biz provides high-fidelity silicon designs, custom
|
||||||
compilation systems, and software engineering consulting; and
|
compilation systems, and software engineering consulting; and
|
||||||
the Client wishes to engage Tech4Biz to access such tools and
|
the Client wishes to engage Tech4Biz to access such tools and
|
||||||
@ -265,7 +265,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<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">
|
||||||
1. Scope of Work and Deliverables
|
1. Scope of Work and Deliverables
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
@ -280,7 +280,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
2. Intellectual Property and Licensing
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
@ -295,7 +295,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
3. Payment Terms and Financial Covenants
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
@ -308,7 +308,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
4. Limitation of Liability
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
@ -321,7 +321,7 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
5. Confidentiality
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
@ -336,22 +336,22 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Signatures Section */}
|
{/* Signatures Section */}
|
||||||
<div className="mt-14 pt-8 border-t border-slate-300">
|
<div className="mt-14 pt-8 border-t border-ink-300">
|
||||||
<p className="text-xs uppercase text-slate-600 font-sans font-bold mb-6 text-center tracking-widest">
|
<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
|
IN WITNESS WHEREOF, the Parties have executed this Agreement as of
|
||||||
the dates indicated below.
|
the dates indicated below.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 text-left font-sans text-xs">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 text-left font-sans text-xs">
|
||||||
<div className="space-y-4">
|
<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.
|
For: Tech4Biz Solutions Inc.
|
||||||
</p>
|
</p>
|
||||||
<div className="h-16 flex items-end pb-1 border-b border-slate-300 relative">
|
<div className="h-16 flex items-end pb-1 border-b border-ink-300 relative">
|
||||||
<span className="font-serif italic text-base text-primary-700 select-none pb-1">
|
<span className="font-serif italic text-base text-ink-950 select-none pb-1">
|
||||||
Yasha Khandelwal{" "}
|
Yasha Khandelwal{" "}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-slate-600 text-[11px]">
|
<div className="space-y-1 text-ink-600 text-[11px]">
|
||||||
<p>
|
<p>
|
||||||
<strong>Name:</strong> Yasha Khandelwal
|
<strong>Name:</strong> Yasha Khandelwal
|
||||||
</p>
|
</p>
|
||||||
@ -365,23 +365,23 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<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()}
|
For: {companyName.toUpperCase()}
|
||||||
</p>
|
</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 ? (
|
{signatureDataUrl ? (
|
||||||
<img
|
<img
|
||||||
src={signatureDataUrl}
|
src={signatureDataUrl}
|
||||||
alt="Client Signature"
|
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]
|
[Awaiting Signature]
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-slate-600 text-[11px]">
|
<div className="space-y-1 text-ink-600 text-[11px]">
|
||||||
<p>
|
<p>
|
||||||
<strong>Name:</strong> Authorized Representative
|
<strong>Name:</strong> Authorized Representative
|
||||||
</p>
|
</p>
|
||||||
@ -400,3 +400,4 @@ export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
export default DocumentPreview;
|
||||||
|
|||||||
@ -24,24 +24,33 @@ export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
|
|||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
// Set styling for the signature line
|
// Detect theme for stroke color
|
||||||
ctx.strokeStyle = '#050505'; // slate-900 or dark ink
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
ctx.strokeStyle = isDark ? '#fafafa' : '#09090b';
|
||||||
ctx.lineWidth = 3;
|
ctx.lineWidth = 3;
|
||||||
ctx.lineCap = 'round';
|
ctx.lineCap = 'round';
|
||||||
ctx.lineJoin = 'round';
|
ctx.lineJoin = 'round';
|
||||||
|
|
||||||
// Handle high DPI displays for crisp rendering
|
// Handle high DPI displays for crisp rendering
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
// Set actual size in memory (scaled to account for extra pixel density)
|
|
||||||
canvas.width = width * dpr;
|
canvas.width = width * dpr;
|
||||||
canvas.height = height * dpr;
|
canvas.height = height * dpr;
|
||||||
// Set display size
|
|
||||||
canvas.style.width = `${width}px`;
|
canvas.style.width = `${width}px`;
|
||||||
canvas.style.height = `${height}px`;
|
canvas.style.height = `${height}px`;
|
||||||
// Normalize coordinate system to use css pixels
|
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
}, [width, height]);
|
}, [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>) => {
|
const startDrawing = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@ -111,19 +120,18 @@ export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
|
|||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas || !hasSignature) return;
|
if (!canvas || !hasSignature) return;
|
||||||
|
|
||||||
// We can return a base64 encoded PNG
|
|
||||||
const dataUrl = canvas.toDataURL('image/png');
|
const dataUrl = canvas.toDataURL('image/png');
|
||||||
onSignatureComplete(dataUrl);
|
onSignatureComplete(dataUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full max-w-2xl mx-auto">
|
<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 */}
|
{/* Helper Text */}
|
||||||
{!hasSignature && (
|
{!hasSignature && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
<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
|
Draw your signature here
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -148,7 +156,7 @@ export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={clearSignature}
|
onClick={clearSignature}
|
||||||
disabled={!hasSignature}
|
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" />
|
<Eraser className="w-4 h-4" />
|
||||||
Clear
|
Clear
|
||||||
@ -158,7 +166,7 @@ export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!hasSignature}
|
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" />
|
<Check className="w-4 h-4" />
|
||||||
Accept & Sign
|
Accept & Sign
|
||||||
|
|||||||
@ -78,11 +78,9 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
const handleDownload = async (asset: Asset) => {
|
const handleDownload = async (asset: Asset) => {
|
||||||
triggerToast(`Starting download: ${asset.title}`);
|
triggerToast(`Starting download: ${asset.title}`);
|
||||||
try {
|
try {
|
||||||
// Simulate incrementing downloads count
|
|
||||||
const updatedAsset = { ...asset, downloadsCount: asset.downloadsCount + 1 };
|
const updatedAsset = { ...asset, downloadsCount: asset.downloadsCount + 1 };
|
||||||
await apiClient.put(`/assets/${asset.id}`, updatedAsset);
|
await apiClient.put(`/assets/${asset.id}`, updatedAsset);
|
||||||
|
|
||||||
// Update local state
|
|
||||||
setAssets(prev => prev.map(a => a.id === asset.id ? updatedAsset : a));
|
setAssets(prev => prev.map(a => a.id === asset.id ? updatedAsset : a));
|
||||||
if (selectedAsset?.id === asset.id) {
|
if (selectedAsset?.id === asset.id) {
|
||||||
setSelectedAsset(updatedAsset);
|
setSelectedAsset(updatedAsset);
|
||||||
@ -92,7 +90,6 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper icons mapping
|
|
||||||
const getCategoryIcon = (catId: string) => {
|
const getCategoryIcon = (catId: string) => {
|
||||||
switch (catId) {
|
switch (catId) {
|
||||||
case 'silicon': return <Cpu className="h-5 w-5" />;
|
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);
|
const currentCategory = categories.find(c => c.id === selectedCategory);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 text-ink-900">
|
||||||
{/* Toast Notification */}
|
{/* Toast Notification */}
|
||||||
{toastMessage && (
|
{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">
|
<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-primary-400 animate-bounce" />
|
<Download className="h-4 w-4 text-ink-0 animate-bounce" />
|
||||||
<span className="font-semibold text-ink-50">{toastMessage}</span>
|
<span className="font-semibold text-ink-50">{toastMessage}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hero section */}
|
{/* 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">
|
<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">
|
<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.
|
Browse, preview, and download proprietary hardware IP, framework assemblies, and cloud-native building blocks licensed under your master agreements.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="bg-white rounded-xl border border-ink-100 p-4 text-center min-w-[6.5rem]">
|
<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-primary-700">{assets.length}</p>
|
<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>
|
<p className="text-[10px] uppercase font-bold tracking-wider text-ink-600">Available</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar - Search & Category tabs */}
|
{/* 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 */}
|
{/* Search & Subcategory select */}
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
@ -142,7 +139,7 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Search by IP block name, language, metadata tag..."
|
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>
|
</div>
|
||||||
{currentCategory && currentCategory.subcategories.length > 0 && (
|
{currentCategory && currentCategory.subcategories.length > 0 && (
|
||||||
@ -150,7 +147,7 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
<select
|
<select
|
||||||
value={selectedSubcategory}
|
value={selectedSubcategory}
|
||||||
onChange={(e) => setSelectedSubcategory(e.target.value)}
|
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>
|
<option value="all">All Subcategories</option>
|
||||||
{currentCategory.subcategories.map(sub => (
|
{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 ${
|
className={`flex items-center gap-2 rounded-lg px-4 py-2.5 text-xs font-semibold transition-all ${
|
||||||
selectedCategory === 'all'
|
selectedCategory === 'all'
|
||||||
? 'bg-primary-400 text-ink-800 shadow-sm'
|
? 'bg-ink-900 text-ink-0 shadow-sm'
|
||||||
: 'bg-ink-50 text-ink-700 border border-ink-100 hover:bg-ink-100'
|
: 'bg-ink-50 text-ink-700 border border-ink-200 hover:bg-ink-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
All Resources
|
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 ${
|
className={`flex items-center gap-2 rounded-lg px-4 py-2.5 text-xs font-semibold transition-all ${
|
||||||
selectedCategory === cat.id
|
selectedCategory === cat.id
|
||||||
? 'bg-primary-400 text-ink-800 shadow-sm'
|
? 'bg-ink-900 text-ink-0 shadow-sm'
|
||||||
: 'bg-ink-50 text-ink-700 border border-ink-100 hover:bg-ink-100'
|
: 'bg-ink-50 text-ink-700 border border-ink-200 hover:bg-ink-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{getCategoryIcon(cat.id)}
|
{getCategoryIcon(cat.id)}
|
||||||
@ -200,7 +197,7 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{[1, 2, 3].map(n => (
|
{[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-40 rounded-lg bg-ink-100" />
|
||||||
<div className="h-4 w-3/4 rounded bg-ink-100" />
|
<div className="h-4 w-3/4 rounded bg-ink-100" />
|
||||||
<div className="h-10 rounded bg-ink-100" />
|
<div className="h-10 rounded bg-ink-100" />
|
||||||
@ -209,7 +206,7 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : assets.length === 0 ? (
|
) : 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-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>
|
<p className="text-sm text-ink-600">Try adjusting your filters or searching for another keyword.</p>
|
||||||
</div>
|
</div>
|
||||||
@ -218,7 +215,7 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
{assets.map(asset => (
|
{assets.map(asset => (
|
||||||
<div
|
<div
|
||||||
key={asset.id}
|
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 */}
|
{/* Card Image banner */}
|
||||||
<div className="h-44 w-full relative overflow-hidden bg-ink-100">
|
<div className="h-44 w-full relative overflow-hidden bg-ink-100">
|
||||||
@ -227,7 +224,7 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
alt={asset.title}
|
alt={asset.title}
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
/>
|
/>
|
||||||
<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)}
|
{getCategoryIcon(asset.categoryId)}
|
||||||
{asset.subcategory}
|
{asset.subcategory}
|
||||||
</span>
|
</span>
|
||||||
@ -236,7 +233,7 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
{/* Card Body */}
|
{/* Card Body */}
|
||||||
<div className="p-5 flex-1 flex flex-col justify-between space-y-4">
|
<div className="p-5 flex-1 flex flex-col justify-between space-y-4">
|
||||||
<div className="space-y-2">
|
<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}
|
{asset.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-ink-600 line-clamp-2 leading-relaxed">
|
<p className="text-xs text-ink-600 line-clamp-2 leading-relaxed">
|
||||||
@ -248,17 +245,17 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{asset.tags.map(tag => (
|
{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}
|
#{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* 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
|
<button
|
||||||
onClick={() => setSelectedAsset(asset)}
|
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
|
View Details
|
||||||
</button>
|
</button>
|
||||||
@ -268,7 +265,7 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
href={asset.githubUrl}
|
href={asset.githubUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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"
|
title="Open Repository"
|
||||||
>
|
>
|
||||||
<GithubIcon className="h-4 w-4" />
|
<GithubIcon className="h-4 w-4" />
|
||||||
@ -276,7 +273,7 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDownload(asset)}
|
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"
|
title="Download Asset"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
@ -292,8 +289,8 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
|
|
||||||
{/* Asset Details Modal */}
|
{/* Asset Details Modal */}
|
||||||
{selectedAsset && (
|
{selectedAsset && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-800/20 backdrop-blur-sm p-4 animate-fade-in">
|
<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-100 bg-white p-6 shadow-premium max-h-[90vh] overflow-y-auto space-y-6 relative animate-scale-up">
|
<div className="w-full max-w-2xl rounded-2xl border border-ink-200 bg-ink-0 p-6 shadow-xl max-h-[90vh] overflow-y-auto space-y-6 relative animate-scale-up">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedAsset(null)}
|
onClick={() => setSelectedAsset(null)}
|
||||||
className="absolute right-4 top-4 rounded-full p-1.5 text-ink-600 hover:bg-ink-100 hover:text-ink-800 transition-colors"
|
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}
|
alt={selectedAsset.title}
|
||||||
className="w-full h-full object-cover"
|
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)}
|
{getCategoryIcon(selectedAsset.categoryId)}
|
||||||
{selectedAsset.subcategory}
|
{selectedAsset.subcategory}
|
||||||
</span>
|
</span>
|
||||||
@ -336,7 +333,7 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="border-t border-ink-100 pt-4">
|
<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>
|
<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}
|
{selectedAsset.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -348,7 +345,7 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
</h4>
|
</h4>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{selectedAsset.tags.map(tag => (
|
{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}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@ -362,7 +359,7 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
href={selectedAsset.githubUrl}
|
href={selectedAsset.githubUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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" />
|
<GithubIcon className="h-4 w-4" />
|
||||||
Repository URL
|
Repository URL
|
||||||
@ -370,7 +367,7 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDownload(selectedAsset)}
|
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 className="h-4 w-4" />
|
||||||
Download Files
|
Download Files
|
||||||
@ -382,3 +379,4 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
export default AssetExplorer;
|
||||||
|
|||||||
@ -74,7 +74,7 @@ export const BlogCatalog: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 text-ink-900">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-ink-800">Engineering Blog & Insights</h2>
|
<h2 className="text-xl font-bold text-ink-800">Engineering Blog & Insights</h2>
|
||||||
@ -83,7 +83,7 @@ export const BlogCatalog: React.FC = () => {
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(true)}
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
Write Post
|
Write Post
|
||||||
@ -94,7 +94,7 @@ export const BlogCatalog: React.FC = () => {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
{[1, 2].map(n => (
|
{[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-48 rounded-lg bg-ink-100" />
|
||||||
<div className="h-4 w-3/4 rounded bg-ink-100" />
|
<div className="h-4 w-3/4 rounded bg-ink-100" />
|
||||||
<div className="h-20 rounded bg-ink-100" />
|
<div className="h-20 rounded bg-ink-100" />
|
||||||
@ -102,7 +102,7 @@ export const BlogCatalog: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : posts.length === 0 ? (
|
) : 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" />
|
<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>
|
<p className="text-sm text-ink-600">No blog posts published yet.</p>
|
||||||
</div>
|
</div>
|
||||||
@ -111,7 +111,7 @@ export const BlogCatalog: React.FC = () => {
|
|||||||
{posts.map(post => (
|
{posts.map(post => (
|
||||||
<article
|
<article
|
||||||
key={post.id}
|
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">
|
<div className="h-48 w-full bg-ink-100 relative">
|
||||||
<img
|
<img
|
||||||
@ -121,7 +121,7 @@ export const BlogCatalog: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<span className={`absolute right-3 top-3 rounded-full border px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider ${
|
<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}
|
{post.status}
|
||||||
</span>
|
</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>
|
<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>
|
<p className="text-xs text-ink-600 leading-relaxed line-clamp-3">{post.content}</p>
|
||||||
</div>
|
</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 => (
|
{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}
|
#{t}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@ -161,8 +161,8 @@ export const BlogCatalog: React.FC = () => {
|
|||||||
|
|
||||||
{/* Post Creator Modal */}
|
{/* Post Creator Modal */}
|
||||||
{isOpen && (
|
{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="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-100 bg-white p-6 shadow-premium max-h-[90vh] overflow-y-auto relative animate-scale-up">
|
<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
|
<button
|
||||||
onClick={() => setIsOpen(false)}
|
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"
|
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">
|
<div className="mb-5">
|
||||||
<h3 className="text-lg font-bold text-ink-800 flex items-center gap-2">
|
<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
|
Write Blog Article
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-ink-600">Draft or publish a technical write-up for the developer channel.</p>
|
<p className="text-xs text-ink-600">Draft or publish a technical write-up for the developer channel.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formError && (
|
{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}
|
{formError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -192,7 +192,7 @@ export const BlogCatalog: React.FC = () => {
|
|||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="e.g. Optimizing Pipeline Hazards in RV64GC Core Designs"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -204,7 +204,7 @@ export const BlogCatalog: React.FC = () => {
|
|||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => setContent(e.target.value)}
|
||||||
placeholder="Write the full post text..."
|
placeholder="Write the full post text..."
|
||||||
rows={6}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -217,7 +217,7 @@ export const BlogCatalog: React.FC = () => {
|
|||||||
value={tagsInput}
|
value={tagsInput}
|
||||||
onChange={(e) => setTagsInput(e.target.value)}
|
onChange={(e) => setTagsInput(e.target.value)}
|
||||||
placeholder="RISC-V, RTL-Design, Edge-Compute"
|
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>
|
</div>
|
||||||
|
|
||||||
@ -228,7 +228,7 @@ export const BlogCatalog: React.FC = () => {
|
|||||||
value={thumbnailUrl}
|
value={thumbnailUrl}
|
||||||
onChange={(e) => setThumbnailUrl(e.target.value)}
|
onChange={(e) => setThumbnailUrl(e.target.value)}
|
||||||
placeholder="https://images.unsplash.com/..."
|
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>
|
||||||
</div>
|
</div>
|
||||||
@ -242,7 +242,7 @@ export const BlogCatalog: React.FC = () => {
|
|||||||
name="blogStatus"
|
name="blogStatus"
|
||||||
checked={status === 'draft'}
|
checked={status === 'draft'}
|
||||||
onChange={() => setStatus('draft')}
|
onChange={() => setStatus('draft')}
|
||||||
className="text-primary-500 focus:ring-primary-500"
|
className="text-ink-900 focus:ring-ink-900/20"
|
||||||
/>
|
/>
|
||||||
Draft
|
Draft
|
||||||
</label>
|
</label>
|
||||||
@ -252,7 +252,7 @@ export const BlogCatalog: React.FC = () => {
|
|||||||
name="blogStatus"
|
name="blogStatus"
|
||||||
checked={status === 'published'}
|
checked={status === 'published'}
|
||||||
onChange={() => setStatus('published')}
|
onChange={() => setStatus('published')}
|
||||||
className="text-primary-500 focus:ring-primary-500"
|
className="text-ink-900 focus:ring-ink-900/20"
|
||||||
/>
|
/>
|
||||||
Published
|
Published
|
||||||
</label>
|
</label>
|
||||||
@ -263,14 +263,14 @@ export const BlogCatalog: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(false)}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
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" />
|
<Send className="h-4 w-4" />
|
||||||
{submitting ? 'Publishing...' : 'Publish Article'}
|
{submitting ? 'Publishing...' : 'Publish Article'}
|
||||||
@ -283,3 +283,4 @@ export const BlogCatalog: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
export default BlogCatalog;
|
||||||
|
|||||||
@ -1,28 +1,87 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import type { User, AuthResponse } from '../types/auth';
|
import type { User, AuthResponse } from '../types/auth';
|
||||||
|
import { refreshAuthToken } from '../services/auth-api';
|
||||||
|
import { axiosInstance } from '../services/axios';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
|
isInitializing: boolean;
|
||||||
setAuth: (data: AuthResponse) => void;
|
setAuth: (data: AuthResponse) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
checkAuth: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set) => ({
|
export const useAuthStore = create<AuthState>()(
|
||||||
user: null,
|
persist(
|
||||||
isAuthenticated: false,
|
(set, get) => ({
|
||||||
accessToken: null,
|
|
||||||
setAuth: (data) => set({
|
|
||||||
user: data.user,
|
|
||||||
accessToken: data.accessToken,
|
|
||||||
isAuthenticated: true
|
|
||||||
}),
|
|
||||||
logout: () => {
|
|
||||||
set({
|
|
||||||
user: null,
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
accessToken: null,
|
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";
|
@import "tailwindcss";
|
||||||
@custom-variant dark (&:is(.dark *));/* ============================================================
|
@custom-variant dark (&:is(.dark *));
|
||||||
TECH4BIZ DESIGN SYSTEM — Tailwind v4
|
|
||||||
Brand Primary: #A2E771 (Lime Green)
|
/* ============================================================
|
||||||
Philosophy: Light theme, no pure black, 3D-first, animated
|
TECH4BIZ DESIGN SYSTEM — MONOCHROME PREMIUM
|
||||||
|
Brand Primary: #18181B (Zinc / Charcoal)
|
||||||
|
Philosophy: Clean contrast, neutral slate variables, fluid transitions
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* ── Brand Primary — Lime Green #A2E771 ── */
|
/* ── Brand Primary — Zinc / Charcoal ── */
|
||||||
--color-primary-50: #F3FCE9;
|
--color-primary-50: #fafafa;
|
||||||
--color-primary-100: #E2F7C9;
|
--color-primary-100: #f4f4f5;
|
||||||
--color-primary-200: #C9EF9E;
|
--color-primary-200: #e4e4e7;
|
||||||
--color-primary-300: #B2E97E;
|
--color-primary-300: #d4d4d8;
|
||||||
--color-primary-400: #A2E771;
|
--color-primary-400: #a1a1aa;
|
||||||
--color-primary-500: #8FD95C;
|
--color-primary-500: #71717a;
|
||||||
--color-primary-600: #75BF46;
|
--color-primary-600: #52525b;
|
||||||
--color-primary-700: #5C9C37;
|
--color-primary-700: #3f3f46;
|
||||||
--color-primary-800: #477A2B;
|
--color-primary-800: #27272a;
|
||||||
--color-primary-900: #365F21;
|
--color-primary-900: #18181b;
|
||||||
|
|
||||||
/* ── Ink Neutrals (No Pure Black) ── */
|
/* ── Ink Neutrals (Zinc Grays) ── */
|
||||||
--color-ink-0: #FFFFFF;
|
--color-ink-0: #ffffff;
|
||||||
--color-ink-50: #F7F9F6;
|
--color-ink-50: #fafafa;
|
||||||
--color-ink-100: #ECF0EA;
|
--color-ink-100: #f4f4f5;
|
||||||
--color-ink-200: #DFE5DC;
|
--color-ink-200: #e4e4e7;
|
||||||
--color-ink-300: #C6CFC2;
|
--color-ink-300: #d4d4d8;
|
||||||
--color-ink-400: #A8B5A3;
|
--color-ink-400: #a1a1aa;
|
||||||
--color-ink-500: #8A9985;
|
--color-ink-500: #71717a;
|
||||||
--color-ink-600: #5B6B57;
|
--color-ink-600: #52525b;
|
||||||
--color-ink-700: #43503F;
|
--color-ink-700: #3f3f46;
|
||||||
--color-ink-800: #232B21;
|
--color-ink-800: #27272a;
|
||||||
--color-ink-900: #1D241B;
|
--color-ink-900: #09090b;
|
||||||
|
|
||||||
/* ── Semantic Colors ── */
|
/* ── Semantic Colors ── */
|
||||||
--color-success: #3FAE5C;
|
--color-success: #10b981;
|
||||||
--color-warning: #E8A93F;
|
--color-warning: #f59e0b;
|
||||||
--color-danger: #E5484D;
|
--color-danger: #ef4444;
|
||||||
--color-info: #4C8DF0;
|
--color-info: #3b82f6;
|
||||||
|
|
||||||
/* ── Typography ── */
|
/* ── Typography ── */
|
||||||
--font-sans: 'Outfit', 'Inter', ui-sans-serif, system-ui, sans-serif;
|
--font-sans: 'Outfit', 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||||
@ -46,14 +48,14 @@
|
|||||||
--spacing-22: 5.5rem;
|
--spacing-22: 5.5rem;
|
||||||
|
|
||||||
/* ── Shadows ── */
|
/* ── Shadows ── */
|
||||||
--shadow-sm: 0 1px 3px 0 rgba(35, 43, 33, 0.06);
|
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
--shadow-md: 0 4px 12px -2px rgba(35, 43, 33, 0.08);
|
--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 8px 30px -4px rgba(35, 43, 33, 0.10);
|
--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 24px -4px rgba(162, 231, 113, 0.25), 0 2px 8px -1px rgba(35, 43, 33, 0.06);
|
--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(162, 231, 113, 0.45), 0 0 60px rgba(162, 231, 113, 0.15);
|
--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(162, 231, 113, 0.35);
|
--shadow-glow-sm: 0 0 15px rgba(113, 113, 122, 0.05);
|
||||||
--shadow-inner-glow: inset 0 1px 0 rgba(162, 231, 113, 0.15);
|
--shadow-inner-glow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
--shadow-3d: 0 20px 60px -10px rgba(35, 43, 33, 0.18), 0 8px 25px -5px rgba(162, 231, 113, 0.12);
|
--shadow-3d: 0 20px 40px -10px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
/* ── Border Radius ── */
|
/* ── Border Radius ── */
|
||||||
--radius-2xl: 1rem;
|
--radius-2xl: 1rem;
|
||||||
@ -66,10 +68,10 @@
|
|||||||
--animate-float-fast: float 4s ease-in-out infinite;
|
--animate-float-fast: float 4s ease-in-out infinite;
|
||||||
--animate-pulse-glow: pulse-glow 2.5s ease-in-out infinite;
|
--animate-pulse-glow: pulse-glow 2.5s ease-in-out infinite;
|
||||||
--animate-shimmer: shimmer 2s linear infinite;
|
--animate-shimmer: shimmer 2s linear infinite;
|
||||||
--animate-fade-in: fade-in 0.5s 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.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.4s 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.4s 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-spin-slow: spin 8s linear infinite;
|
||||||
--animate-orbit: orbit 12s linear infinite;
|
--animate-orbit: orbit 12s linear infinite;
|
||||||
--animate-morph: morph 8s ease-in-out infinite;
|
--animate-morph: morph 8s ease-in-out infinite;
|
||||||
@ -77,36 +79,36 @@
|
|||||||
/* ── Keyframes ── */
|
/* ── Keyframes ── */
|
||||||
@keyframes float {
|
@keyframes float {
|
||||||
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||||
33% { transform: translateY(-12px) rotate(1deg); }
|
33% { transform: translateY(-8px) rotate(0.5deg); }
|
||||||
66% { transform: translateY(-6px) rotate(-1deg); }
|
66% { transform: translateY(-4px) rotate(-0.5deg); }
|
||||||
}
|
}
|
||||||
@keyframes pulse-glow {
|
@keyframes pulse-glow {
|
||||||
0%, 100% { box-shadow: 0 0 15px rgba(162, 231, 113, 0.3); }
|
0%, 100% { box-shadow: 0 0 15px rgba(113, 113, 122, 0.1); }
|
||||||
50% { box-shadow: 0 0 40px rgba(162, 231, 113, 0.6), 0 0 80px rgba(162, 231, 113, 0.2); }
|
50% { box-shadow: 0 0 30px rgba(113, 113, 122, 0.2); }
|
||||||
}
|
}
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
from { background-position: -200% center; }
|
from { background-position: -200% center; }
|
||||||
to { background-position: 200% center; }
|
to { background-position: 200% center; }
|
||||||
}
|
}
|
||||||
@keyframes fade-in {
|
@keyframes fade-in {
|
||||||
from { opacity: 0; transform: translateY(12px); }
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
@keyframes scale-up {
|
@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); }
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
}
|
}
|
||||||
@keyframes slide-up {
|
@keyframes slide-up {
|
||||||
from { opacity: 0; transform: translateY(20px); }
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
@keyframes slide-right {
|
@keyframes slide-right {
|
||||||
from { opacity: 0; transform: translateX(-20px); }
|
from { opacity: 0; transform: translateX(-12px); }
|
||||||
to { opacity: 1; transform: translateX(0); }
|
to { opacity: 1; transform: translateX(0); }
|
||||||
}
|
}
|
||||||
@keyframes orbit {
|
@keyframes orbit {
|
||||||
from { transform: rotate(0deg) translateX(120px) rotate(0deg); }
|
from { transform: rotate(0deg) translateX(100px) rotate(0deg); }
|
||||||
to { transform: rotate(360deg) translateX(120px) rotate(-360deg); }
|
to { transform: rotate(360deg) translateX(100px) rotate(-360deg); }
|
||||||
}
|
}
|
||||||
@keyframes morph {
|
@keyframes morph {
|
||||||
0%, 100% { border-radius: 40% 60% 70% 30% / 40% 50% 60% 50%; }
|
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
|
BASE STYLES
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@ -143,12 +197,12 @@ body {
|
|||||||
/* Premium Scrollbar */
|
/* Premium Scrollbar */
|
||||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
::-webkit-scrollbar-track { background: var(--color-ink-50); }
|
::-webkit-scrollbar-track { background: var(--color-ink-50); }
|
||||||
::-webkit-scrollbar-thumb { background: var(--color-ink-300); border-radius: 9999px; }
|
::-webkit-scrollbar-thumb { background: var(--color-ink-200); border-radius: 9999px; }
|
||||||
::-webkit-scrollbar-thumb:hover { background: var(--color-ink-600); }
|
::-webkit-scrollbar-thumb:hover { background: var(--color-ink-400); }
|
||||||
|
|
||||||
/* Focus ring */
|
/* Focus ring */
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--color-primary-400);
|
outline: 2px solid var(--color-ink-900);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
@ -167,56 +221,56 @@ body {
|
|||||||
transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
.card-3d:hover {
|
.card-3d:hover {
|
||||||
transform: rotateY(-4deg) rotateX(2deg) translateZ(8px);
|
transform: rotateY(-2deg) rotateX(1deg) translateZ(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
GLASS MORPHISM
|
GLASS MORPHISM
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.glass {
|
.glass {
|
||||||
background: rgba(247, 249, 246, 0.72);
|
background: rgba(255, 255, 255, 0.7);
|
||||||
backdrop-filter: blur(20px) saturate(180%);
|
backdrop-filter: blur(16px);
|
||||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
-webkit-backdrop-filter: blur(16px);
|
||||||
border: 1px solid rgba(236, 240, 234, 0.8);
|
border: 1px solid rgba(228, 228, 231, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-strong {
|
.glass-strong {
|
||||||
background: rgba(255, 255, 255, 0.88);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
backdrop-filter: blur(32px) saturate(200%);
|
backdrop-filter: blur(24px);
|
||||||
-webkit-backdrop-filter: blur(32px) saturate(200%);
|
-webkit-backdrop-filter: blur(24px);
|
||||||
border: 1px solid rgba(162, 231, 113, 0.2);
|
border: 1px solid rgba(228, 228, 231, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-dark {
|
.glass-dark {
|
||||||
background: rgba(35, 43, 33, 0.75);
|
background: rgba(9, 9, 11, 0.75);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(16px);
|
||||||
-webkit-backdrop-filter: blur(20px);
|
-webkit-backdrop-filter: blur(16px);
|
||||||
border: 1px solid rgba(162, 231, 113, 0.15);
|
border: 1px solid rgba(39, 39, 42, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
GRADIENT UTILITIES
|
GRADIENT UTILITIES
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.gradient-brand {
|
.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 {
|
.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 {
|
.gradient-mesh {
|
||||||
background:
|
background:
|
||||||
radial-gradient(at 40% 20%, rgba(162, 231, 113, 0.18) 0px, transparent 50%),
|
radial-gradient(at 40% 20%, rgba(113, 113, 122, 0.05) 0px, transparent 50%),
|
||||||
radial-gradient(at 80% 0%, rgba(117, 191, 70, 0.12) 0px, transparent 50%),
|
radial-gradient(at 80% 0%, rgba(82, 82, 91, 0.03) 0px, transparent 50%),
|
||||||
radial-gradient(at 0% 50%, rgba(162, 231, 113, 0.10) 0px, transparent 50%),
|
radial-gradient(at 0% 50%, rgba(113, 113, 122, 0.04) 0px, transparent 50%),
|
||||||
radial-gradient(at 80% 50%, rgba(79, 122, 43, 0.08) 0px, transparent 50%),
|
radial-gradient(at 80% 50%, rgba(39, 39, 42, 0.02) 0px, transparent 50%),
|
||||||
radial-gradient(at 0% 100%, rgba(162, 231, 113, 0.12) 0px, transparent 50%),
|
radial-gradient(at 0% 100%, rgba(113, 113, 122, 0.05) 0px, transparent 50%),
|
||||||
var(--color-ink-50);
|
var(--color-ink-50);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gradient-text {
|
.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;
|
background-clip: text;
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
@ -225,11 +279,11 @@ body {
|
|||||||
.gradient-shimmer {
|
.gradient-shimmer {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
var(--color-primary-300) 0%,
|
var(--color-ink-600) 0%,
|
||||||
var(--color-primary-500) 25%,
|
var(--color-ink-800) 25%,
|
||||||
var(--color-primary-300) 50%,
|
var(--color-ink-600) 50%,
|
||||||
var(--color-primary-500) 75%,
|
var(--color-ink-800) 75%,
|
||||||
var(--color-primary-300) 100%
|
var(--color-ink-600) 100%
|
||||||
);
|
);
|
||||||
background-size: 200% auto;
|
background-size: 200% auto;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
@ -242,54 +296,43 @@ body {
|
|||||||
PREMIUM COMPONENT UTILITIES
|
PREMIUM COMPONENT UTILITIES
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: linear-gradient(135deg, var(--color-primary-400), var(--color-primary-500));
|
background: var(--color-ink-900);
|
||||||
color: var(--color-ink-800);
|
color: var(--color-ink-0);
|
||||||
font-weight: 600;
|
font-weight: 550;
|
||||||
padding: 0.625rem 1.25rem;
|
padding: 0.625rem 1.25rem;
|
||||||
border-radius: 0.625rem;
|
border-radius: 0.5rem;
|
||||||
border: none;
|
border: 1px solid transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
transition: all 0.2s 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);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.btn-primary::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: linear-gradient(135deg, rgba(255,255,255,0.25), transparent);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
}
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
transform: translateY(-2px);
|
background: var(--color-ink-800);
|
||||||
box-shadow: 0 8px 25px rgba(162, 231, 113, 0.45), 0 4px 10px rgba(0,0,0,0.08);
|
}
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
.btn-primary:hover::before { opacity: 1; }
|
|
||||||
.btn-primary:active { transform: translateY(0); }
|
|
||||||
|
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1.5px solid var(--color-ink-200);
|
border: 1px solid var(--color-ink-200);
|
||||||
color: var(--color-ink-700);
|
color: var(--color-ink-700);
|
||||||
font-weight: 600;
|
font-weight: 550;
|
||||||
padding: 0.625rem 1.25rem;
|
padding: 0.625rem 1.25rem;
|
||||||
border-radius: 0.625rem;
|
border-radius: 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
.btn-ghost:hover {
|
.btn-ghost:hover {
|
||||||
background: var(--color-ink-100);
|
background: var(--color-ink-100);
|
||||||
border-color: var(--color-ink-300);
|
border-color: var(--color-ink-300);
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-field {
|
.input-field {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--color-ink-50);
|
background: var(--color-ink-0);
|
||||||
border: 1.5px solid var(--color-ink-200);
|
border: 1px solid var(--color-ink-200);
|
||||||
border-radius: 0.625rem;
|
border-radius: 0.5rem;
|
||||||
padding: 0.625rem 1rem;
|
padding: 0.625rem 1rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--color-ink-800);
|
color: var(--color-ink-800);
|
||||||
@ -304,30 +347,28 @@ body {
|
|||||||
padding-right: 2.75rem;
|
padding-right: 2.75rem;
|
||||||
}
|
}
|
||||||
.input-field:focus {
|
.input-field:focus {
|
||||||
background: white;
|
border-color: var(--color-ink-900);
|
||||||
border-color: var(--color-primary-400);
|
box-shadow: 0 0 0 3px rgba(9, 9, 11, 0.06), 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||||
box-shadow: 0 0 0 3px rgba(162, 231, 113, 0.2), 0 1px 3px rgba(0,0,0,0.05);
|
|
||||||
}
|
}
|
||||||
.input-field::placeholder { color: var(--color-ink-400); }
|
.input-field::placeholder { color: var(--color-ink-400); }
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid var(--color-ink-100);
|
border: 1px solid var(--color-ink-100);
|
||||||
border-radius: 1rem;
|
border-radius: 0.75rem;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
transition: box-shadow 0.3s, transform 0.3s;
|
transition: box-shadow 0.2s, transform 0.2s;
|
||||||
}
|
}
|
||||||
.card:hover {
|
.card:hover {
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-md);
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-glass {
|
.card-glass {
|
||||||
background: rgba(255, 255, 255, 0.7);
|
background: rgba(255, 255, 255, 0.7);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.8);
|
border: 1px solid rgba(228, 228, 231, 0.4);
|
||||||
border-radius: 1rem;
|
border-radius: 0.75rem;
|
||||||
box-shadow: var(--shadow-3d);
|
box-shadow: var(--shadow-premium);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@ -341,15 +382,15 @@ body {
|
|||||||
animation: var(--animate-float);
|
animation: var(--animate-float);
|
||||||
}
|
}
|
||||||
.orb-primary {
|
.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 {
|
.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-delay: -3s;
|
||||||
animation-duration: 8s;
|
animation-duration: 8s;
|
||||||
}
|
}
|
||||||
.orb-accent {
|
.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-delay: -6s;
|
||||||
animation-duration: 11s;
|
animation-duration: 11s;
|
||||||
}
|
}
|
||||||
@ -368,11 +409,11 @@ body {
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.badge-success { background: rgba(63, 174, 92, 0.12); color: #3FAE5C; border: 1px solid rgba(63, 174, 92, 0.25); }
|
.badge-success { background: rgba(16, 185, 129, 0.08); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.15); }
|
||||||
.badge-warning { background: rgba(232, 169, 63, 0.12); color: #E8A93F; border: 1px solid rgba(232, 169, 63, 0.25); }
|
.badge-warning { background: rgba(245, 158, 11, 0.08); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.15); }
|
||||||
.badge-danger { background: rgba(229, 72, 77, 0.12); color: #E5484D; border: 1px solid rgba(229, 72, 77, 0.25); }
|
.badge-danger { background: rgba(239, 68, 68, 0.08); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.15); }
|
||||||
.badge-neutral { background: rgba(35, 43, 33, 0.06); color: #5B6B57; border: 1px solid rgba(35, 43, 33, 0.12); }
|
.badge-neutral { background: rgba(82, 82, 91, 0.08); color: #52525b; border: 1px solid rgba(82, 82, 91, 0.15); }
|
||||||
.badge-primary { background: rgba(162, 231, 113, 0.15); color: #477A2B; border: 1px solid rgba(162, 231, 113, 0.3); }
|
.badge-primary { background: rgba(9, 9, 11, 0.06); color: #09090b; border: 1px solid rgba(9, 9, 11, 0.12); }
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
ANIMATION UTILITY CLASSES
|
ANIMATION UTILITY CLASSES
|
||||||
@ -382,10 +423,10 @@ body {
|
|||||||
.animate-float-fast { animation: float 4s ease-in-out infinite; }
|
.animate-float-fast { animation: float 4s ease-in-out infinite; }
|
||||||
.animate-pulse-glow { animation: pulse-glow 2.5s ease-in-out infinite; }
|
.animate-pulse-glow { animation: pulse-glow 2.5s ease-in-out infinite; }
|
||||||
.animate-shimmer { animation: shimmer 2s linear 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-fade-in { animation: fade-in 0.4s 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-scale-up { animation: scale-up 0.35s 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-up { animation: slide-up 0.35s 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-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-spin-slow { animation: spin 8s linear infinite; }
|
||||||
.animate-orbit { animation: orbit 12s linear infinite; }
|
.animate-orbit { animation: orbit 12s linear infinite; }
|
||||||
.animate-morph { animation: morph 8s ease-in-out infinite; }
|
.animate-morph { animation: morph 8s ease-in-out infinite; }
|
||||||
@ -399,14 +440,14 @@ body {
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
TRANSITION UTILITIES
|
TRANSITION UTILITIES
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.transition-premium { transition: all 0.25s 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.5s 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
|
INTERACTIVE HOVER EFFECTS
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.hover-lift { transition: transform 0.3s, box-shadow 0.3s; }
|
.hover-lift { transition: transform 0.2s, box-shadow 0.2s; }
|
||||||
.hover-lift:hover { transform: translateY(-4px); box-shadow: var(--shadow-3d); }
|
.hover-lift:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
|
||||||
|
|
||||||
.hover-glow:hover { box-shadow: var(--shadow-glow); }
|
.hover-glow:hover { box-shadow: var(--shadow-glow); }
|
||||||
|
|
||||||
@ -419,7 +460,7 @@ body {
|
|||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -50%;
|
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;
|
opacity: 0;
|
||||||
transition: opacity 0.4s;
|
transition: opacity 0.4s;
|
||||||
}
|
}
|
||||||
@ -451,7 +492,7 @@ body {
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
transition: background 0.15s;
|
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; }
|
.table-premium tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@ -462,7 +503,7 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
padding: 0.5rem 0.875rem;
|
padding: 0.5rem 0.875rem;
|
||||||
border-radius: 0.625rem;
|
border-radius: 0.5rem;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-ink-600);
|
color: var(--color-ink-600);
|
||||||
@ -481,9 +522,9 @@ body {
|
|||||||
.nav-link:hover { color: var(--color-ink-800); }
|
.nav-link:hover { color: var(--color-ink-800); }
|
||||||
.nav-link:hover::before { opacity: 1; }
|
.nav-link:hover::before { opacity: 1; }
|
||||||
.nav-link.active {
|
.nav-link.active {
|
||||||
background: linear-gradient(135deg, var(--color-primary-400), var(--color-primary-500));
|
background: var(--color-ink-900);
|
||||||
color: var(--color-ink-800);
|
color: var(--color-ink-0);
|
||||||
box-shadow: 0 2px 8px rgba(162, 231, 113, 0.35);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
.nav-link.active::before { display: none; }
|
.nav-link.active::before { display: none; }
|
||||||
|
|
||||||
@ -542,7 +583,7 @@ body {
|
|||||||
.progress-bar-fill {
|
.progress-bar-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 9999px;
|
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);
|
transition: width 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@ -550,6 +591,6 @@ body {
|
|||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
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;
|
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 = () => {
|
export const DashboardPage = () => {
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
|
|
||||||
const CARDS = [
|
const CARDS = user?.role === 'ADMIN' ? [
|
||||||
{
|
{
|
||||||
title: 'Asset Library',
|
title: 'Asset Library',
|
||||||
description: 'Manage and assign premium marketing collateral, brand guidelines, and shared resources.',
|
description: 'Manage and assign premium marketing collateral, brand guidelines, and shared resources.',
|
||||||
icon: FolderKanban,
|
icon: FolderKanban,
|
||||||
color: 'from-blue-500 to-cyan-400',
|
path: '/admin/assets',
|
||||||
path: '/assets',
|
metrics: 'View Catalog'
|
||||||
metrics: '24 New Assets'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Legal Engine',
|
title: 'Legal Engine',
|
||||||
description: 'Strict version control for active NDAs, MSAs, and partner compliance tracking.',
|
description: 'Strict version control for active NDAs, MSAs, and partner compliance tracking.',
|
||||||
icon: FileSignature,
|
icon: FileSignature,
|
||||||
color: 'from-purple-500 to-pink-500',
|
path: '/admin/legal',
|
||||||
path: '/legal',
|
metrics: 'Legal Templates'
|
||||||
metrics: '3 Pending Signatures'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Partner Directory',
|
title: 'Partner Directory',
|
||||||
description: 'View active channel partners, audit logs, and their assigned enterprise content.',
|
description: 'View active channel partners, audit logs, and their assigned enterprise content.',
|
||||||
icon: Users,
|
icon: Users,
|
||||||
color: 'from-emerald-400 to-teal-500',
|
path: '/admin/partners',
|
||||||
path: '/directory',
|
metrics: 'Manage Partners'
|
||||||
metrics: '12 Active 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 (
|
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">
|
<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="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-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)]" />
|
<div className="w-2 h-2 rounded-full bg-ink-0 animate-pulse" />
|
||||||
<span className="text-[11px] font-bold text-slate-600 dark:text-white/80 tracking-widest uppercase">System Operational</span>
|
<span className="text-[11px] font-bold tracking-widest uppercase">System Active</span>
|
||||||
</div>
|
</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]}
|
Welcome back, {user?.email?.split('@')[0]}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-slate-500 dark:text-white/40 max-w-2xl leading-relaxed mt-2 font-medium">
|
<p className="text-xl text-ink-500 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.
|
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>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@ -66,14 +78,14 @@ export const DashboardPage = () => {
|
|||||||
{ label: 'Active Data Streams', value: '1,492', icon: Zap, trend: '+12%' },
|
{ label: 'Active Data Streams', value: '1,492', icon: Zap, trend: '+12%' },
|
||||||
{ label: 'Security Compliance', value: 'Level 4', icon: ShieldCheck, trend: 'Verified' }
|
{ label: 'Security Compliance', value: 'Level 4', icon: ShieldCheck, trend: 'Verified' }
|
||||||
].map((stat, i) => (
|
].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 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 dark:opacity-5 group-hover:opacity-10 dark:group-hover:opacity-20 transition-opacity duration-500 group-hover:scale-110 transform">
|
<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-slate-900 dark:text-white" />
|
<stat.icon className="w-24 h-24 text-ink-900" />
|
||||||
</div>
|
</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">
|
<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>
|
<h3 className="text-5xl font-extrabold text-ink-900 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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -82,30 +94,26 @@ export const DashboardPage = () => {
|
|||||||
{/* Main Action Cards */}
|
{/* Main Action Cards */}
|
||||||
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-8 pt-6">
|
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-8 pt-6">
|
||||||
{CARDS.map((card, idx) => (
|
{CARDS.map((card, idx) => (
|
||||||
<Link key={idx} to={card.path} className="group relative block">
|
<Link key={idx} to={card.path} className="group relative block h-full">
|
||||||
<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-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="relative h-full bg-white/80 dark:bg-[#0A0A0A]/80 backdrop-blur-2xl border border-slate-200 dark:border-white/10 rounded-[2rem] p-10 hover:border-slate-300 dark:hover:border-white/30 transition-all duration-300 overflow-hidden shadow-xl shadow-slate-200/50 dark:shadow-2xl hover:shadow-2xl hover:shadow-slate-300/50 dark:hover:shadow-[0_20px_40px_rgba(0,0,0,0.5)] hover:-translate-y-1">
|
|
||||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br opacity-5 dark:opacity-10 group-hover:opacity-10 dark:group-hover:opacity-30 blur-3xl transition-opacity duration-500 rounded-full -mr-12 -mt-12" />
|
|
||||||
|
|
||||||
<div className="flex justify-between items-start mb-16 relative z-10">
|
<div className="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-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">
|
||||||
<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" />
|
||||||
<card.icon className="w-8 h-8 text-slate-800 dark:text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 h-12 rounded-full bg-slate-50 dark:bg-white/5 flex items-center justify-center group-hover:bg-slate-100 dark:group-hover:bg-white/20 transition-all duration-300 border border-slate-200 dark:border-white/5 group-hover:border-slate-300 dark:group-hover:border-white/20">
|
<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-slate-400 dark:text-white/40 group-hover:text-slate-900 dark:group-hover:text-white transition-colors" />
|
<ArrowUpRight className="w-6 h-6 text-ink-400 group-hover:text-ink-900 transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10">
|
<div className="relative z-10 mt-auto">
|
||||||
<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="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}
|
{card.metrics}
|
||||||
</div>
|
</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}
|
{card.title}
|
||||||
</h3>
|
</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}
|
{card.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -116,3 +124,4 @@ export const DashboardPage = () => {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
export default DashboardPage;
|
||||||
|
|||||||
@ -27,7 +27,7 @@ export const InvitePage: React.FC = () => {
|
|||||||
|
|
||||||
const validateToken = async () => {
|
const validateToken = async () => {
|
||||||
try {
|
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);
|
setEmail(response.data.email);
|
||||||
setStatus('valid');
|
setStatus('valid');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -59,10 +59,7 @@ export const InvitePage: React.FC = () => {
|
|||||||
password
|
password
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save auth state
|
|
||||||
setAuth(response.data);
|
setAuth(response.data);
|
||||||
|
|
||||||
// Redirect to onboarding wizard
|
|
||||||
navigate('/onboarding');
|
navigate('/onboarding');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.error || 'Failed to accept invite');
|
setError(err.response?.data?.error || 'Failed to accept invite');
|
||||||
@ -72,24 +69,24 @@ export const InvitePage: React.FC = () => {
|
|||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 dark:bg-[#050505] flex items-center justify-center">
|
<div className="min-h-screen bg-ink-50 flex items-center justify-center">
|
||||||
<div className="w-8 h-8 border-4 border-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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'invalid') {
|
if (status === 'invalid') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 dark:bg-[#050505] flex items-center justify-center p-4">
|
<div className="min-h-screen bg-ink-50 flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md bg-white dark:bg-[#0A0A0A] rounded-3xl p-8 border border-slate-200 dark:border-white/10 text-center shadow-2xl">
|
<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-100 dark:bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
<AlertCircle className="w-8 h-8 text-red-600 dark:text-red-500" />
|
<AlertCircle className="w-8 h-8 text-red-600 dark:text-red-500" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">Invalid or Expired Link</h2>
|
<h2 className="text-2xl font-bold text-ink-900 mb-2">Invalid or Expired Link</h2>
|
||||||
<p className="text-slate-500 dark:text-white/50 text-sm mb-8">
|
<p className="text-ink-500 text-sm mb-8">
|
||||||
This invitation link is no longer valid. Please request a new invitation from your administrator.
|
This invitation link is no longer valid. Please request a new invitation from your administrator.
|
||||||
</p>
|
</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
|
Return to Login
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -98,44 +95,45 @@ export const InvitePage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="min-h-screen bg-ink-50 text-ink-900 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" />
|
{/* 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="w-full max-w-md z-10">
|
||||||
<div className="text-center mb-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">
|
<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-white" />
|
<Shield className="w-6 h-6 text-ink-0" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-extrabold tracking-tight mb-2">Welcome to Tech4Biz</h1>
|
<h1 className="text-3xl font-extrabold tracking-tight mb-2">Welcome to Tech4Biz</h1>
|
||||||
<p className="text-sm font-medium text-slate-500 dark:text-white/50">
|
<p className="text-sm font-medium text-ink-500">
|
||||||
Set up your partner account for <span className="text-slate-900 dark:text-white font-bold">{email}</span>
|
Set up your partner account for <span className="text-ink-900 font-bold">{email}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
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">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
{error && (
|
{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" />
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<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="relative">
|
||||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 left-0 pl-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>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full pl-11 pr-4 py-3.5 bg-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"
|
placeholder="Enter a secure password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -143,16 +141,16 @@ export const InvitePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<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="relative">
|
||||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 left-0 pl-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>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
className="w-full pl-11 pr-4 py-3.5 bg-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"
|
placeholder="Confirm your password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -162,10 +160,10 @@ export const InvitePage: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting || !password || !confirmPassword}
|
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 ? (
|
{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
|
Create Account & Continue
|
||||||
@ -179,3 +177,4 @@ export const InvitePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
export default InvitePage;
|
||||||
|
|||||||
@ -33,7 +33,6 @@ export const LoginPage = () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
const response = await loginUser({ email: data.email, password: data.password });
|
const response = await loginUser({ email: data.email, password: data.password });
|
||||||
setAuth(response);
|
setAuth(response);
|
||||||
// Route based on role — no redirect flash
|
|
||||||
if (response.user.role === 'ADMIN') {
|
if (response.user.role === 'ADMIN') {
|
||||||
navigate('/admin');
|
navigate('/admin');
|
||||||
} else {
|
} else {
|
||||||
@ -45,10 +44,10 @@ export const LoginPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
||||||
{/* Dynamic Background Elements */}
|
{/* Monochromatic Soft Background Blurs */}
|
||||||
<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 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-purple-500/5 dark:bg-purple-600/10 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
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
@ -56,35 +55,35 @@ export const LoginPage = () => {
|
|||||||
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1] }}
|
||||||
className="w-full max-w-md relative z-10"
|
className="w-full max-w-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="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-slate-300 dark:via-white/20 to-transparent" />
|
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-ink-300 to-transparent" />
|
||||||
|
|
||||||
<div className="flex flex-col items-center mb-12 text-center">
|
<div className="flex flex-col items-center mb-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">
|
<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-white w-10 h-10 absolute" />
|
<Hexagon className="text-ink-0 w-10 h-10 absolute" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl font-extrabold text-slate-900 dark:text-white tracking-tight">Channel Portal</h2>
|
<h2 className="text-3xl font-extrabold text-ink-900 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>
|
<p className="text-ink-500 text-sm mt-3 font-medium uppercase tracking-widest">Authorized Access Only</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{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">
|
<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)] dark:shadow-[0_0_10px_rgba(239,68,68,0.8)]" />
|
<div className="w-2 h-2 rounded-full bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]" />
|
||||||
{error}
|
{error}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
<div className="space-y-2">
|
<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="relative group">
|
||||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 left-0 pl-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>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
{...register('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"
|
placeholder="admin@tech4biz.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -93,17 +92,17 @@ export const LoginPage = () => {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between items-center">
|
<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>
|
<label className="block text-[11px] font-bold text-ink-500 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>
|
<a href="#" className="text-[11px] font-bold text-ink-500 hover:text-ink-900 transition-colors tracking-wider">RECOVERY?</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 left-0 pl-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>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
{...register('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="••••••••"
|
placeholder="••••••••"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -113,7 +112,7 @@ export const LoginPage = () => {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
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 ? 'AUTHENTICATING...' : 'SECURE SIGN IN'}
|
||||||
{!isSubmitting && <ArrowRight className="w-5 h-5 group-hover:translate-x-1.5 transition-transform" />}
|
{!isSubmitting && <ArrowRight className="w-5 h-5 group-hover:translate-x-1.5 transition-transform" />}
|
||||||
@ -121,10 +120,11 @@ export const LoginPage = () => {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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.
|
© 2026 Tech4Biz Solutions.
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
export default LoginPage;
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { axiosInstance } from '../services/axios';
|
|||||||
type DocumentType = 'NDA' | 'MSA';
|
type DocumentType = 'NDA' | 'MSA';
|
||||||
|
|
||||||
export const OnboardingPage: React.FC = () => {
|
export const OnboardingPage: React.FC = () => {
|
||||||
const { user } = useAuthStore();
|
const { user, checkAuth } = useAuthStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
@ -26,6 +26,118 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (user?.onboardingStatus === 'APPROVED') {
|
if (user?.onboardingStatus === 'APPROVED') {
|
||||||
navigate('/client');
|
navigate('/client');
|
||||||
@ -42,6 +154,24 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [user, navigate]);
|
}, [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 handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>, type: DocumentType) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@ -52,7 +182,7 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('title', `Signed ${type} - ${user?.email}`);
|
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' }
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -80,9 +210,8 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
if (type === 'NDA') {
|
if (type === 'NDA') {
|
||||||
setStep(2);
|
setStep(2);
|
||||||
} else {
|
} else {
|
||||||
|
await checkAuth();
|
||||||
setStep(3); // PENDING_APPROVAL
|
setStep(3); // PENDING_APPROVAL
|
||||||
// Force a page reload to update auth state / guard triggers
|
|
||||||
window.location.reload();
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to submit ${type}:`, 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) => (
|
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-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
|
<button
|
||||||
onClick={() => setMode('draw')}
|
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
|
Draw Signature
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMode('upload')}
|
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
|
Upload PDF
|
||||||
</button>
|
</button>
|
||||||
@ -112,17 +241,17 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
<div className="flex-1 flex flex-col justify-center">
|
<div className="flex-1 flex flex-col justify-center">
|
||||||
<SignatureCapture onSignatureComplete={setSignature} />
|
<SignatureCapture onSignatureComplete={setSignature} />
|
||||||
{signature && (
|
{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">
|
<div className="flex items-center gap-3">
|
||||||
<CheckCircle className="w-5 h-5 text-emerald-600 dark:text-emerald-400" />
|
<CheckCircle className="w-5 h-5 text-ink-900" />
|
||||||
<span className="font-semibold text-emerald-700 dark:text-emerald-400 text-sm">Signature captured successfully</span>
|
<span className="font-semibold text-ink-900 text-sm">Signature captured successfully</span>
|
||||||
</div>
|
</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>
|
</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
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@ -133,23 +262,23 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
|
|
||||||
{uploadUrl ? (
|
{uploadUrl ? (
|
||||||
<div className="text-center">
|
<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">
|
<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-emerald-600 dark:text-emerald-400" />
|
<CheckCircle className="w-8 h-8 text-ink-900" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-bold text-slate-900 dark:text-white mb-2">Document Uploaded</h3>
|
<h3 className="font-bold text-ink-900 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>
|
<button onClick={() => setUploadUrl(null)} className="text-sm font-bold text-ink-500 hover:text-ink-900 underline">Remove & Replace</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center">
|
<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">
|
<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-blue-600 dark:text-blue-400" />
|
<UploadCloud className="w-8 h-8 text-ink-500" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-bold text-slate-900 dark:text-white mb-2">Upload Signed Document</h3>
|
<h3 className="font-bold text-ink-900 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>
|
<p className="text-sm text-ink-500 mb-6">PDF, Word, or Image formats accepted.</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={isSubmitting}
|
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'}
|
{isSubmitting ? 'Uploading...' : 'Browse Files'}
|
||||||
</button>
|
</button>
|
||||||
@ -161,70 +290,71 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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="min-h-screen bg-ink-50 text-ink-900 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" />
|
{/* Dynamic Background Accents using monochromatic themes */}
|
||||||
<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="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 */}
|
{/* 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="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">
|
<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-white" />
|
<ShieldCheck className="w-5 h-5 text-ink-0" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xl font-extrabold tracking-tight">Tech4Biz</span>
|
<span className="text-xl font-extrabold tracking-tight">Tech4Biz</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-8 flex-1">
|
<div className="space-y-8 flex-1">
|
||||||
<div className="relative pl-8">
|
<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">
|
<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-white" />
|
<CheckCircle className="w-3.5 h-3.5 text-ink-0" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute left-3 top-7 bottom-[-20px] w-0.5 bg-blue-600/30"></div>
|
<div className="absolute left-3 top-7 bottom-[-20px] w-0.5 bg-ink-900/20"></div>
|
||||||
<h3 className="font-bold text-slate-900 dark:text-white">Account Created</h3>
|
<h3 className="font-bold text-ink-900">Account Created</h3>
|
||||||
<p className="text-xs font-medium text-slate-500 dark:text-white/40 mt-1">Credentials verified securely.</p>
|
<p className="text-xs font-medium text-ink-500 mt-1">Credentials verified securely.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative pl-8">
|
<div className="relative pl-8">
|
||||||
<div className={`absolute left-0 top-1 w-6 h-6 rounded-full flex items-center justify-center shadow-md transition-colors duration-300 ${step > 1 ? 'bg-blue-600' : step === 1 ? 'border-2 border-blue-600 bg-white dark:bg-[#0A0A0A]' : 'bg-slate-200 dark:bg-white/10'}`}>
|
<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-white" /> : <div className="w-2 h-2 rounded-full bg-blue-600" />}
|
{step > 1 ? <CheckCircle className="w-3.5 h-3.5 text-ink-0" /> : <div className="w-2 h-2 rounded-full bg-ink-900" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute left-3 top-7 bottom-[-20px] w-0.5 bg-slate-200 dark:bg-white/10">
|
<div className="absolute left-3 top-7 bottom-[-20px] w-0.5 bg-ink-200">
|
||||||
<div className="w-full bg-blue-600/30 transition-all duration-500" style={{ height: step > 1 ? '100%' : '0%' }}></div>
|
<div className="w-full bg-ink-900/20 transition-all duration-500" style={{ height: step > 1 ? '100%' : '0%' }}></div>
|
||||||
</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>
|
<h3 className={`font-bold transition-colors ${step >= 1 ? 'text-ink-900' : 'text-ink-400'}`}>NDA Agreement</h3>
|
||||||
<p className="text-xs font-medium text-slate-500 dark:text-white/40 mt-1">Non-disclosure signature.</p>
|
<p className="text-xs font-medium text-ink-500 mt-1">Non-disclosure signature.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative pl-8">
|
<div className="relative pl-8">
|
||||||
<div className={`absolute left-0 top-1 w-6 h-6 rounded-full flex items-center justify-center shadow-md transition-colors duration-300 ${step > 2 ? 'bg-blue-600' : step === 2 ? 'border-2 border-blue-600 bg-white dark:bg-[#0A0A0A]' : 'bg-slate-200 dark:bg-white/10'}`}>
|
<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-white" /> : step === 2 ? <div className="w-2 h-2 rounded-full bg-blue-600" /> : <div className="w-2 h-2 rounded-full bg-transparent" />}
|
{step > 2 ? <CheckCircle className="w-3.5 h-3.5 text-ink-0" /> : step === 2 ? <div className="w-2 h-2 rounded-full bg-ink-900" /> : <div className="w-2 h-2 rounded-full bg-transparent" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute left-3 top-7 bottom-[-20px] w-0.5 bg-slate-200 dark:bg-white/10">
|
<div className="absolute left-3 top-7 bottom-[-20px] w-0.5 bg-ink-200">
|
||||||
<div className="w-full bg-blue-600/30 transition-all duration-500" style={{ height: step > 2 ? '100%' : '0%' }}></div>
|
<div className="w-full bg-ink-900/20 transition-all duration-500" style={{ height: step > 2 ? '100%' : '0%' }}></div>
|
||||||
</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>
|
<h3 className={`font-bold transition-colors ${step >= 2 ? 'text-ink-900' : 'text-ink-400'}`}>MSA Agreement</h3>
|
||||||
<p className="text-xs font-medium text-slate-500 dark:text-white/40 mt-1">Master Services Agreement.</p>
|
<p className="text-xs font-medium text-ink-500 mt-1">Master Services Agreement.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative pl-8">
|
<div className="relative pl-8">
|
||||||
<div className={`absolute left-0 top-1 w-6 h-6 rounded-full flex items-center justify-center shadow-md transition-colors duration-300 ${step === 3 ? 'bg-amber-500' : 'bg-slate-200 dark:bg-white/10'}`}>
|
<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-white" /> : <div className="w-2 h-2 rounded-full bg-transparent" />}
|
{step === 3 ? <Clock className="w-3.5 h-3.5 text-ink-0" /> : <div className="w-2 h-2 rounded-full bg-transparent" />}
|
||||||
</div>
|
</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>
|
<h3 className={`font-bold transition-colors ${step === 3 ? 'text-ink-900' : 'text-ink-400'}`}>Admin Approval</h3>
|
||||||
<p className="text-xs font-medium text-slate-500 dark:text-white/40 mt-1">Pending compliance review.</p>
|
<p className="text-xs font-medium text-ink-500 mt-1">Pending compliance review.</p>
|
||||||
</div>
|
</div>
|
||||||
</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="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()}
|
{user?.email?.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-bold truncate max-w-[150px]">{user?.email}</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -236,23 +366,25 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
{step === 1 && (
|
{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">
|
<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="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">
|
<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-amber-600 dark:text-amber-400" />
|
<Lock className="w-3.5 h-3.5 text-ink-0" />
|
||||||
<span className="text-[10px] font-bold text-amber-700 dark:text-amber-400 tracking-widest uppercase">Action Required</span>
|
<span className="text-[10px] font-bold text-ink-0 tracking-widest uppercase">Action Required</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl font-extrabold tracking-tight mb-2">Non-Disclosure Agreement</h2>
|
<h2 className="text-3xl 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.
|
Please provide your signature or upload a signed copy of our standard NDA to proceed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{renderDocumentViewer('NDA')}
|
||||||
|
|
||||||
{renderDocumentTab('NDA', ndaMode, setNdaMode, ndaSignature, setNdaSignature, ndaUploadUrl, setNdaUploadUrl)}
|
{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
|
<button
|
||||||
onClick={() => submitDocument('NDA')}
|
onClick={() => submitDocument('NDA')}
|
||||||
disabled={(!ndaSignature && !ndaUploadUrl) || isSubmitting}
|
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'}
|
{isSubmitting ? 'Processing...' : 'Continue to MSA'}
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
@ -264,26 +396,28 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
{step === 2 && (
|
{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">
|
<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="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">
|
<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-blue-600 dark:text-blue-400" />
|
<FileText className="w-3.5 h-3.5 text-ink-600" />
|
||||||
<span className="text-[10px] font-bold text-blue-700 dark:text-blue-400 tracking-widest uppercase">Final Agreement</span>
|
<span className="text-[10px] font-bold text-ink-700 tracking-widest uppercase">Final Agreement</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl font-extrabold tracking-tight mb-2">Master Services Agreement</h2>
|
<h2 className="text-3xl 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.
|
Sign the MSA to finalize your compliance requirements and enter the approval queue.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{renderDocumentViewer('MSA')}
|
||||||
|
|
||||||
{renderDocumentTab('MSA', msaMode, setMsaMode, msaSignature, setMsaSignature, msaUploadUrl, setMsaUploadUrl)}
|
{renderDocumentTab('MSA', msaMode, setMsaMode, msaSignature, setMsaSignature, msaUploadUrl, setMsaUploadUrl)}
|
||||||
|
|
||||||
<div className="mt-auto pt-6 border-t border-slate-200 dark:border-white/10 flex justify-between items-center">
|
<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-slate-500 hover:text-slate-900 dark:hover:text-white transition-colors">
|
<button onClick={() => setStep(1)} className="text-sm font-bold text-ink-500 hover:text-ink-900 transition-colors">
|
||||||
Back to NDA
|
Back to NDA
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => submitDocument('MSA')}
|
onClick={() => submitDocument('MSA')}
|
||||||
disabled={(!msaSignature && !msaUploadUrl) || isSubmitting}
|
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'}
|
{isSubmitting ? 'Processing...' : 'Submit for Approval'}
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
@ -294,28 +428,28 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
|
|
||||||
{step === 3 && (
|
{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">
|
<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="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-amber-200 dark:border-amber-500/30 animate-[spin_3s_linear_infinite]" border-style="dashed"></div>
|
<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-amber-600 dark:text-amber-400 relative z-10" />
|
<Clock className="w-10 h-10 text-ink-900 relative z-10" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="text-3xl font-extrabold tracking-tight mb-4">Pending Approval</h2>
|
<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.
|
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>
|
</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">
|
<div className="flex justify-between items-center mb-3">
|
||||||
<span className="text-sm font-medium text-slate-500">NDA Status</span>
|
<span className="text-sm font-medium text-ink-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-xs font-bold text-ink-900 bg-ink-100 px-2 py-1 rounded-md">Signed</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center mb-3">
|
<div className="flex justify-between items-center mb-3">
|
||||||
<span className="text-sm font-medium text-slate-500">MSA Status</span>
|
<span className="text-sm font-medium text-ink-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-xs font-bold text-ink-900 bg-ink-100 px-2 py-1 rounded-md">Signed</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center pt-3 border-t border-slate-200 dark:border-white/10">
|
<div className="flex justify-between items-center pt-3 border-t border-ink-200">
|
||||||
<span className="text-sm font-medium text-slate-500">Account Access</span>
|
<span className="text-sm font-medium text-ink-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>
|
<span className="text-xs font-bold text-ink-0 bg-ink-900 px-2 py-1 rounded-md">Locked</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -326,3 +460,4 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
export default OnboardingPage;
|
||||||
|
|||||||
@ -53,42 +53,42 @@ export const ApprovalsPage: React.FC = () => {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center min-h-[60vh]">
|
<div className="flex-1 flex items-center justify-center min-h-[60vh]">
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div>
|
<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
|
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
|
{partners.length} Pending
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</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.
|
Review and approve partner legal documents to grant platform access.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search pending partners..."
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List */}
|
{/* 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">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-sm whitespace-nowrap">
|
<table className="w-full text-left text-sm whitespace-nowrap">
|
||||||
<thead className="bg-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>
|
<tr>
|
||||||
<th className="px-6 py-4">Partner</th>
|
<th className="px-6 py-4">Partner</th>
|
||||||
<th className="px-6 py-4">NDA Status</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>
|
<th className="px-6 py-4 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-200 dark:divide-white/10">
|
<tbody className="divide-y divide-ink-200">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{partners.length === 0 ? (
|
{partners.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="px-6 py-12 text-center">
|
<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">
|
<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-emerald-600 dark:text-emerald-400" />
|
<CheckCircle className="w-6 h-6 text-ink-900" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-900 dark:text-white font-bold">Queue is empty</p>
|
<p className="text-ink-900 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-500 text-xs mt-1">All partners have been reviewed.</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
@ -117,17 +117,17 @@ export const ApprovalsPage: React.FC = () => {
|
|||||||
<motion.tr
|
<motion.tr
|
||||||
key={partner.id}
|
key={partner.id}
|
||||||
initial={{ opacity: 1 }}
|
initial={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0, x: -20, backgroundColor: 'rgba(59, 130, 246, 0.1)' }}
|
exit={{ opacity: 0, x: -20, backgroundColor: 'rgba(0, 0, 0, 0.02)' }}
|
||||||
className="hover:bg-slate-50 dark:hover:bg-white/5 transition-colors group"
|
className="hover:bg-ink-50 transition-colors group"
|
||||||
>
|
>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-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()}
|
{partner.email.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-bold text-slate-900 dark:text-white">{partner.email}</p>
|
<p className="font-bold text-ink-900">{partner.email}</p>
|
||||||
<p className="text-xs text-slate-500 dark:text-white/40 flex items-center gap-1">
|
<p className="text-xs text-ink-500 flex items-center gap-1">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{new Date(partner.createdAt).toLocaleDateString()}
|
{new Date(partner.createdAt).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
@ -137,36 +137,36 @@ export const ApprovalsPage: React.FC = () => {
|
|||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
{nda ? (
|
{nda ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle className="w-4 h-4 text-emerald-500" />
|
<CheckCircle className="w-4 h-4 text-ink-900" />
|
||||||
<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">
|
<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'}
|
{nda.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
|
||||||
</span>
|
</span>
|
||||||
{nda.documentUrl && (
|
{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>
|
||||||
) : (
|
) : (
|
||||||
<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" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
{msa ? (
|
{msa ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle className="w-4 h-4 text-emerald-500" />
|
<CheckCircle className="w-4 h-4 text-ink-900" />
|
||||||
<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">
|
<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'}
|
{msa.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
|
||||||
</span>
|
</span>
|
||||||
{msa.documentUrl && (
|
{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>
|
||||||
) : (
|
) : (
|
||||||
<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" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@ -174,7 +174,7 @@ export const ApprovalsPage: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => approvePartner(partner.id)}
|
onClick={() => approvePartner(partner.id)}
|
||||||
disabled={!nda || !msa || processingId === 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'}
|
{processingId === partner.id ? 'Approving...' : 'Approve Access'}
|
||||||
</button>
|
</button>
|
||||||
@ -191,3 +191,4 @@ export const ApprovalsPage: React.FC = () => {
|
|||||||
</div>
|
</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 { Users, Mail, CheckCircle, AlertCircle, ChevronRight, UserPlus, Clock, ShieldCheck, RefreshCw } from 'lucide-react';
|
||||||
import { axiosInstance } from '../../services/axios';
|
import { axiosInstance } from '../../services/axios';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
interface Partner {
|
interface Partner {
|
||||||
id: string;
|
id: string;
|
||||||
@ -14,29 +15,29 @@ interface Partner {
|
|||||||
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
|
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
|
||||||
PENDING_ONBOARDING: {
|
PENDING_ONBOARDING: {
|
||||||
label: 'Pending Onboarding',
|
label: 'Pending Onboarding',
|
||||||
color: 'text-amber-700 dark:text-amber-400',
|
color: 'text-ink-500',
|
||||||
bg: 'bg-amber-50 dark:bg-amber-500/10',
|
bg: 'bg-ink-50',
|
||||||
border: 'border-amber-200 dark:border-amber-500/20',
|
border: 'border-ink-200',
|
||||||
},
|
},
|
||||||
PENDING_APPROVAL: {
|
PENDING_APPROVAL: {
|
||||||
label: 'Awaiting Approval',
|
label: 'Awaiting Approval',
|
||||||
color: 'text-blue-700 dark:text-blue-400',
|
color: 'text-ink-0 bg-ink-900',
|
||||||
bg: 'bg-blue-50 dark:bg-blue-500/10',
|
bg: 'bg-ink-900',
|
||||||
border: 'border-blue-200 dark:border-blue-500/20',
|
border: 'border-ink-800',
|
||||||
},
|
},
|
||||||
APPROVED: {
|
APPROVED: {
|
||||||
label: 'Active',
|
label: 'Active',
|
||||||
color: 'text-emerald-700 dark:text-emerald-400',
|
color: 'text-ink-900 font-extrabold',
|
||||||
bg: 'bg-emerald-50 dark:bg-emerald-500/10',
|
bg: 'bg-ink-100',
|
||||||
border: 'border-emerald-200 dark:border-emerald-500/20',
|
border: 'border-ink-300',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusConfig = (status: string) => STATUS_CONFIG[status] ?? {
|
const getStatusConfig = (status: string) => STATUS_CONFIG[status] ?? {
|
||||||
label: status,
|
label: status,
|
||||||
color: 'text-slate-700 dark:text-slate-400',
|
color: 'text-ink-500',
|
||||||
bg: 'bg-slate-50 dark:bg-white/5',
|
bg: 'bg-ink-100',
|
||||||
border: 'border-slate-200 dark:border-white/10',
|
border: 'border-ink-200',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DirectoryPage: React.FC = () => {
|
export const DirectoryPage: React.FC = () => {
|
||||||
@ -71,7 +72,6 @@ export const DirectoryPage: React.FC = () => {
|
|||||||
const res = await axiosInstance.post('/auth/invite', { email });
|
const res = await axiosInstance.post('/auth/invite', { email });
|
||||||
setInviteResult({ token: res.data.token });
|
setInviteResult({ token: res.data.token });
|
||||||
setEmail('');
|
setEmail('');
|
||||||
// Refresh partner list to show the newly invited partner
|
|
||||||
fetchPartners();
|
fetchPartners();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setInviteResult({ error: err.response?.data?.error || 'Failed to send invite' });
|
setInviteResult({ error: err.response?.data?.error || 'Failed to send invite' });
|
||||||
@ -83,43 +83,82 @@ export const DirectoryPage: React.FC = () => {
|
|||||||
const counts = {
|
const counts = {
|
||||||
total: partners.length,
|
total: partners.length,
|
||||||
active: partners.filter(p => p.onboardingStatus === 'APPROVED').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 (
|
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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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
|
Partner Directory
|
||||||
</h1>
|
</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.
|
Manage your network and invite new partners to the platform.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={fetchPartners}
|
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"
|
title="Refresh"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4 group-hover:rotate-180 transition-transform duration-500" />
|
<RefreshCw className="w-4 h-4 group-hover:rotate-180 transition-transform duration-500" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Row */}
|
{/* Dynamic Real-time Approval Notification Banner */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
{counts.awaitingApproval > 0 && (
|
||||||
{[
|
<motion.div
|
||||||
{ label: 'Total Partners', value: counts.total, icon: Users },
|
initial={{ opacity: 0, y: -10 }}
|
||||||
{ label: 'Active', value: counts.active, icon: ShieldCheck },
|
animate={{ opacity: 1, y: 0 }}
|
||||||
{ label: 'Pending', value: counts.pending, icon: Clock },
|
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"
|
||||||
].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="absolute inset-0 bg-gradient-to-r from-ink-800 via-ink-900 to-ink-800 opacity-50" />
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center gap-3 relative z-10">
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-500 dark:text-white/40">{stat.label}</p>
|
<div className="w-10 h-10 rounded-xl bg-ink-0/10 flex items-center justify-center text-ink-0 shrink-0">
|
||||||
<stat.icon className="w-4 h-4 text-slate-400 dark:text-white/20" />
|
<AlertCircle className="w-5 h-5 animate-bounce" />
|
||||||
</div>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -127,28 +166,28 @@ export const DirectoryPage: React.FC = () => {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
{/* Invite Form */}
|
{/* Invite Form */}
|
||||||
<div className="lg:col-span-1">
|
<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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-lg font-bold text-slate-900 dark:text-white mb-2 relative z-10">Invite Partner</h3>
|
<h3 className="text-lg font-bold text-ink-900 mb-2 relative z-10">Invite Partner</h3>
|
||||||
<p className="text-xs text-slate-500 dark:text-white/50 mb-6 relative z-10">
|
<p className="text-xs text-ink-500 mb-6 relative z-10">
|
||||||
Generate a secure invitation link for a new partner.
|
Generate a secure invitation link for a new partner.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleInvite} className="space-y-4 relative z-10">
|
<form onSubmit={handleInvite} className="space-y-4 relative z-10">
|
||||||
<div>
|
<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">
|
<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
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="partner@company.com"
|
placeholder="partner@company.com"
|
||||||
required
|
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>
|
||||||
</div>
|
</div>
|
||||||
@ -156,7 +195,7 @@ export const DirectoryPage: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting || !email}
|
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'}
|
{isSubmitting ? 'Generating...' : 'Generate Invite Link'}
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
@ -171,18 +210,18 @@ export const DirectoryPage: React.FC = () => {
|
|||||||
className="mt-6 overflow-hidden"
|
className="mt-6 overflow-hidden"
|
||||||
>
|
>
|
||||||
{inviteResult.error ? (
|
{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">
|
<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-600 dark:text-red-400 shrink-0 mt-0.5" />
|
<AlertCircle className="w-5 h-5 text-red-650 shrink-0 mt-0.5" />
|
||||||
<p className="text-xs font-bold text-red-800 dark:text-red-400">{inviteResult.error}</p>
|
<p className="text-xs font-bold text-red-650">{inviteResult.error}</p>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<CheckCircle className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
|
<CheckCircle className="w-4 h-4 text-ink-900" />
|
||||||
<span className="text-xs font-bold text-emerald-700 dark:text-emerald-400">Invite Created!</span>
|
<span className="text-xs font-bold text-ink-900">Invite Created!</span>
|
||||||
</div>
|
</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>
|
<p className="text-[10px] text-ink-500 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">
|
<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}
|
{window.location.origin}/invite?token={inviteResult.token}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -195,10 +234,10 @@ export const DirectoryPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Partner List */}
|
{/* Partner List */}
|
||||||
<div className="lg:col-span-2">
|
<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">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-sm whitespace-nowrap">
|
<table className="w-full text-left text-sm whitespace-nowrap">
|
||||||
<thead className="bg-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>
|
<tr>
|
||||||
<th className="px-6 py-4">Partner</th>
|
<th className="px-6 py-4">Partner</th>
|
||||||
<th className="px-6 py-4">Status</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>
|
<th className="px-6 py-4">Joined</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-200 dark:divide-white/10">
|
<tbody className="divide-y divide-ink-200">
|
||||||
{loadingPartners ? (
|
{loadingPartners ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="px-6 py-12 text-center">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : partners.length === 0 ? (
|
) : partners.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} className="px-6 py-12 text-center">
|
<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">
|
<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-slate-400 dark:text-white/20" />
|
<Users className="w-6 h-6 text-ink-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-bold text-slate-900 dark:text-white">No partners yet</p>
|
<p className="text-sm font-bold text-ink-900">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-xs text-ink-500 mt-1">Use the invite form to add your first partner.</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
partners.map(partner => {
|
partners.map(partner => {
|
||||||
const sc = getStatusConfig(partner.onboardingStatus);
|
const sc = getStatusConfig(partner.onboardingStatus);
|
||||||
return (
|
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">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-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()}
|
{partner.email.charAt(0).toUpperCase()}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
@ -243,12 +282,12 @@ export const DirectoryPage: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
{partner.mfaEnabled ? (
|
{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>
|
||||||
<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()}
|
{new Date(partner.createdAt).toLocaleDateString()}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -264,3 +303,4 @@ export const DirectoryPage: React.FC = () => {
|
|||||||
</div>
|
</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;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const refreshAuthToken = async (): Promise<{ accessToken: string }> => {
|
export const refreshAuthToken = async (): Promise<AuthResponse> => {
|
||||||
const response = await axiosInstance.post<{ accessToken: string }>(
|
const response = await axiosInstance.post<AuthResponse>(
|
||||||
AUTH_API_ROUTES.refresh
|
AUTH_API_ROUTES.refresh
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
export const axiosInstance = axios.create({
|
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: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
@ -41,7 +41,12 @@ axiosInstance.interceptors.response.use(
|
|||||||
async (error) => {
|
async (error) => {
|
||||||
const originalRequest = error.config;
|
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) {
|
if (isRefreshing) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
failedQueue.push({ resolve, reject });
|
failedQueue.push({ resolve, reject });
|
||||||
@ -55,12 +60,10 @@ axiosInstance.interceptors.response.use(
|
|||||||
isRefreshing = true;
|
isRefreshing = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { accessToken } = await refreshAuthToken();
|
const { accessToken, user } = await refreshAuthToken();
|
||||||
const { setAuth, user } = useAuthStore.getState();
|
const { setAuth } = useAuthStore.getState();
|
||||||
|
|
||||||
if (user) {
|
setAuth({ user, accessToken });
|
||||||
setAuth({ user, accessToken });
|
|
||||||
}
|
|
||||||
|
|
||||||
processQueue(null, accessToken);
|
processQueue(null, accessToken);
|
||||||
originalRequest.headers.Authorization = `Bearer ${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
|
- [ ] Hook the `AuditLog` database table into all controller actions.
|
||||||
|
- [ ] Display an interactive activity timeline on the Admin dashboard showing:
|
||||||
Groups
|
- *Who downloaded what file and when.*
|
||||||
|
- *Organization onboardings and pending request queues.*
|
||||||
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.
|
|
||||||
|
|||||||
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