Compare commits

...

8 Commits

35 changed files with 2477 additions and 934 deletions

View File

@ -40,6 +40,12 @@ model User {
partnerGroup String?
assignedNdaId String?
assignedMsaId String?
website String?
sector String?
companySize String?
defaultTheme String? @default("dark")
companyName String?
showEcosystemTab Boolean @default(true)
assignedNda LegalDocument? @relation("AssignedNda", fields: [assignedNdaId], references: [id], onDelete: SetNull)
assignedMsa LegalDocument? @relation("AssignedMsa", fields: [assignedMsaId], references: [id], onDelete: SetNull)
organization Organization? @relation(fields: [organizationId], references: [id])
@ -150,20 +156,6 @@ model AuditLog {
actor User @relation(fields: [actorId], references: [id])
}
model BlogPost {
id String @id @default(uuid())
title String
content String @db.Text
author String
publishDate String
thumbnailUrl String?
readTime String?
tags String[]
status String @default("draft") // draft, published
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AssetGroup {
id String @id @default(uuid())
name String @unique
@ -172,3 +164,22 @@ model AssetGroup {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model EcosystemOffering {
id String @id @default(uuid())
name String @unique
type String // "PRODUCT" | "SERVICE"
tagline String
description String @db.Text
benefits String[]
websiteUrl String
ctaText String @default("Visit Website")
logoIcon String @default("Globe")
logoUrl String?
mediaUrl String?
mediaType String? // "IMAGE" | "VIDEO" | "GIF"
orderIndex Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@ -114,35 +114,88 @@ async function seed() {
console.log(`Partner (Approved) already exists: ${activeEmail}`);
}
// 7. Seed Blog Posts
const blogCount = await prisma.blogPost.count();
if (blogCount === 0) {
await prisma.blogPost.createMany({
data: [
// 5. Seed Ecosystem Offerings
const offerings = [
{
title: "Unlocking Ultra-Low Latency: Synthesis of RISC-V in Edge Devices",
content: "As edge intelligence grows, local compute units require custom processor topologies. In this article, we details the exact synthesis settings and pipelining optimizations that enabled our quad-core RISC-V IP block to achieve 35% better performance per watt compared to baseline architectures. We review cache organization, instruction fetch queue sizing, and how we tackled branch prediction overheads within tightly constrained FPGA silicon boundaries.",
author: "Dr. Marcus Vance",
publishDate: "2026-06-18",
thumbnailUrl: "https://images.unsplash.com/photo-1601524909162-be87252be298?w=500&auto=format&fit=crop&q=60",
readTime: "6 min read",
tags: ["RISC-V", "Hardware-Design", "Edge-AI"],
status: "published"
name: 'CodeNuk',
type: 'PRODUCT',
tagline: 'Accelerate software delivery with deterministic AI-powered backend generation.',
description: 'CodeNuk is an enterprise-grade AI platform that transforms Software Requirements Specifications (SRS) into production-ready backend foundations. By deterministically generating architecture, database schemas, APIs, security components, testing frameworks, and deployment-ready assets directly from business requirements, CodeNuk eliminates weeks of repetitive engineering effort while ensuring consistency, traceability, and architectural standardization.',
benefits: [
'Accelerates software development and time-to-market.',
'Standardizes backend architecture across projects.',
'Minimizes manual engineering effort and setup time.',
'Improves code consistency and traceability to business requirements.',
'Delivers deployment-ready backend assets with built-in testing and documentation.',
'Commercial model based on one-time project pricing instead of recurring subscriptions.'
],
websiteUrl: 'https://codenuk.com',
ctaText: 'Visit CodeNuk',
logoIcon: 'Code',
logoUrl: 'codenuk',
orderIndex: 0
},
{
title: "Introduction to CodeNuk: Scalable Microservice Architecture",
content: "Building distributed systems often involves navigating high configuration overhead. CodeNuk solves this by providing a unified, type-safe scaffolding that integrates telemetry, connection pools, and circuit-breakers out of the box. This deep-dive explains how CodeNuk leverages TypeScript decorators to declare service endpoints and automatically generate OpenAPI contracts and React Client hooks during the build phase, saving engineering weeks.",
author: "Yasha Khandelwal",
publishDate: "2026-06-25",
thumbnailUrl: "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=500&auto=format&fit=crop&q=60",
readTime: "8 min read",
tags: ["CodeNuk", "TypeScript", "Microservices"],
status: "published"
name: 'Tech4Biz Solutions',
type: 'SERVICE',
tagline: 'A strategic Technology Execution Partner delivering end-to-end digital engineering and transformation services.',
description: 'Tech4Biz Solutions partners with enterprises to architect, build, integrate, and scale secure, future-ready technology solutions. Our expertise spans software engineering, AI, cloud, automation, IoT, and cybersecurity, enabling organizations to accelerate digital transformation, modernize legacy systems, and deliver technology initiatives with speed, quality, and confidence.',
benefits: [
'Custom Software Engineering & Cloud Solutions',
'AI, Automation & IoT Integration',
'Legacy Modernization & Cybersecurity Audit',
'Strategic Technology Execution & Ownership'
],
websiteUrl: 'https://www.tech4bizsolutions.com',
ctaText: 'Visit Tech4Biz',
logoIcon: 'Briefcase',
logoUrl: 'tech4biz',
orderIndex: 1
},
{
name: 'Audittrax Labs',
type: 'SERVICE',
tagline: 'An AI-powered platform delivering Tech Due Diligence, Technical Advisory, and Continuous Assurance.',
description: 'Audittrax Labs enables enterprises, investors, and business leaders to make informed technology decisions through Tech Due Diligence, Technical Advisory, and AI-driven audit, risk, and compliance services. By combining continuous assurance, automated controls monitoring, and expert technical assessments, the platform helps organizations evaluate technology landscapes, mitigate risk, strengthen governance, and accelerate confident business decisions.',
benefits: [
'AI-Driven Compliance & Tech Auditing',
'Comprehensive Tech Due Diligence for Investors',
'Automated Security & Risk Monitoring',
'Technical Advisory & Governance Solutions'
],
websiteUrl: 'https://auditraxlabs.com',
ctaText: 'Visit Audittrax',
logoIcon: 'Shield',
logoUrl: 'auditraxlabs',
orderIndex: 2
},
{
name: 'Cloudtopiaa',
type: 'PRODUCT',
tagline: 'An enterprise cloud platform delivering secure, scalable, and high-performance infrastructure services.',
description: 'Cloudtopiaa enables organizations to accelerate their cloud journey through enterprise-grade infrastructure, storage, networking, security, and cloud-native services. Designed for modern workloads, the platform helps businesses migrate, deploy, manage, and scale applications with improved resilience, operational efficiency, and cost optimization.',
benefits: [
'Enterprise-grade secure infrastructure',
'High-performance storage, networking, and cloud-native services',
'Cost optimization & operational efficiency audit',
'Seamless cloud migration and automation tools'
],
websiteUrl: 'https://cloudtopiaa.com',
ctaText: 'Visit Cloudtopiaa',
logoIcon: 'Cloud',
logoUrl: 'cloudtopiaa',
orderIndex: 3
}
]
];
for (const offering of offerings) {
await prisma.ecosystemOffering.upsert({
where: { name: offering.name },
update: offering,
create: offering
});
console.log('Seeded Blog Posts.');
}
console.log('Seeded Ecosystem Offerings.');
console.log('Seeding completed successfully.');
}

View File

@ -15,7 +15,7 @@ import authRoutes from './routes/auth.routes';
import assetRoutes from './routes/asset.routes';
import orgRoutes from './routes/organization.routes';
import legalRoutes from './routes/legal.routes';
import blogRoutes from './routes/blog.routes';
import ecosystemRoutes from './routes/ecosystem.routes';
import { ensureBucketExists } from './utils/s3';
import { originStorage } from './utils/origin-storage';
@ -108,7 +108,7 @@ app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/assets', assetRoutes);
app.use('/api/v1/organizations', orgRoutes);
app.use('/api/v1/legal', legalRoutes);
app.use('/api/v1/blog', blogRoutes);
app.use('/api/v1/ecosystem', ecosystemRoutes);
app.get('/api/v1/health', (req: Request, res: Response) => {
res.status(200).json({ status: 'success', message: 'API is fully functional and real.' });

View File

@ -25,7 +25,7 @@ export class AuthController {
public invitePartner = async (req: Request, res: Response, next: NextFunction) => {
try {
const { email, organizationId, partnerGroup, assignedNdaId, assignedMsaId, sharedAssetIds, mfaEnabled } = z.object({
const { email, organizationId, partnerGroup, assignedNdaId, assignedMsaId, sharedAssetIds, mfaEnabled, showEcosystemTab } = z.object({
email: z.string().email(),
organizationId: z.string().uuid().optional(),
partnerGroup: z.string().optional().nullable(),
@ -33,6 +33,7 @@ export class AuthController {
assignedMsaId: z.string().optional().nullable(),
sharedAssetIds: z.array(z.string().uuid()).optional(),
mfaEnabled: z.boolean().optional(),
showEcosystemTab: z.boolean().optional(),
}).parse(req.body);
const result = await this.authService.invitePartner(email, {
@ -42,6 +43,7 @@ export class AuthController {
assignedMsaId: assignedMsaId || undefined,
sharedAssetIds,
mfaEnabled,
showEcosystemTab,
});
res.status(201).json({ message: 'Invite created', token: result.inviteToken });
} catch(err) { next(err); }
@ -50,20 +52,22 @@ export class AuthController {
public updatePartner = async (req: Request, res: Response, next: NextFunction) => {
try {
const { partnerId } = req.params;
const { partnerGroup, assignedNdaId, assignedMsaId, sharedAssetIds, mfaEnabled } = z.object({
const { partnerGroup, assignedNdaId, assignedMsaId, sharedAssetIds, mfaEnabled, showEcosystemTab } = z.object({
partnerGroup: z.string().optional().nullable(),
assignedNdaId: z.string().optional().nullable(),
assignedMsaId: z.string().optional().nullable(),
sharedAssetIds: z.array(z.string().uuid()).optional(),
mfaEnabled: z.boolean().optional(),
showEcosystemTab: z.boolean().optional(),
}).parse(req.body);
const result = await this.authService.updatePartner(partnerId, {
partnerGroup: partnerGroup === null ? undefined : partnerGroup,
assignedNdaId: assignedNdaId === null ? undefined : assignedNdaId,
assignedMsaId: assignedMsaId === null ? undefined : assignedMsaId,
partnerGroup: (partnerGroup === null || partnerGroup === '') ? null : partnerGroup,
assignedNdaId: assignedNdaId === undefined ? undefined : assignedNdaId,
assignedMsaId: assignedMsaId === undefined ? undefined : assignedMsaId,
sharedAssetIds,
mfaEnabled,
showEcosystemTab,
});
res.status(200).json(result);
} catch(err) { next(err); }
@ -153,5 +157,24 @@ export class AuthController {
res.status(200).json(user);
} catch(err) { next(err); }
};
public updateProfile = 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 { password, companyName, website, sector, companySize, defaultTheme } = req.body;
const updatedUser = await this.authService.updateProfile(userId, {
password,
companyName,
website,
sector,
companySize,
defaultTheme
});
res.status(200).json(updatedUser);
} catch (err) { next(err); }
};
}

View File

@ -1,91 +0,0 @@
import { Response, NextFunction } from 'express';
import { BlogService } from '../services/blog.service';
import { AuthRequest } from '../middleware/auth.middleware';
import prisma from '../utils/db';
export class BlogController {
private blogService = new BlogService();
public listBlogPosts = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { status, tag, search, author } = req.query;
const filters: any = {};
if (typeof tag === 'string') filters.tag = tag;
if (typeof search === 'string') filters.search = search;
if (typeof author === 'string') filters.author = author;
// Restrict status access if user is not admin
if (req.user?.role !== 'ADMIN') {
filters.status = 'published';
} else if (typeof status === 'string') {
filters.status = status;
}
const posts = await this.blogService.getBlogPosts(filters);
res.status(200).json(posts);
} catch (err) {
next(err);
}
};
public getBlogPost = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const post = await this.blogService.getBlogPostById(req.params.id);
if (!post) {
return res.status(404).json({ error: 'Blog post not found' });
}
// Clients cannot view drafts
if (post.status !== 'published' && req.user?.role !== 'ADMIN') {
return res.status(403).json({ error: 'Access forbidden' });
}
res.status(200).json(post);
} catch (err) {
next(err);
}
};
public createBlogPost = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
let authorVal = req.body.author || 'Technical Architect';
if (!req.body.author && req.user?.userId) {
const user = await prisma.user.findUnique({
where: { id: req.user.userId }
});
if (user) {
authorVal = user.email;
}
}
const postData = {
...req.body,
author: authorVal,
};
const post = await this.blogService.createBlogPost(postData);
res.status(201).json(post);
} catch (err) {
next(err);
}
};
public updateBlogPost = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const post = await this.blogService.updateBlogPost(req.params.id, req.body);
res.status(200).json(post);
} catch (err) {
next(err);
}
};
public deleteBlogPost = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await this.blogService.deleteBlogPost(req.params.id);
res.status(204).send();
} catch (err) {
next(err);
}
};
}

View File

@ -0,0 +1,114 @@
import { Request, Response, NextFunction } from 'express';
import path from 'path';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { s3Client, BUCKET_NAME } from '../utils/s3';
import prisma from '../utils/db';
export class EcosystemController {
public listOfferings = async (req: Request, res: Response, next: NextFunction) => {
try {
const user = (req as any).user;
const isAdmin = user?.role === 'ADMIN';
const offerings = await prisma.ecosystemOffering.findMany({
where: isAdmin ? undefined : { isActive: true },
orderBy: { orderIndex: 'asc' }
});
res.status(200).json(offerings);
} catch (err) {
next(err);
}
};
public createOffering = async (req: Request, res: Response, next: NextFunction) => {
try {
const data = req.body;
const created = await prisma.ecosystemOffering.create({
data: {
name: data.name,
type: data.type,
tagline: data.tagline,
description: data.description,
benefits: data.benefits || [],
websiteUrl: data.websiteUrl,
ctaText: data.ctaText || 'Visit Website',
logoIcon: data.logoIcon || 'Globe',
logoUrl: data.logoUrl || null,
mediaUrl: data.mediaUrl || null,
mediaType: data.mediaType || null,
orderIndex: data.orderIndex !== undefined ? data.orderIndex : 0,
isActive: data.isActive !== undefined ? data.isActive : true,
}
});
res.status(201).json(created);
} catch (err) {
next(err);
}
};
public updateOffering = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
const data = req.body;
const updated = await prisma.ecosystemOffering.update({
where: { id },
data: {
name: data.name,
type: data.type,
tagline: data.tagline,
description: data.description,
benefits: data.benefits,
websiteUrl: data.websiteUrl,
ctaText: data.ctaText,
logoIcon: data.logoIcon,
logoUrl: data.logoUrl,
mediaUrl: data.mediaUrl,
mediaType: data.mediaType,
orderIndex: data.orderIndex,
isActive: data.isActive,
}
});
res.status(200).json(updated);
} catch (err) {
next(err);
}
};
public deleteOffering = async (req: Request, res: Response, next: NextFunction) => {
try {
const { id } = req.params;
await prisma.ecosystemOffering.delete({
where: { id }
});
res.status(200).json({ message: 'Offering deleted successfully' });
} catch (err) {
next(err);
}
};
public uploadFile = async (req: Request, res: Response, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const filename = uniqueSuffix + path.extname(req.file.originalname);
await s3Client.send(new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: filename,
Body: req.file.buffer,
ContentType: req.file.mimetype,
}));
const fileUrl = `/uploads/${filename}`;
res.status(200).json({ url: fileUrl });
} catch (err) {
next(err);
}
};
}

View File

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

View File

@ -1,16 +0,0 @@
import { Router } from 'express';
import { BlogController } from '../controllers/blog.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const blogController = new BlogController();
router.use(authenticate);
router.get('/', blogController.listBlogPosts);
router.get('/:id', blogController.getBlogPost);
router.post('/new', requireRole('ADMIN'), blogController.createBlogPost);
router.patch('/:id', requireRole('ADMIN'), blogController.updateBlogPost);
router.delete('/:id', requireRole('ADMIN'), blogController.deleteBlogPost);
export default router;

View File

@ -0,0 +1,15 @@
import { Router } from 'express';
import { EcosystemController } from '../controllers/ecosystem.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
import { upload } from '../middleware/upload.middleware';
const router = Router();
const controller = new EcosystemController();
router.get('/offerings', authenticate, controller.listOfferings);
router.post('/offerings', authenticate, requireRole('ADMIN'), controller.createOffering);
router.put('/offerings/:id', authenticate, requireRole('ADMIN'), controller.updateOffering);
router.delete('/offerings/:id', authenticate, requireRole('ADMIN'), controller.deleteOffering);
router.post('/upload', authenticate, requireRole('ADMIN'), upload.single('file'), controller.uploadFile);
export default router;

View File

@ -177,9 +177,15 @@ export class AssetService {
return [];
}
return await prisma.asset.findMany({
where: {
// Support comma-separated multiple groups
const userGroups = user.partnerGroup
? user.partnerGroup.split(',').map(s => s.trim().toLowerCase())
: [];
const whereClause: any = {
status: 'published',
OR: [
{
sharedWith: {
some: {
organizationId: user.organizationId,
@ -189,7 +195,25 @@ export class AssetService {
]
}
}
},
}
]
};
if (userGroups.length > 0) {
whereClause.OR.push({
assetGroups: {
some: {
name: {
in: userGroups,
mode: 'insensitive'
}
}
}
});
}
return await prisma.asset.findMany({
where: whereClause,
include: {
sharedWith: {
include: {

View File

@ -50,7 +50,7 @@ export class AuthService {
return this.getUserById(user.id);
}
public async invitePartner(email: string, options: { organizationId?: string, partnerGroup?: string, assignedNdaId?: string, assignedMsaId?: string, sharedAssetIds?: string[], mfaEnabled?: boolean } = {}) {
public async invitePartner(email: string, options: { organizationId?: string, partnerGroup?: string, assignedNdaId?: string, assignedMsaId?: string, sharedAssetIds?: string[], mfaEnabled?: boolean, showEcosystemTab?: boolean } = {}) {
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) throw new AppError('Email already in use', 400);
@ -103,6 +103,7 @@ export class AuthService {
inviteTokenExp,
onboardingStatus,
mfaEnabled: options.mfaEnabled !== undefined ? options.mfaEnabled : true,
showEcosystemTab: options.showEcosystemTab !== undefined ? options.showEcosystemTab : true,
partnerGroup: options.partnerGroup || null,
assignedNdaId,
assignedMsaId,
@ -137,7 +138,7 @@ export class AuthService {
return { inviteToken, emailSent, emailError };
}
public async updatePartner(partnerId: string, options: { partnerGroup?: string, assignedNdaId?: string, assignedMsaId?: string, sharedAssetIds?: string[], mfaEnabled?: boolean }) {
public async updatePartner(partnerId: string, options: { partnerGroup?: string | null, assignedNdaId?: string | null, assignedMsaId?: string | null, sharedAssetIds?: string[], mfaEnabled?: boolean, showEcosystemTab?: boolean }) {
const user = await prisma.user.findUnique({
where: { id: partnerId }
});
@ -184,6 +185,7 @@ export class AuthService {
assignedNdaId,
assignedMsaId,
mfaEnabled: options.mfaEnabled !== undefined ? options.mfaEnabled : user.mfaEnabled,
showEcosystemTab: options.showEcosystemTab !== undefined ? options.showEcosystemTab : user.showEcosystemTab,
}
});
@ -377,6 +379,7 @@ export class AuthService {
email: true,
onboardingStatus: true,
mfaEnabled: true,
showEcosystemTab: true,
createdAt: true,
updatedAt: true,
organizationId: true,
@ -441,10 +444,17 @@ export class AuthService {
mfaEnabled: true,
organizationId: true,
onboardingStatus: true,
partnerGroup: true,
website: true,
sector: true,
companySize: true,
defaultTheme: true,
companyName: true,
createdAt: true,
updatedAt: true,
assignedNdaId: true,
assignedMsaId: true,
showEcosystemTab: true,
organization: {
select: {
id: true,
@ -472,6 +482,111 @@ export class AuthService {
mfaEnabled: true,
organizationId: true,
onboardingStatus: true,
partnerGroup: true,
website: true,
sector: true,
companySize: true,
defaultTheme: true,
companyName: true,
createdAt: true,
updatedAt: true,
assignedNdaId: true,
assignedMsaId: true,
showEcosystemTab: true,
organization: {
select: {
id: true,
name: true,
status: true,
}
}
}
});
}
}
if (user && user.role === 'PARTNER_USER' && user.organizationId) {
const sharedAssets = await prisma.sharedAsset.findMany({
where: {
organizationId: user.organizationId,
OR: [
{ userId: null },
{ userId: user.id }
]
},
select: {
assetId: true,
asset: {
select: {
id: true,
title: true,
type: true,
categoryId: true,
subcategory: true,
url: true
}
}
}
});
return {
...user,
sharedAssets
};
}
return user;
}
public async updateProfile(userId: string, data: {
password?: string;
companyName?: string;
website?: string;
sector?: string;
companySize?: string;
defaultTheme?: string;
}) {
const user = await prisma.user.findUnique({
where: { id: userId },
include: { organization: true }
});
if (!user) throw new AppError('User not found', 404);
const updateData: any = {};
if (data.password) {
updateData.passwordHash = await bcrypt.hash(data.password, 10);
}
if (data.website !== undefined) updateData.website = data.website;
if (data.sector !== undefined) updateData.sector = data.sector;
if (data.companySize !== undefined) updateData.companySize = data.companySize;
if (data.defaultTheme !== undefined) updateData.defaultTheme = data.defaultTheme;
if (data.companyName !== undefined) {
updateData.companyName = data.companyName;
if (user.organizationId) {
await prisma.organization.update({
where: { id: user.organizationId },
data: { name: data.companyName }
});
}
}
const updated = await prisma.user.update({
where: { id: userId },
data: updateData,
select: {
id: true,
email: true,
role: true,
mfaEnabled: true,
organizationId: true,
onboardingStatus: true,
partnerGroup: true,
website: true,
sector: true,
companySize: true,
defaultTheme: true,
companyName: true,
createdAt: true,
updatedAt: true,
assignedNdaId: true,
@ -485,9 +600,7 @@ export class AuthService {
}
}
});
}
}
return user;
return this.getUserById(userId);
}
}

View File

@ -1,124 +0,0 @@
import prisma from '../utils/db';
export interface BlogPostFilters {
status?: string;
tag?: string;
search?: string;
author?: string;
}
export class BlogService {
public async getBlogPosts(filters: BlogPostFilters = {}) {
const { status, tag, search, author } = filters;
const where: any = {};
if (status) {
where.status = status;
}
if (tag) {
where.tags = {
has: tag,
};
}
if (author) {
where.author = {
contains: author,
mode: 'insensitive',
};
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ content: { contains: search, mode: 'insensitive' } },
];
}
return await prisma.blogPost.findMany({
where,
orderBy: {
publishDate: 'desc',
},
});
}
public async getBlogPostById(id: string) {
return await prisma.blogPost.findUnique({
where: { id },
});
}
public async createBlogPost(data: any) {
const { tags, content, ...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);
}
}
// Auto-calculate read time
const wordsPerMinute = 200;
const wordCount = content ? content.trim().split(/\s+/).length : 0;
const readTimeVal = `${Math.max(1, Math.ceil(wordCount / wordsPerMinute))} min read`;
// Default publishDate if not provided
const publishDateVal = rest.publishDate || new Date().toISOString().split('T')[0];
return await prisma.blogPost.create({
data: {
...rest,
content: content || '',
tags: parsedTags,
readTime: readTimeVal,
publishDate: publishDateVal,
},
});
}
public async updateBlogPost(id: string, data: any) {
const { tags, content, ...rest } = data;
const updateData: any = { ...rest };
if (content !== undefined) {
updateData.content = content;
// Auto-calculate read time
const wordsPerMinute = 200;
const wordCount = content ? content.trim().split(/\s+/).length : 0;
updateData.readTime = `${Math.max(1, Math.ceil(wordCount / wordsPerMinute))} min read`;
}
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;
}
return await prisma.blogPost.update({
where: { id },
data: updateData,
});
}
public async deleteBlogPost(id: string) {
return await prisma.blogPost.delete({
where: { id },
});
}
}

View File

@ -6,7 +6,6 @@ import {
ShieldCheck,
ClipboardCheck,
FolderGit2,
BookCopy,
Users,
LogOut,
Menu,
@ -15,6 +14,7 @@ import {
Moon,
ChevronRight,
ChevronLeft,
Globe
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
@ -36,7 +36,7 @@ export const AdminLayout: React.FC = () => {
{ 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: "Blog CMS", path: "/admin/blog", icon: BookCopy },
{ name: "Ecosystem Manager", path: "/admin/ecosystem", icon: Globe },
];
return (

View File

@ -2,14 +2,9 @@ import React, { useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useThemeStore } from '../../hooks/use-theme';
import { useAuthStore } from '../../hooks/use-auth';
import { Cpu, BookOpen, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft } from 'lucide-react';
import { Cpu, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft, Settings, Globe } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
const navItems = [
{ name: 'Assets', path: '/client', icon: Cpu, label: 'Asset Explorer' },
{ name: 'Blog', path: '/client/blog', icon: BookOpen, label: 'Insights Blog' },
];
export const ClientLayout: React.FC = () => {
const { user, logout } = useAuthStore();
const { theme, toggleTheme } = useThemeStore();
@ -18,6 +13,71 @@ export const ClientLayout: React.FC = () => {
const [mobileOpen, setMobileOpen] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(false);
const dynamicNavItems = [
{ name: 'Assets', path: '/client', icon: Cpu, label: 'Asset Explorer' },
{ name: 'Ecosystem', path: '/client/ecosystem', icon: Globe, label: 'Explore More' }
];
// Settings Modal State
const [settingsOpen, setSettingsOpen] = useState(false);
const [defaultTheme, setDefaultTheme] = useState(user?.defaultTheme || 'dark');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [settingsError, setSettingsError] = useState('');
const [settingsSuccess, setSettingsSuccess] = useState('');
const openSettings = () => {
setDefaultTheme(user?.defaultTheme || 'dark');
setPassword('');
setConfirmPassword('');
setSettingsError('');
setSettingsSuccess('');
setSettingsOpen(true);
};
const handleSaveSettings = async (e: React.FormEvent) => {
e.preventDefault();
setSettingsError('');
setSettingsSuccess('');
if (password && password !== confirmPassword) {
setSettingsError("Passwords do not match");
return;
}
setIsSaving(true);
try {
const { updateProfile } = await import('../../services/auth-api');
const updatedUser = await updateProfile({
defaultTheme: defaultTheme || undefined,
password: password || undefined
});
// Update auth store
useAuthStore.getState().setAuth({
user: updatedUser,
accessToken: useAuthStore.getState().accessToken!
});
if (defaultTheme) {
useThemeStore.getState().setTheme(defaultTheme as any);
}
setSettingsSuccess("Profile settings updated successfully!");
setPassword('');
setConfirmPassword('');
setTimeout(() => {
setSettingsOpen(false);
}, 1500);
} catch (err: any) {
setSettingsError(err.response?.data?.error || err.message || "Failed to update profile settings");
} finally {
setIsSaving(false);
}
};
const handleLogout = () => {
logout();
navigate('/login');
@ -56,7 +116,7 @@ export const ClientLayout: React.FC = () => {
{/* Navigation */}
<nav className={`flex-1 py-8 space-y-2 overflow-y-auto transition-all duration-300 ${isCollapsed ? 'px-2' : 'px-4'}`}>
{navItems.map(item => {
{dynamicNavItems.map(item => {
const Icon = item.icon;
const isActive = location.pathname === item.path;
return (
@ -78,6 +138,17 @@ export const ClientLayout: React.FC = () => {
</Link>
);
})}
<button
onClick={openSettings}
className={`w-full flex items-center gap-3 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group relative cursor-pointer ${
isCollapsed ? 'justify-center px-0' : 'px-4'
} text-ink-500 hover:text-ink-900 hover:bg-ink-100`}
title={isCollapsed ? 'Profile Settings' : undefined}
>
<Settings className={`w-5 h-5 transition-transform group-hover:scale-110 ${settingsOpen ? 'text-ink-950' : 'text-ink-400 group-hover:text-ink-900'}`} />
{!isCollapsed && <span>Profile Settings</span>}
</button>
</nav>
{/* Footer */}
@ -110,6 +181,8 @@ export const ClientLayout: React.FC = () => {
</div>
)}
{/* Profile Settings button removed from here */}
<button
onClick={handleLogout}
className={`w-full flex items-center justify-center gap-2 rounded-xl text-sm font-bold tracking-wide text-red-650 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200 cursor-pointer ${isCollapsed ? 'py-2 px-0' : 'px-4 py-2.5'}`}
@ -156,12 +229,19 @@ export const ClientLayout: React.FC = () => {
</button>
</div>
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
{navItems.map(item => (
{dynamicNavItems.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-ink-100 text-ink-900 border border-ink-300' : 'text-ink-650'}`}>
<item.icon className="w-5 h-5" />
{item.label}
</Link>
))}
<button
onClick={() => { setMobileOpen(false); openSettings(); }}
className="flex items-center gap-3 w-full px-4 py-3 rounded-xl text-sm font-semibold text-ink-650 hover:bg-ink-100 cursor-pointer animate-fade-in"
>
<Settings className="w-5 h-5 text-ink-400" />
Profile Settings
</button>
</nav>
<div className="p-4 border-t border-ink-200 space-y-4">
<button onClick={toggleTheme} className="flex items-center justify-between w-full p-3 rounded-xl bg-ink-50 border border-ink-200 text-sm font-bold text-ink-650">
@ -189,6 +269,130 @@ export const ClientLayout: React.FC = () => {
</div>
</footer>
</main>
{/* Settings Modal */}
<AnimatePresence>
{settingsOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => !isSaving && setSettingsOpen(false)}
className="fixed inset-0 bg-ink-900/60 backdrop-blur-md"
/>
{/* Modal Body */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="relative w-full max-w-xl bg-ink-0 border border-ink-200 rounded-3xl shadow-2xl p-6 md:p-8 z-50 overflow-y-auto max-h-[90vh] text-ink-900 transition-colors duration-300"
>
<div className="flex justify-between items-start mb-6">
<div>
<h3 className="text-xl font-black tracking-tight text-ink-950">Profile Settings</h3>
<p className="text-xs font-bold text-ink-500 mt-1">Refine your profile parameters and manage credentials.</p>
</div>
<button
onClick={() => setSettingsOpen(false)}
disabled={isSaving}
className="p-1.5 rounded-lg bg-ink-50 border border-ink-200 text-ink-500 hover:text-ink-900 disabled:opacity-50 cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{settingsError && (
<div className="mb-4 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-xs font-bold text-red-600 dark:text-red-400">
{settingsError}
</div>
)}
{settingsSuccess && (
<div className="mb-4 p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-xs font-bold text-emerald-600 dark:text-emerald-400">
{settingsSuccess}
</div>
)}
<form onSubmit={handleSaveSettings} className="space-y-4">
{/* Read-only Email Field */}
<div>
<label className="block text-xs font-extrabold uppercase tracking-widest text-ink-500 mb-1.5">Email Address</label>
<input
type="email"
value={user?.email || ''}
disabled
className="w-full px-4 py-2.5 rounded-xl bg-ink-100 border border-ink-200 text-ink-450 text-sm font-bold cursor-not-allowed"
/>
<p className="text-[10px] font-bold text-ink-400 mt-1">Email address cannot be changed.</p>
</div>
{/* Company Profile Fields Removed */}
{/* Theme settings */}
<div>
<label className="block text-xs font-extrabold uppercase tracking-widest text-ink-500 mb-1.5">Default Theme</label>
<select
value={defaultTheme}
onChange={(e) => setDefaultTheme(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-ink-50 border border-ink-200 focus:border-ink-400 focus:bg-ink-0 text-sm font-bold transition-all outline-none cursor-pointer text-ink-900"
>
<option value="dark">Dark Theme</option>
<option value="light">Light Theme</option>
</select>
</div>
{/* Security Fields */}
<div className="border-t border-ink-200 pt-4 mt-2">
<h4 className="text-sm font-bold text-ink-950 mb-3">Change Password</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-extrabold uppercase tracking-widest text-ink-500 mb-1.5">New Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-ink-50 border border-ink-200 focus:border-ink-400 focus:bg-ink-0 text-sm font-bold transition-all outline-none"
placeholder="••••••••"
/>
</div>
<div>
<label className="block text-xs font-extrabold uppercase tracking-widest text-ink-500 mb-1.5">Confirm New Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-ink-50 border border-ink-200 focus:border-ink-400 focus:bg-ink-0 text-sm font-bold transition-all outline-none"
placeholder="••••••••"
/>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3 border-t border-ink-200 pt-5 mt-4">
<button
type="button"
disabled={isSaving}
onClick={() => setSettingsOpen(false)}
className="px-5 py-2.5 rounded-xl text-sm font-bold bg-ink-50 hover:bg-ink-100 border border-ink-200 text-ink-700 disabled:opacity-50 cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={isSaving}
className="px-6 py-2.5 rounded-xl text-sm font-bold bg-ink-900 hover:bg-ink-950 text-ink-0 hover:shadow-lg transition-all disabled:opacity-50 cursor-pointer flex items-center justify-center gap-2"
>
{isSaving ? 'Saving Changes...' : 'Save Settings'}
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
};

View File

@ -41,21 +41,27 @@ const LegalTemplatesPage = React.lazy(() =>
default: m.LegalTemplatesPage,
})),
);
const BlogCatalog = React.lazy(() =>
import("../../features/blog/components/BlogCatalog").then((m) => ({
default: m.BlogCatalog,
})),
);
const ClientAgreementsPage = React.lazy(() =>
import("../../pages/ClientAgreementsPage").then((m) => ({
default: m.ClientAgreementsPage,
})),
);
const EcosystemPage = React.lazy(() =>
import("../../pages/EcosystemPage").then((m) => ({
default: m.EcosystemPage,
})),
);
const GroupDetailsPage = React.lazy(() =>
import("../../pages/admin/GroupDetailsPage").then((m) => ({
default: m.GroupDetailsPage,
})),
);
const EcosystemManagerPage = React.lazy(() =>
import("../../pages/admin/EcosystemManagerPage").then((m) => ({
default: m.EcosystemManagerPage,
})),
);
// Dummy Components for routing
const LoadingFallback = () => (
@ -136,10 +142,10 @@ export const router = createBrowserRouter([
),
},
{
path: "blog",
path: "ecosystem",
element: (
<Suspense fallback={<LoadingFallback />}>
<BlogCatalog />
<EcosystemPage />
</Suspense>
),
},
@ -177,14 +183,6 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: "blog",
element: (
<Suspense fallback={<LoadingFallback />}>
<BlogCatalog />
</Suspense>
),
},
{
path: "approvals",
element: (
@ -209,6 +207,14 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: "ecosystem",
element: (
<Suspense fallback={<LoadingFallback />}>
<EcosystemManagerPage />
</Suspense>
),
},
],
},
]);

View File

@ -18,7 +18,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({ title, subtitle, badge,
{badge && <div className="shrink-0">{badge}</div>}
</div>
{subtitle && (
<p className="text-xs page-subtitle font-medium text-ink-500 truncate max-w-3xl">
<p className="text-xs page-subtitle font-medium text-ink-500 max-w-3xl leading-relaxed">
{subtitle}
</p>
)}

View File

@ -13,7 +13,8 @@ import {
Download,
Clock,
AlertCircle,
Globe
Globe,
Sparkles
} from 'lucide-react';
import type { Asset } from '../../../types/assets';
import type { User } from '../../../types/auth';
@ -47,6 +48,7 @@ interface AssetCardProps {
onToggleSelect?: (assetId: string) => void;
isExpanded?: boolean;
onToggleExpand?: () => void;
isRecommended?: boolean;
}
export const AssetCard: React.FC<AssetCardProps> = ({
@ -64,7 +66,8 @@ export const AssetCard: React.FC<AssetCardProps> = ({
isSelected = false,
onToggleSelect,
isExpanded = false,
onToggleExpand
onToggleExpand,
isRecommended = false
}) => {
const isMenuOpen = activeMenuId === asset.id;
const canDirectDownload = user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED';
@ -153,6 +156,8 @@ export const AssetCard: React.FC<AssetCardProps> = ({
className={`group bg-ink-0 border rounded-xl p-4 transition-all duration-300 flex flex-col justify-between cursor-pointer min-h-[410px] ${
isExpanded
? 'absolute z-20 top-0 left-0 right-0 h-auto shadow-2xl border-ink-300 bg-ink-0'
: isRecommended
? 'relative w-full h-full border-amber-500/35 bg-gradient-to-br from-amber-500/[0.02] via-ink-0 to-ink-0 hover:border-amber-500 hover:shadow-lg hover:shadow-amber-500/10 hover:-translate-y-0.5'
: 'relative w-full h-full border-ink-200 hover:border-ink-300 hover:shadow-md hover:-translate-y-0.5'
} ${isSelected ? 'border-ink-900 ring-1 ring-ink-900 bg-ink-50/30' : ''}`}
>
@ -169,6 +174,12 @@ export const AssetCard: React.FC<AssetCardProps> = ({
className="w-3.5 h-3.5 rounded border-ink-300 bg-ink-0 text-ink-900 focus:ring-ink-900/10 cursor-pointer shadow-sm"
/>
)}
{isRecommended && (
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-500 text-ink-950 text-[9px] font-extrabold uppercase tracking-wider shadow-sm border border-amber-400">
<Sparkles className="w-2.5 h-2.5 fill-ink-950" />
<span>Recommended for You</span>
</div>
)}
</div>
<div className="absolute top-2 right-2 z-10 flex items-center gap-1">

View File

@ -274,7 +274,10 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
</div>
}
size="full"
className={isMaximized ? '!max-w-[96vw] !max-h-[92vh] !h-[92vh] !mt-4' : '!max-w-[85vw] md:!max-w-[80vw] xl:!max-w-[75vw] !h-[75vh] !max-h-[75vh] !w-full'}
className={isMaximized
? '!fixed !inset-0 !z-[10001] !max-w-none !max-h-none !w-screen !h-screen !rounded-none !border-none !m-0'
: '!max-w-[85vw] md:!max-w-[80vw] xl:!max-w-[75vw] !h-[75vh] !max-h-[75vh] !w-full'
}
footer={
<>
<Button
@ -625,11 +628,33 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
</div>
</div>
) : isWord ? (
<div className="flex-1 overflow-auto bg-slate-100 p-6 md:p-8 flex justify-center">
<div className="flex-1 overflow-auto bg-slate-100 p-2 sm:p-6 md:p-8">
<style>{`
.docx-wrapper {
background: transparent !important;
padding: 0 !important;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
}
.docx {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1) !important;
border: 1px solid #e2e8f0 !important;
border-radius: 12px !important;
max-width: 100% !important;
margin: 0 auto 24px auto !important;
background-color: white !important;
}
@media (max-width: 640px) {
.docx {
padding: 16px !important;
margin-bottom: 12px !important;
}
}
`}</style>
<div
ref={wordContainerRef}
className="w-full max-w-4xl bg-white shadow-md border border-slate-200 rounded-xl p-8 md:p-12 overflow-y-auto"
style={{ minHeight: '842px' }}
className="w-full max-w-4xl mx-auto"
/>
</div>
) : (

View File

@ -1,328 +0,0 @@
import React, { useState, useEffect } from "react";
import type { BlogPost } from "../../../types";
import { getBlogPosts, createBlogPost } from "../../../services/blog-api";
import { useAuthStore } from "../../../hooks/use-auth";
import {
BookOpen,
User,
Calendar,
Clock,
Plus,
Sparkles,
Send,
} from "lucide-react";
import Modal from "../../../components/ui/Modal";
import { PageLayout } from "../../../components/layout/PageLayout";
export const BlogCatalog: React.FC = () => {
const { user } = useAuthStore();
const [posts, setPosts] = useState<BlogPost[]>([]);
const [loading, setLoading] = useState(true);
const isAdmin = user?.role === "ADMIN";
// CMS modal state
const [isOpen, setIsOpen] = useState(false);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [tagsInput, setTagsInput] = useState("");
const [thumbnailUrl, setThumbnailUrl] = useState("");
const [status, setStatus] = useState<"draft" | "published">("draft");
const [formError, setFormError] = useState("");
const [submitting, setSubmitting] = useState(false);
const fetchPosts = async () => {
setLoading(true);
try {
const data = await getBlogPosts();
setPosts(data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPosts();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError("");
if (!title.trim() || !content.trim()) {
setFormError("Title and content are required.");
return;
}
setSubmitting(true);
try {
const tags = tagsInput
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0);
const payload = {
title,
content,
tags,
thumbnailUrl: thumbnailUrl.trim() || undefined,
status,
author: "Technical Architect",
};
const newPost = await createBlogPost(payload);
setPosts((prev) => [newPost, ...prev]);
setTitle("");
setContent("");
setTagsInput("");
setThumbnailUrl("");
setStatus("draft");
setIsOpen(false);
} catch (err) {
setFormError("Failed to publish article.");
} finally {
setSubmitting(false);
}
};
// Header component
const headerNode = (
<div>
<h2 className="text-xl font-bold text-ink-800">
Engineering Blog & Insights
</h2>
<p className="text-sm text-ink-600">
Deep-dives into RISC-V pipelining, CodeNuk scaffolding practices,
and edge security optimizations.
</p>
</div>
);
// Toolbar component (only render if admin is true to show Write Post action)
const toolbarNode = isAdmin ? (
<div className="flex justify-end p-3.5 bg-ink-0 border border-ink-200 rounded-xl shadow-sm">
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-1.5 rounded-lg bg-ink-900 px-4 py-2 text-xs font-semibold text-ink-0 hover:bg-ink-800 transition-all shadow-sm cursor-pointer"
>
<Plus className="h-4 w-4" />
Write Post
</button>
</div>
) : undefined;
return (
<PageLayout header={headerNode} toolbar={toolbarNode}>
<div className="p-5 flex-1 min-h-0 overflow-y-auto">
{loading ? (
<div className="grid gap-6 md:grid-cols-2">
{[1, 2].map((n) => (
<div
key={n}
className="animate-pulse rounded-xl border border-ink-200 bg-ink-0 p-5 space-y-4"
>
<div className="h-48 rounded-lg bg-ink-100" />
<div className="h-4 w-3/4 rounded bg-ink-100" />
<div className="h-20 rounded bg-ink-100" />
</div>
))}
</div>
) : posts.length === 0 ? (
<div className="rounded-xl border border-ink-200 bg-ink-0 p-12 text-center">
<BookOpen className="h-8 w-8 text-ink-300 mx-auto mb-2" />
<p className="text-sm text-ink-600">No blog posts published yet.</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-2">
{posts.map((post) => (
<article
key={post.id}
className="rounded-xl border border-ink-200 bg-ink-0 shadow-sm overflow-hidden flex flex-col hover:border-ink-400 transition-all duration-300"
>
<div className="h-48 w-full bg-ink-100 relative">
<img
src={post.thumbnailUrl}
alt={post.title}
className="w-full h-full object-cover"
/>
{isAdmin && (
<span
className={`absolute right-3 top-3 rounded-full border px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider ${
post.status === "published"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-ink-100 text-ink-700 border-ink-200"
}`}
>
{post.status}
</span>
)}
</div>
<div className="p-5 flex-1 flex flex-col justify-between space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-3 text-[10px] font-bold text-ink-600 uppercase tracking-wide">
<span className="flex items-center gap-1">
<User className="h-3.5 w-3.5" />
{post.author}
</span>
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{post.publishDate}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{post.readTime}
</span>
</div>
<h3 className="text-base font-bold text-ink-800 leading-snug line-clamp-1">
{post.title}
</h3>
<p className="text-xs text-ink-600 leading-relaxed line-clamp-3">
{post.content}
</p>
</div>
<div className="flex flex-wrap gap-1.5 pt-2 border-t border-ink-100">
{post.tags.map((t) => (
<span
key={t}
className="rounded bg-ink-50 px-2 py-0.5 text-[9px] font-semibold text-ink-700 border border-ink-200"
>
#{t}
</span>
))}
</div>
</div>
</article>
))}
</div>
)}
</div>
{/* Post Creator Modal */}
<Modal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title={
<span className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-ink-900" />
Write Blog Article
</span>
}
subtitle="Draft or publish a technical write-up for the developer channel."
size="md"
>
{formError && (
<div className="mb-4 rounded-lg bg-red-500/10 border border-red-500/20 p-3 text-xs font-semibold text-red-650">
{formError}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">
Article Title
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Optimizing Pipeline Hazards in RV64GC Core Designs"
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">
Content (Markdown supported)
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write the full post text..."
rows={6}
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none resize-y bg-ink-50 text-ink-900 placeholder-ink-400"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">
Tags (comma-separated)
</label>
<input
type="text"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="RISC-V, RTL-Design, Edge-Compute"
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">
Article Cover Photo URL
</label>
<input
type="url"
value={thumbnailUrl}
onChange={(e) => setThumbnailUrl(e.target.value)}
placeholder="https://images.unsplash.com/..."
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">
Publish Status
</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-1.5 text-xs text-ink-800 font-semibold cursor-pointer">
<input
type="radio"
name="blogStatus"
checked={status === "draft"}
onChange={() => setStatus("draft")}
className="text-ink-900 focus:ring-ink-900/20"
/>
Draft
</label>
<label className="flex items-center gap-1.5 text-xs text-ink-800 font-semibold cursor-pointer">
<input
type="radio"
name="blogStatus"
checked={status === "published"}
onChange={() => setStatus("published")}
className="text-ink-900 focus:ring-ink-900/20"
/>
Published
</label>
</div>
</div>
<div className="border-t border-ink-100 pt-4 flex justify-end gap-3 mt-6">
<button
type="button"
onClick={() => setIsOpen(false)}
className="rounded-lg border border-ink-200 bg-ink-0 px-4 py-2 text-sm font-semibold text-ink-600 hover:bg-ink-50 cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
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 cursor-pointer disabled:opacity-50"
>
<Send className="h-4 w-4" />
{submitting ? "Publishing..." : "Publish Article"}
</button>
</div>
</form>
</Modal>
</PageLayout>
);
};
export default BlogCatalog;

View File

@ -3,6 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
import type { User, AuthResponse } from '../types/auth';
import { refreshAuthToken } from '../services/auth-api';
import { axiosInstance } from '../services/axios';
import { useThemeStore } from './use-theme';
interface AuthState {
user: User | null;
@ -21,12 +22,17 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: false,
accessToken: null,
isInitializing: true,
setAuth: (data) => set({
setAuth: (data) => {
if (data.user?.defaultTheme) {
useThemeStore.getState().setTheme(data.user.defaultTheme as any);
}
set({
user: data.user,
accessToken: data.accessToken,
isAuthenticated: true,
isInitializing: false,
}),
});
},
logout: () => set({
user: null,
accessToken: null,
@ -39,6 +45,9 @@ export const useAuthStore = create<AuthState>()(
if (state.accessToken && state.user) {
try {
const res = await axiosInstance.get('/auth/me');
if (res.data?.defaultTheme) {
useThemeStore.getState().setTheme(res.data.defaultTheme as any);
}
set({
user: res.data,
isAuthenticated: true,
@ -58,6 +67,9 @@ export const useAuthStore = create<AuthState>()(
// Try refreshing token
try {
const data = await refreshAuthToken();
if (data.user?.defaultTheme) {
useThemeStore.getState().setTheme(data.user.defaultTheme as any);
}
set({
user: data.user,
accessToken: data.accessToken,

View File

@ -6,6 +6,7 @@ interface ThemeState {
theme: Theme;
toggleTheme: () => void;
initTheme: () => void;
setTheme: (theme: Theme) => void;
}
export const useThemeStore = create<ThemeState>((set) => ({
@ -31,5 +32,14 @@ export const useThemeStore = create<ThemeState>((set) => ({
}
return { theme: initialTheme };
}),
setTheme: (theme) => set(() => {
localStorage.setItem('theme-preference', theme);
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
return { theme };
})
}));

View File

@ -1,4 +1,4 @@
import type { User, Asset, Category, BlogPost, Announcement } from '../types';
import type { User, Asset, Category, Announcement } from '../types';
import { initializeStorage } from './mock-data';
// Ensure data is seeded in localStorage
@ -90,19 +90,7 @@ export const apiClient = {
return { data: categories as unknown as T, status: 200 };
}
// Blog posts
if (cleanUrl === '/blog') {
const posts = getStored<BlogPost[]>('t4b_blog_posts');
// Filter out drafts for client roles
const activeUserEmail = sessionStorage.getItem('t4b_session_email');
const users = getStored<User[]>('t4b_users');
const activeUser = users.find(u => u.email === activeUserEmail);
let filtered = [...posts];
if (!activeUser || activeUser.role !== 'ADMIN') {
filtered = filtered.filter(p => p.status === 'published');
}
return { data: filtered as unknown as T, status: 200 };
}
// Announcements
if (cleanUrl === '/announcements') {
@ -263,23 +251,7 @@ export const apiClient = {
return { data: newAsset as unknown as T, status: 201 };
}
if (url === '/blog/new') {
const posts = getStored<BlogPost[]>('t4b_blog_posts');
const newPost: BlogPost = {
id: `blog-${Date.now()}`,
title: payload.title,
content: payload.content,
author: payload.author || 'Admin Staff',
publishDate: new Date().toISOString().split('T')[0],
thumbnailUrl: payload.thumbnailUrl || 'https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=500&auto=format&fit=crop&q=60',
readTime: `${Math.ceil(payload.content.split(' ').length / 200)} min read`,
tags: payload.tags || [],
status: payload.status || 'draft'
};
posts.push(newPost);
setStored('t4b_blog_posts', posts);
return { data: newPost as unknown as T, status: 201 };
}
throw { status: 404, message: 'Route not found' };
},
@ -287,6 +259,30 @@ export const apiClient = {
put: async <T>(url: string, payload: any): Promise<ApiResponse<T>> => {
await delay();
if (url === '/auth/profile') {
const activeUserEmail = sessionStorage.getItem('t4b_session_email');
const users = getStored<User[]>('t4b_users');
const index = users.findIndex(u => u.email === activeUserEmail);
if (index === -1) {
throw { status: 401, message: 'Unauthorized' };
}
const user = users[index];
if (payload.password) {
user.passwordHash = 'hashed_new_password';
}
if (payload.companyName !== undefined) user.companyName = payload.companyName;
if (payload.website !== undefined) user.website = payload.website;
if (payload.sector !== undefined) user.sector = payload.sector;
if (payload.companySize !== undefined) user.companySize = payload.companySize;
if (payload.defaultTheme !== undefined) user.defaultTheme = payload.defaultTheme;
users[index] = user;
setStored('t4b_users', users);
return { data: user as unknown as T, status: 200 };
}
if (url.startsWith('/admin/clients/')) {
const clientId = url.split('/admin/clients/')[1];
const users = getStored<User[]>('t4b_users');

View File

@ -1,4 +1,4 @@
import type { User, Asset, Category, BlogPost, Announcement } from "../types";
import type { User, Asset, Category, Announcement } from "../types";
// Pre-defined categories
export const SEED_CATEGORIES: Category[] = [
@ -149,35 +149,7 @@ export const SEED_ASSETS: Asset[] = [
},
];
// Pre-defined seed blog posts
export const SEED_BLOG_POSTS: BlogPost[] = [
{
id: "blog-1",
title: "Unlocking Ultra-Low Latency: Synthesis of RISC-V in Edge Devices",
content:
"As edge intelligence grows, local compute units require custom processor topologies. In this article, we details the exact synthesis settings and pipelining optimizations that enabled our quad-core RISC-V IP block to achieve 35% better performance per watt compared to baseline architectures. We review cache organization, instruction fetch queue sizing, and how we tackled branch prediction overheads within tightly constrained FPGA silicon boundaries.",
author: "Dr. Marcus Vance",
publishDate: "2026-06-18",
thumbnailUrl:
"https://images.unsplash.com/photo-1601524909162-be87252be298?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3",
readTime: "6 min read",
tags: ["RISC-V", "Hardware-Design", "Edge-AI"],
status: "published",
},
{
id: "blog-2",
title: "Introduction to CodeNuk: Scalable Microservice Architecture",
content:
"Building distributed systems often involves navigating high configuration overhead. CodeNuk solves this by providing a unified, type-safe scaffolding that integrates telemetry, connection pools, and circuit-breakers out of the box. This deep-dive explains how CodeNuk leverages TypeScript decorators to declare service endpoints and automatically generate OpenAPI contracts and React Client hooks during the build phase, saving engineering weeks.",
author: "Yasha Khandelwal",
publishDate: "2026-06-25",
thumbnailUrl:
"https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3",
readTime: "8 min read",
tags: ["CodeNuk", "TypeScript", "Microservices"],
status: "published",
},
];
// Pre-defined seed announcements
export const SEED_ANNOUNCEMENTS: Announcement[] = [
@ -283,9 +255,7 @@ export const initializeStorage = () => {
localStorage.setItem("t4b_categories", JSON.stringify(SEED_CATEGORIES));
}
if (!localStorage.getItem("t4b_blog_posts")) {
localStorage.setItem("t4b_blog_posts", JSON.stringify(SEED_BLOG_POSTS));
}
if (!localStorage.getItem("t4b_announcements")) {
localStorage.setItem(

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import type { Variants } from "framer-motion";
import { UploadCloud, Search, File, CheckCircle, Share2, Folder } from "lucide-react";
import { UploadCloud, Search, File, CheckCircle, Share2, Folder, Sparkles } from "lucide-react";
import { useAuthStore } from "../hooks/use-auth";
import { axiosInstance } from "../services/axios";
import {
@ -62,6 +62,25 @@ export const AssetsPage = () => {
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>("ALL");
// Find recommended assets based on user's partnerGroup matching any AssetGroup.name (supports comma-separated multiple groups)
const partnerGroupStrings = user?.partnerGroup && user.role === "PARTNER_USER"
? user.partnerGroup.split(',').map(s => s.trim().toLowerCase())
: [];
// Only recommend assets that the partner actually has permission to access, and deduplicate
const recommendedAssets = (() => {
if (partnerGroupStrings.length === 0) return [];
const activeGroups = groups.filter(g => partnerGroupStrings.includes(g.name.trim().toLowerCase()));
return activeGroups
.flatMap(g => g.assets)
.filter((recAsset, index, self) =>
self.findIndex(a => a.id === recAsset.id) === index &&
assets.some(allAsset => allAsset.id === recAsset.id)
);
})();
// Modal Control States
const [isUploadOpen, setIsUploadOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
@ -82,10 +101,7 @@ export const AssetsPage = () => {
}, []);
useEffect(() => {
const availableCategories = new Set(
assets.map((asset) => asset.categoryId || "General")
);
if (selectedCategory !== "ALL" && !availableCategories.has(selectedCategory)) {
if (selectedCategory !== "ALL" && selectedCategory !== "RECOMMENDED") {
setSelectedCategory("ALL");
}
}, [assets, selectedCategory]);
@ -97,12 +113,13 @@ export const AssetsPage = () => {
setAssets(assetsData);
setSelectedAssetIds([]);
// Load groups for both ADMIN (for managing) and PARTNER (for recommendations)
const groupsData = await getAssetGroups();
setGroups(groupsData);
if (user?.role === "ADMIN") {
const orgsData = await getOrganizations();
setOrganizations(orgsData);
const groupsData = await getAssetGroups();
setGroups(groupsData);
}
} catch (err) {
console.error("Failed to fetch assets data", err);
@ -223,17 +240,6 @@ export const AssetsPage = () => {
}
};
const CATEGORIES = [
"ALL",
...Array.from(
new Set(
assets
.map((asset) => asset.categoryId || "General")
.filter((catId): catId is string => !!catId)
)
).sort()
];
const filteredAssets = assets.filter((asset) => {
const query = searchQuery.toLowerCase().trim();
@ -249,8 +255,8 @@ export const AssetsPage = () => {
const matchesCategory =
selectedCategory === "ALL" ||
asset.categoryId === selectedCategory ||
(!asset.categoryId && selectedCategory === "General");
(selectedCategory === "RECOMMENDED" &&
recommendedAssets.some((r) => r.id === asset.id));
return matchesSearch && matchesCategory;
});
@ -268,20 +274,21 @@ export const AssetsPage = () => {
<PageHeader
title="Asset Library"
subtitle="Securely manage, distribute, and track marketing collateral and partner resources."
badge={
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-ink-100 border border-ink-200 text-[10px] font-bold text-ink-900 tracking-wider uppercase shrink-0">
<CheckCircle className="w-3.5 h-3.5 text-ink-950" />
<span>Global CDN Active</span>
</div>
}
// badge={
// <div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-ink-100 border border-ink-200 text-[10px] font-bold text-ink-900 tracking-wider uppercase shrink-0">
// <CheckCircle className="w-3.5 h-3.5 text-ink-950" />
// <span>Global CDN Active</span>
// </div>
// }
/>
);
// Toolbar component
const toolbarNode = (
<div className="flex flex-col md:flex-row gap-4 items-center justify-between p-3.5 bg-ink-0 border border-ink-200 rounded-xl shadow-sm">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 flex-1 w-full max-w-2xl">
<div className="relative flex-1 group">
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-3 flex-1 min-w-0 w-full">
{/* Search Bar with stable minimum width */}
<div className="relative w-full md:w-[280px] shrink-0 group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="w-4 h-4 text-ink-400 group-focus-within:text-ink-900 transition-colors" />
</div>
@ -294,21 +301,30 @@ export const AssetsPage = () => {
/>
</div>
<div className="flex gap-1.5 overflow-x-auto pb-1 sm:pb-0 scrollbar-none">
{CATEGORIES.map((cat) => (
{/* Toggle Controls: All Assets vs Recommended */}
{user?.role === "PARTNER_USER" && recommendedAssets.length > 0 && (
<div className="flex items-center gap-1 bg-ink-50/50 p-1 rounded-xl border border-ink-200 shadow-sm shrink-0">
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={`px-2.5 py-1.5 rounded-lg border text-[10px] uppercase tracking-wider font-bold transition-all whitespace-nowrap shadow-sm cursor-pointer ${
selectedCategory === cat
? "bg-ink-900 text-ink-0 border-ink-900"
: "bg-ink-50 text-ink-700 border-ink-200 hover:bg-ink-100"
onClick={() => setSelectedCategory("ALL")}
className={`px-3.5 py-1.5 rounded-lg text-[10px] uppercase tracking-wider font-extrabold transition-all cursor-pointer whitespace-nowrap ${selectedCategory === "ALL"
? "bg-ink-900 text-ink-0 shadow-sm"
: "text-ink-600 hover:text-ink-900 hover:bg-ink-100/50"
}`}
>
{cat}
All Assets
</button>
<button
onClick={() => setSelectedCategory("RECOMMENDED")}
className={`px-3.5 py-1.5 rounded-lg text-[10px] uppercase tracking-wider font-extrabold transition-all cursor-pointer whitespace-nowrap flex items-center gap-1.5 ${selectedCategory === "RECOMMENDED"
? "bg-gradient-to-r from-amber-500 to-amber-600 text-zinc-950 shadow-md shadow-amber-500/25"
: "text-amber-700 dark:text-amber-400 hover:bg-amber-500/10"
}`}
>
<Sparkles className="w-3.5 h-3.5 animate-pulse" />
<span>Recommended for You</span>
</button>
))}
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0 w-full md:w-auto justify-end">
@ -373,7 +389,35 @@ export const AssetsPage = () => {
<div className="py-20 flex justify-center items-center">
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin" />
</div>
) : filteredAssets.length === 0 ? (
) : (
<>
{selectedCategory === "RECOMMENDED" && (
<div className="mb-8 p-8 bg-gradient-to-br from-amber-500/[0.07] via-slate-900/40 to-slate-950/20 border border-amber-500/20 rounded-2xl relative overflow-hidden shadow-lg backdrop-blur-md">
{/* Background decorative elements */}
<div className="absolute top-0 right-0 w-80 h-80 bg-amber-500/[0.04] rounded-full blur-3xl -mr-16 -mt-16 pointer-events-none" />
<div className="absolute -left-10 -bottom-10 w-60 h-60 bg-amber-600/[0.02] rounded-full blur-3xl pointer-events-none" />
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 relative z-10">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-500/10 border border-amber-500/20 text-xs font-bold text-amber-700 dark:text-amber-400 uppercase tracking-wider">
<Sparkles className="w-3.5 h-3.5 animate-pulse" />
<span>Curated Catalog</span>
</span>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md bg-ink-900/60 text-ink-200 text-[10px] font-bold uppercase tracking-wider border border-ink-700/30">
{recommendedAssets.length} {recommendedAssets.length === 1 ? 'Asset' : 'Assets'}
</span>
</div>
<h2 className="text-3xl font-extrabold text-ink-900 font-sans tracking-tight">Recommended for You</h2>
<p className="text-ink-600 text-sm max-w-2xl leading-relaxed">
These resources have been hand-picked by our team to match your partner profile and accelerate your integration.
</p>
</div>
</div>
</div>
)}
{filteredAssets.length === 0 ? (
<div className="py-12 text-center bg-ink-0 border border-ink-200 rounded-xl">
<File className="w-12 h-12 text-ink-300 mx-auto mb-4" />
<h3 className="text-lg font-bold text-ink-900">No assets found</h3>
@ -406,11 +450,14 @@ export const AssetsPage = () => {
onToggleSelect={handleToggleSelectAsset}
isExpanded={expandedAssetId === asset.id}
onToggleExpand={() => setExpandedAssetId(expandedAssetId === asset.id ? null : asset.id)}
isRecommended={recommendedAssets.some(r => r.id === asset.id)}
/>
</motion.div>
))}
</motion.div>
)}
</>
)}
</div>
{/* Modals Container */}

View File

@ -1,7 +1,7 @@
import { useAuthStore } from '../hooks/use-auth';
import { motion } from 'framer-motion';
import type { Variants } from 'framer-motion';
import { FolderKanban, FileSignature, Users, ArrowUpRight, Activity, Zap, ShieldCheck } from 'lucide-react';
import { FolderKanban, FileSignature, Users, ArrowUpRight, Activity, Zap, ShieldCheck, Globe } from 'lucide-react';
import { Link } from 'react-router-dom';
import { PageHeader } from '../components/ui/PageHeader';
import { PageLayout } from '../components/layout/PageLayout';
@ -55,14 +55,26 @@ export const DashboardPage = () => {
icon: FileSignature,
path: '/client/agreements',
metrics: 'My Agreements'
},
{
title: 'Explore More',
description: 'Explore CodeNuk, Cloudtopiaa, and other leading products and services in our ecosystem.',
icon: Globe,
path: '/client/ecosystem',
metrics: 'Explore Offerings'
}
];
const isAdmin = user?.role === 'ADMIN';
// Header component
const headerNode = (
<PageHeader
title={`Welcome back, ${user?.email?.split('@')[0]}`}
subtitle={`You are authenticated as ${user?.role}. Manage your channel network, monitor compliance, and distribute assets globally.`}
subtitle={isAdmin
? 'Manage your channel network, monitor compliance, and distribute assets globally.'
: `Access your shared resources, legal agreements, and explore our ecosystem of products and services.`
}
badge={
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-ink-900 border border-ink-800 text-[10px] font-bold text-ink-0 tracking-wider uppercase shrink-0">
<div className="w-1 h-1 rounded-full bg-ink-0 animate-pulse" />

View File

@ -0,0 +1,212 @@
import React, { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Code,
Briefcase,
Shield,
Cloud,
Globe,
ExternalLink,
Layers
} from 'lucide-react';
import { PageHeader } from '../components/ui/PageHeader';
import { PageLayout } from '../components/layout/PageLayout';
import { getEcosystemOfferings } from '../services/ecosystem-api';
import type { EcosystemOffering } from '../services/ecosystem-api';
import { BrandLogo } from './admin/EcosystemManagerPage';
const iconMap: Record<string, any> = {
Code,
Briefcase,
Shield,
Cloud,
Globe
};
export const EcosystemPage: React.FC = () => {
const [offerings, setOfferings] = useState<EcosystemOffering[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<'ALL' | 'PRODUCT' | 'SERVICE'>('ALL');
useEffect(() => {
const fetchOfferings = async () => {
try {
const data = await getEcosystemOfferings();
setOfferings(data);
} catch (err) {
console.error('Failed to load ecosystem offerings:', err);
} finally {
setLoading(false);
}
};
fetchOfferings();
}, []);
const filteredOfferings = offerings.filter(o => {
if (filter === 'ALL') return true;
return o.type === filter;
});
const headerNode = (
<PageHeader
title="Ecosystem Explorer"
subtitle="Unlock access to Tech4Biz's complete network of industry-leading products, platforms, and specialized services."
/>
);
return (
<PageLayout header={headerNode}>
<div className="p-6 space-y-6">
{/* Segmented controls filter */}
<div className="flex justify-between items-center border-b border-ink-200 pb-4">
<div className="flex bg-ink-100 p-0.5 rounded-xl border border-ink-200">
{(['ALL', 'PRODUCT', 'SERVICE'] as const).map((type) => (
<button
key={type}
onClick={() => setFilter(type)}
className={`px-4 py-2 rounded-lg text-xs font-black tracking-wider uppercase transition-all duration-300 cursor-pointer ${filter === type
? 'bg-ink-0 text-ink-950 shadow-sm border border-ink-200/50'
: 'text-ink-500 hover:text-ink-900'
}`}
>
{type}s
</button>
))}
</div>
<div className="text-[10px] font-bold text-ink-400 uppercase tracking-widest hidden sm:block">
{filteredOfferings.length} Solutions Available
</div>
</div>
{loading ? (
<div className="flex flex-1 items-center justify-center py-20">
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin"></div>
</div>
) : (
<motion.div
layout
className="grid grid-cols-1 md:grid-cols-2 gap-8"
>
<AnimatePresence mode="popLayout">
{filteredOfferings.map((offering, idx) => {
const IconComponent = iconMap[offering.logoIcon] || Globe;
const isProduct = offering.type === 'PRODUCT';
return (
<motion.div
key={offering.id}
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ type: 'spring', stiffness: 300, damping: 25, delay: idx * 0.05 }}
className="group flex flex-col justify-between bg-ink-0 border border-ink-200 hover:border-ink-450 rounded-3xl p-6 md:p-8 hover:shadow-xl transition-all duration-500"
>
<div>
{/* Logo and Badges */}
<div className="flex justify-between items-center mb-6">
<div className="h-10 flex items-center shrink-0">
{offering.logoUrl ? (
<BrandLogo name={offering.logoUrl} className="max-h-7 max-w-[150px] object-contain text-ink-955 dark:text-ink-0" />
) : (
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-ink-0 bg-ink-900 shadow-md">
<IconComponent className="w-5 h-5" />
</div>
)}
</div>
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[9px] font-black tracking-widest uppercase border ${isProduct
? 'bg-blue-500/10 text-blue-600 border-blue-500/20'
: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20'
}`}>
<Layers className="w-3 h-3" />
{offering.type}
</span>
</div>
{/* Info Copy */}
<div className="space-y-3">
<h3 className="text-lg font-black tracking-tight text-ink-950">
{offering.name}
</h3>
<p className="text-xs font-bold text-ink-700 leading-snug">
{offering.tagline}
</p>
<p className="text-xs font-medium leading-relaxed text-ink-500 pt-1">
{offering.description}
</p>
</div>
{/* Key Benefits */}
{offering.benefits && offering.benefits.length > 0 && (
<div className="pt-6 space-y-2">
<span className="text-[10px] font-extrabold uppercase tracking-widest text-ink-400">Key Benefits</span>
<ul className="grid grid-cols-1 gap-2.5 pt-1">
{offering.benefits.map((benefit, bIdx) => (
<li key={bIdx} className="flex items-start gap-2.5 text-xs font-medium text-ink-600">
<span className={`w-1.5 h-1.5 rounded-full shrink-0 mt-1.5 ${isProduct ? 'bg-blue-500' : 'bg-emerald-500'
}`} />
<span>{benefit}</span>
</li>
))}
</ul>
</div>
)}
{/* Optional Media (GIF, Video, Image) */}
{offering.mediaUrl && offering.mediaType && offering.mediaType !== 'NONE' && (
<div className="mt-6 border border-ink-200 rounded-2xl overflow-hidden bg-ink-50 relative aspect-video shadow-sm">
{offering.mediaType === 'VIDEO' ? (
offering.mediaUrl.includes('youtube.com') || offering.mediaUrl.includes('youtube-nocookie.com') || offering.mediaUrl.includes('vimeo.com') ? (
<iframe
src={offering.mediaUrl}
title={`${offering.name} Video Preview`}
className="w-full h-full border-0 absolute inset-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
) : (
<video
src={offering.mediaUrl}
controls
muted
loop
playsInline
className="w-full h-full object-cover"
/>
)
) : (
<img
src={offering.mediaUrl}
alt={`${offering.name} Screenshot`}
className="w-full h-full object-cover group-hover:scale-102 transition-transform duration-700"
/>
)}
</div>
)}
</div>
{/* CTA Button */}
<div className="pt-8 mt-auto">
<a
href={offering.websiteUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 w-full px-5 py-3 rounded-xl text-xs font-black tracking-wider uppercase bg-ink-900 text-ink-0 hover:bg-ink-800 hover:shadow-lg transition-all duration-300 cursor-pointer"
>
<span>{offering.ctaText || 'Explore Solution'}</span>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
</motion.div>
);
})}
</AnimatePresence>
</motion.div>
)}
</div>
</PageLayout>
);
};
export default EcosystemPage;

View File

@ -139,12 +139,12 @@ export const LoginPage = () => {
<label className="block text-[10px] font-bold text-ink-500 uppercase tracking-wider">
Password
</label>
<a
{/* <a
href="#"
className="text-[10px] font-bold text-ink-500 hover:text-ink-900 transition-colors tracking-wider"
>
RECOVERY?
</a>
</a> */}
</div>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">

View File

@ -15,6 +15,7 @@ import {
Copy,
Trash2,
Plus,
Lock,
} from "lucide-react";
import { motion } from "framer-motion";
import { Link } from "react-router-dom";
@ -135,6 +136,17 @@ export const DirectoryPage: React.FC = () => {
const [mfaRequired, setMfaRequired] = useState(true);
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
const [inviteAssetSearch, setInviteAssetSearch] = useState("");
const [shareAllAssets, setShareAllAssets] = useState(true);
const [showEcosystemTab, setShowEcosystemTab] = useState(true);
// Helper for sync checkbox
const handleSetSelectedAssetIds = (value: React.SetStateAction<string[]>) => {
setSelectedAssetIds(prev => {
const next = typeof value === 'function' ? (value as Function)(prev) : value;
setShareAllAssets(next.length === allAssets.length && allAssets.length > 0);
return next;
});
};
// Editing partner states
const [editingPartner, setEditingPartner] = useState<any>(null);
@ -142,9 +154,20 @@ export const DirectoryPage: React.FC = () => {
const [editNdaId, setEditNdaId] = useState("");
const [editMsaId, setEditMsaId] = useState("");
const [editMfaEnabled, setEditMfaEnabled] = useState(true);
const [editShowEcosystemTab, setEditShowEcosystemTab] = useState(true);
const [editAssetIds, setEditAssetIds] = useState<string[]>([]);
const [editAssetSearch, setEditAssetSearch] = useState("");
const [isEditOpen, setIsEditOpen] = useState(false);
const [editShareAll, setEditShareAll] = useState(false);
// Helper for edit sync checkbox
const handleSetEditAssetIds = (value: React.SetStateAction<string[]>) => {
setEditAssetIds(prev => {
const next = typeof value === 'function' ? (value as Function)(prev) : value;
setEditShareAll(next.length === allAssets.length && allAssets.length > 0);
return next;
});
};
const [assetGroups, setAssetGroups] = useState<AssetGroup[]>([]);
const [selectedPartnerForAssets, setSelectedPartnerForAssets] = useState<any | null>(null);
const [isPartnerAssetsOpen, setIsPartnerAssetsOpen] = useState(false);
@ -154,16 +177,52 @@ export const DirectoryPage: React.FC = () => {
const [partnerAssetToRemove, setPartnerAssetToRemove] = useState<{ id: string, title: string } | null>(null);
useEffect(() => {
getAssets().then(setAllAssets).catch(console.error);
getAssets().then((assets) => {
setAllAssets(assets);
setSelectedAssetIds(assets.map(a => a.id));
}).catch(console.error);
getLegalDocuments().then(setAllDocs).catch(console.error);
getAssetGroups().then(setAssetGroups).catch(console.error);
}, []);
const handleToggleInviteGroup = (groupName: string, select: boolean) => {
setPartnerGroup(prev => {
const groups = prev ? prev.split(',').map(s => s.trim()) : [];
if (select) {
if (!groups.some(g => g.toLowerCase() === groupName.toLowerCase())) {
groups.push(groupName);
}
} else {
const filtered = groups.filter(g => g.toLowerCase() !== groupName.toLowerCase());
return filtered.join(', ');
}
return groups.join(', ');
});
};
const handleToggleEditGroup = (groupName: string, select: boolean) => {
setEditGroup(prev => {
const groups = prev ? prev.split(',').map(s => s.trim()) : [];
if (select) {
if (!groups.some(g => g.toLowerCase() === groupName.toLowerCase())) {
groups.push(groupName);
}
} else {
const filtered = groups.filter(g => g.toLowerCase() !== groupName.toLowerCase());
return filtered.join(', ');
}
return groups.join(', ');
});
};
const renderAssetSelector = (
selectedIds: string[],
setSelectedIds: React.Dispatch<React.SetStateAction<string[]>>,
searchQuery: string,
setSearchQuery: React.Dispatch<React.SetStateAction<string>>
setSearchQuery: React.Dispatch<React.SetStateAction<string>>,
onToggleGroup?: (groupName: string, select: boolean) => void,
activeGroupsString: string = ""
) => {
const filtered = allAssets.filter(asset =>
asset.title.toLowerCase().includes(searchQuery.toLowerCase())
@ -177,8 +236,6 @@ export const DirectoryPage: React.FC = () => {
}
};
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
@ -204,29 +261,23 @@ export const DirectoryPage: React.FC = () => {
{assetGroups.length > 0 && (
<div className="flex flex-wrap gap-1.5 items-center bg-ink-50/50 p-2 rounded-lg border border-ink-200">
<span className="text-[9px] font-bold uppercase tracking-wider text-ink-500 shrink-0">
Apply Group:
Apply Recommendation Group:
</span>
{assetGroups.map((g) => {
const hasAll = g.assets.length > 0 && g.assets.every(a => selectedIds.includes(a.id));
const activeGroupNames = activeGroupsString
? activeGroupsString.split(',').map(s => s.trim().toLowerCase())
: [];
const isGroupActive = activeGroupNames.includes(g.name.trim().toLowerCase());
return (
<button
key={g.id}
type="button"
onClick={() => {
const groupAssetIds = g.assets.map(a => a.id);
if (hasAll) {
setSelectedIds(prev => prev.filter(id => !groupAssetIds.includes(id)));
} else {
setSelectedIds(prev => {
const newIds = [...prev];
groupAssetIds.forEach(id => {
if (!newIds.includes(id)) newIds.push(id);
});
return newIds;
});
if (onToggleGroup) {
onToggleGroup(g.name, !isGroupActive);
}
}}
className={`px-2 py-0.5 rounded text-[9px] font-bold transition-all border cursor-pointer ${hasAll
className={`px-2 py-0.5 rounded text-[9px] font-bold transition-all border cursor-pointer ${isGroupActive
? "bg-ink-900 text-ink-0 border-ink-900 shadow-sm"
: "bg-ink-0 text-ink-700 border-ink-200 hover:bg-ink-100"
}`}
@ -321,6 +372,7 @@ export const DirectoryPage: React.FC = () => {
assignedNdaId: selectedPartnerForAssets.assignedNdaId === null ? "NONE" : selectedPartnerForAssets.assignedNdaId || undefined,
assignedMsaId: selectedPartnerForAssets.assignedMsaId === null ? "NONE" : selectedPartnerForAssets.assignedMsaId || undefined,
mfaEnabled: selectedPartnerForAssets.mfaEnabled,
showEcosystemTab: selectedPartnerForAssets.showEcosystemTab,
}
});
success("Asset removed", `"${assetTitle}" is no longer shared with this partner.`);
@ -346,6 +398,7 @@ export const DirectoryPage: React.FC = () => {
assignedNdaId: selectedPartnerForAssets.assignedNdaId === null ? "NONE" : selectedPartnerForAssets.assignedNdaId || undefined,
assignedMsaId: selectedPartnerForAssets.assignedMsaId === null ? "NONE" : selectedPartnerForAssets.assignedMsaId || undefined,
mfaEnabled: selectedPartnerForAssets.mfaEnabled,
showEcosystemTab: selectedPartnerForAssets.showEcosystemTab,
}
});
success("Assets added", `Successfully shared ${selectedNewAssetIds.length} new assets with this partner.`);
@ -390,6 +443,7 @@ export const DirectoryPage: React.FC = () => {
assignedMsaId: assignedMsaId || undefined,
sharedAssetIds: selectedAssetIds,
mfaEnabled: mfaRequired,
showEcosystemTab,
}, {
onSuccess: (data) => {
setInviteResult({ token: data.token });
@ -403,6 +457,7 @@ export const DirectoryPage: React.FC = () => {
setAssignedNdaId("");
setAssignedMsaId("");
setMfaRequired(true);
setShowEcosystemTab(true);
setSelectedAssetIds([]);
fetchPartners();
},
@ -428,6 +483,7 @@ export const DirectoryPage: React.FC = () => {
assignedMsaId: editMsaId || null,
sharedAssetIds: editAssetIds,
mfaEnabled: editMfaEnabled,
showEcosystemTab: editShowEcosystemTab,
}
}, {
onSuccess: () => {
@ -560,6 +616,13 @@ export const DirectoryPage: React.FC = () => {
onClick={() => {
setInviteResult(null);
setEmail("");
setPartnerGroup("");
setAssignedNdaId("");
setAssignedMsaId("");
setMfaRequired(true);
setSelectedAssetIds(allAssets.map(a => a.id));
setShareAllAssets(true);
setInviteAssetSearch("");
setIsInviteOpen(true);
}}
variant="primary"
@ -740,6 +803,7 @@ export const DirectoryPage: React.FC = () => {
) : (
paginatedPartners.map((partner) => {
const sc = getStatusConfig(partner.onboardingStatus);
const pGroup = partner.partnerGroup;
return (
<tr
key={partner.id}
@ -772,9 +836,25 @@ export const DirectoryPage: React.FC = () => {
setSelectedPartnerForAssets(partner);
setIsPartnerAssetsOpen(true);
}}
className="text-xs font-bold text-ink-700 hover:text-ink-950 hover:underline cursor-pointer focus:outline-none"
className="text-xs font-bold text-ink-700 hover:text-ink-950 hover:underline cursor-pointer focus:outline-none flex flex-col items-start gap-0.5"
>
{partner.sharedAssets?.length || 0} assets
<span>
{(() => {
const directCount = partner.sharedAssets?.length || 0;
if (!pGroup) return directCount;
const pGroupNames = pGroup.split(',').map(s => s.trim().toLowerCase());
const uniqueGroupAssetIds = new Set<string>();
assetGroups
.filter(g => pGroupNames.includes(g.name.trim().toLowerCase()))
.forEach(g => g.assets.forEach(a => uniqueGroupAssetIds.add(a.id)));
return directCount + uniqueGroupAssetIds.size;
})()} assets
</span>
{pGroup && assetGroups.some(g => pGroup.split(',').map(s => s.trim().toLowerCase()).includes(g.name.trim().toLowerCase())) && (
<span className="text-[9px] text-amber-600 font-extrabold uppercase tracking-wide">
Includes {pGroup}
</span>
)}
</button>
</td>
<td className="px-5 py-4">
@ -835,7 +915,10 @@ export const DirectoryPage: React.FC = () => {
setEditNdaId(partner.assignedNdaId === null ? "NONE" : partner.assignedNdaId || "");
setEditMsaId(partner.assignedMsaId === null ? "NONE" : partner.assignedMsaId || "");
setEditMfaEnabled(partner.mfaEnabled);
setEditAssetIds(partner.sharedAssets?.map((sa: any) => sa.assetId) || []);
setEditShowEcosystemTab(partner.showEcosystemTab !== false);
const sharedIds = partner.sharedAssets?.map((sa: any) => sa.assetId) || [];
setEditAssetIds(sharedIds);
setEditShareAll(sharedIds.length === allAssets.length && allAssets.length > 0);
setIsEditOpen(true);
}}
className="p-1.5 text-ink-600 hover:text-ink-950 hover:bg-ink-100 rounded-lg transition-colors cursor-pointer"
@ -867,6 +950,127 @@ export const DirectoryPage: React.FC = () => {
size="lg"
>
<div className="space-y-4 font-sans">
{/* ── Group Assignment Section ── */}
{!isAddingPartnerAssets && (
<div className="bg-ink-50/60 border border-ink-200 rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-[10px] font-extrabold uppercase tracking-widest text-ink-500 flex items-center gap-1.5">
<Folder className="w-3.5 h-3.5 text-ink-600" />
<span>Recommendation Groups</span>
</h4>
</div>
{/* Current Groups as Tags */}
<div className="flex flex-wrap gap-2 items-center min-h-[28px]">
{(() => {
const currentGroups = selectedPartnerForAssets?.partnerGroup
? selectedPartnerForAssets.partnerGroup.split(',').map((s: string) => s.trim()).filter(Boolean)
: [];
if (currentGroups.length === 0) {
return (
<span className="text-[10px] text-ink-400 font-medium italic">No recommendation groups assigned</span>
);
}
return currentGroups.map((groupName: string) => (
<span
key={groupName}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-ink-900 text-ink-0 text-[10px] font-bold tracking-wide"
>
<span>{groupName}</span>
<button
type="button"
onClick={async () => {
if (!selectedPartnerForAssets) return;
const updatedGroups = currentGroups
.filter((g: string) => g.toLowerCase() !== groupName.toLowerCase())
.join(', ');
try {
const currentAssetIds = selectedPartnerForAssets.sharedAssets?.map((sa: any) => sa.assetId) || [];
const updated = await updateMutation.mutateAsync({
partnerId: selectedPartnerForAssets.id,
params: {
partnerGroup: updatedGroups || null as any,
sharedAssetIds: currentAssetIds,
assignedNdaId: selectedPartnerForAssets.assignedNdaId === null ? "NONE" : selectedPartnerForAssets.assignedNdaId || undefined,
assignedMsaId: selectedPartnerForAssets.assignedMsaId === null ? "NONE" : selectedPartnerForAssets.assignedMsaId || undefined,
mfaEnabled: selectedPartnerForAssets.mfaEnabled,
showEcosystemTab: selectedPartnerForAssets.showEcosystemTab,
}
});
setSelectedPartnerForAssets(updated);
fetchPartners();
success("Group removed", `"${groupName}" removed from this partner's recommendations.`);
} catch (err: any) {
error("Failed to update", err.response?.data?.error || "Something went wrong.");
}
}}
className="hover:bg-ink-0/20 rounded-full p-0.5 transition-colors cursor-pointer"
title={`Remove ${groupName}`}
>
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
));
})()}
{/* Add Group Dropdown */}
{(() => {
const currentGroups = selectedPartnerForAssets?.partnerGroup
? selectedPartnerForAssets.partnerGroup.split(',').map((s: string) => s.trim().toLowerCase()).filter(Boolean)
: [];
const availableGroups = assetGroups.filter(
g => !currentGroups.includes(g.name.trim().toLowerCase())
);
if (availableGroups.length === 0) return null;
return (
<select
value=""
onChange={async (e) => {
const groupName = e.target.value;
if (!groupName || !selectedPartnerForAssets) return;
const existingGroups = selectedPartnerForAssets.partnerGroup
? selectedPartnerForAssets.partnerGroup.split(',').map((s: string) => s.trim()).filter(Boolean)
: [];
const updatedGroups = [...existingGroups, groupName].join(', ');
try {
const currentAssetIds = selectedPartnerForAssets.sharedAssets?.map((sa: any) => sa.assetId) || [];
const updated = await updateMutation.mutateAsync({
partnerId: selectedPartnerForAssets.id,
params: {
partnerGroup: updatedGroups,
sharedAssetIds: currentAssetIds,
assignedNdaId: selectedPartnerForAssets.assignedNdaId === null ? "NONE" : selectedPartnerForAssets.assignedNdaId || undefined,
assignedMsaId: selectedPartnerForAssets.assignedMsaId === null ? "NONE" : selectedPartnerForAssets.assignedMsaId || undefined,
mfaEnabled: selectedPartnerForAssets.mfaEnabled,
showEcosystemTab: selectedPartnerForAssets.showEcosystemTab,
}
});
setSelectedPartnerForAssets(updated);
fetchPartners();
success("Group added", `"${groupName}" added to this partner's recommendations.`);
} catch (err: any) {
error("Failed to update", err.response?.data?.error || "Something went wrong.");
}
}}
className="text-[10px] font-bold bg-ink-0 border border-dashed border-ink-300 hover:border-ink-400 rounded-lg px-2 py-1 text-ink-600 cursor-pointer outline-none transition-colors"
>
<option value="">+ Add Group</option>
{availableGroups.map(g => (
<option key={g.id} value={g.name}>{g.name} ({g.assets.length} assets)</option>
))}
</select>
);
})()}
</div>
</div>
)}
{/* Header Action */}
<div className="flex justify-between items-center">
<h4 className="text-xs font-bold uppercase tracking-wider text-ink-500">
@ -923,12 +1127,10 @@ export const DirectoryPage: React.FC = () => {
prev.includes(asset.id) ? prev.filter(x => x !== asset.id) : [...prev, asset.id]
);
}}
className={`flex items-center gap-3 p-3 text-xs font-semibold cursor-pointer transition-all hover:bg-ink-50 ${
isSelected ? 'bg-ink-50/70' : ''
className={`flex items-center gap-3 p-3 text-xs font-semibold cursor-pointer transition-all hover:bg-ink-50 ${isSelected ? 'bg-ink-50/70' : ''
}`}
>
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-all ${
isSelected
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-all ${isSelected
? 'border-ink-900 bg-ink-900 text-ink-0'
: 'border-ink-200 bg-ink-50'
}`}>
@ -971,15 +1173,64 @@ export const DirectoryPage: React.FC = () => {
</div>
) : (
/* Current Shared Assets List */
(!selectedPartnerForAssets?.sharedAssets || selectedPartnerForAssets.sharedAssets.length === 0) ? (
(() => {
const matchedGroups = selectedPartnerForAssets?.partnerGroup
? selectedPartnerForAssets.partnerGroup.split(',').map((s: string) => s.trim().toLowerCase())
: [];
// Map inherited assets and keep track of which group name they belong to
const inheritedAssetsMap = new Map<string, { asset: any, groupName: string }>();
assetGroups
.filter(g => matchedGroups.includes(g.name.trim().toLowerCase()))
.forEach(g => {
g.assets.forEach(asset => {
if (!inheritedAssetsMap.has(asset.id)) {
inheritedAssetsMap.set(asset.id, { asset, groupName: g.name });
}
});
});
const inheritedAssets = Array.from(inheritedAssetsMap.values());
const directAssets = selectedPartnerForAssets?.sharedAssets || [];
const totalCount = directAssets.length + inheritedAssets.length;
if (totalCount === 0) {
return (
<div className="text-center py-10 bg-ink-50/50 border border-dashed border-ink-200 rounded-xl">
<Folder className="w-10 h-10 text-ink-300 mx-auto mb-2" />
<p className="text-xs font-bold text-ink-900 font-sans">No assets shared</p>
<p className="text-[10px] text-ink-450 mt-1 font-sans">There are no assets currently assigned to this partner.</p>
</div>
) : (
);
}
return (
<div className="border border-ink-200 rounded-xl overflow-hidden divide-y divide-ink-200 bg-ink-0 max-h-[350px] overflow-y-auto">
{selectedPartnerForAssets.sharedAssets.map((item: any) => {
{/* Group Inherited Assets */}
{inheritedAssets.map(({ asset, groupName }) => (
<div key={`group-asset-${asset.id}`} className="flex items-center justify-between p-3 hover:bg-ink-100 transition-colors font-sans">
<div className="flex items-center gap-3 min-w-0">
{getFileIcon(asset.type || '', asset.title)}
<div className="min-w-0 font-sans">
<p className="text-xs font-bold text-ink-900 truncate">{asset.title}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-[8px] px-1.5 py-0.2 rounded bg-ink-100 border border-ink-200 text-ink-600 font-bold uppercase tracking-wider">
{asset.categoryId || 'General'}
</span>
<span className="text-[8px] px-1.5 py-0.2 rounded bg-amber-500/10 border border-amber-500/20 text-amber-700 font-extrabold uppercase tracking-wider">
Inherited ({groupName})
</span>
</div>
</div>
</div>
<div className="p-1.5 text-ink-400 cursor-not-allowed shrink-0 ml-4" title={`Inherited via ${groupName} permissions (Read-only)`}>
<Lock className="w-4 h-4 text-ink-400" />
</div>
</div>
))}
{/* Direct Shared Assets */}
{directAssets.map((item: any) => {
const asset = item.asset;
if (!asset) return null;
return (
@ -1011,7 +1262,8 @@ export const DirectoryPage: React.FC = () => {
);
})}
</div>
)
);
})()
)}
</div>
</Modal>
@ -1143,6 +1395,20 @@ export const DirectoryPage: React.FC = () => {
<option value="false">No (Disabled)</option>
</select>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">
Ecosystem Explorer
</label>
<select
value={showEcosystemTab ? "true" : "false"}
onChange={(e) => setShowEcosystemTab(e.target.value === "true")}
className="w-full px-3 py-2 bg-ink-50 border border-ink-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-700 font-semibold"
>
<option value="true">Allowed (Visible)</option>
<option value="false">Blocked (Hidden)</option>
</select>
</div>
</div>
<div>
@ -1156,7 +1422,27 @@ export const DirectoryPage: React.FC = () => {
</span>
)}
</div>
{renderAssetSelector(selectedAssetIds, setSelectedAssetIds, inviteAssetSearch, setInviteAssetSearch)}
<div className="mb-2.5 flex items-center gap-2 p-2.5 bg-ink-50 border border-ink-200 rounded-lg">
<input
type="checkbox"
id="share-all-invite"
checked={shareAllAssets}
onChange={(e) => {
const checked = e.target.checked;
setShareAllAssets(checked);
if (checked) {
setSelectedAssetIds(allAssets.map(a => a.id));
} else {
setSelectedAssetIds([]);
}
}}
className="rounded border-ink-300 text-ink-900 focus:ring-ink-950 w-4 h-4 cursor-pointer"
/>
<label htmlFor="share-all-invite" className="text-xs font-bold text-ink-900 cursor-pointer select-none">
Share all assets in catalog
</label>
</div>
{renderAssetSelector(selectedAssetIds, handleSetSelectedAssetIds, inviteAssetSearch, setInviteAssetSearch, handleToggleInviteGroup, partnerGroup)}
</div>
{inviteResult?.error && (
@ -1307,6 +1593,20 @@ export const DirectoryPage: React.FC = () => {
<option value="false">No (Disabled)</option>
</select>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">
Ecosystem Explorer
</label>
<select
value={editShowEcosystemTab ? "true" : "false"}
onChange={(e) => setEditShowEcosystemTab(e.target.value === "true")}
className="w-full px-3 py-2 bg-ink-50 border border-ink-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-700 font-semibold"
>
<option value="true">Allowed (Visible)</option>
<option value="false">Blocked (Hidden)</option>
</select>
</div>
</div>
<div>
@ -1320,7 +1620,27 @@ export const DirectoryPage: React.FC = () => {
</span>
)}
</div>
{renderAssetSelector(editAssetIds, setEditAssetIds, editAssetSearch, setEditAssetSearch)}
<div className="mb-2.5 flex items-center gap-2 p-2.5 bg-ink-50 border border-ink-200 rounded-lg">
<input
type="checkbox"
id="share-all-edit"
checked={editShareAll}
onChange={(e) => {
const checked = e.target.checked;
setEditShareAll(checked);
if (checked) {
setEditAssetIds(allAssets.map(a => a.id));
} else {
setEditAssetIds([]);
}
}}
className="rounded border-ink-300 text-ink-900 focus:ring-ink-950 w-4 h-4 cursor-pointer"
/>
<label htmlFor="share-all-edit" className="text-xs font-bold text-ink-900 cursor-pointer select-none">
Share all assets in catalog
</label>
</div>
{renderAssetSelector(editAssetIds, handleSetEditAssetIds, editAssetSearch, setEditAssetSearch, handleToggleEditGroup, editGroup)}
</div>
<div className="flex items-center gap-3 pt-2">

View File

@ -0,0 +1,886 @@
import React, { useEffect, useState, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Globe,
Plus,
Edit2,
Trash2,
X,
ExternalLink,
Video,
Image as ImageIcon,
Upload,
Check,
AlertCircle
} from 'lucide-react';
import { PageHeader } from '../../components/ui/PageHeader';
import { PageLayout } from '../../components/layout/PageLayout';
import {
getEcosystemOfferings,
createEcosystemOffering,
updateEcosystemOffering,
deleteEcosystemOffering,
uploadEcosystemFile
} from '../../services/ecosystem-api';
import type { EcosystemOffering } from '../../services/ecosystem-api';
// Custom Brand Logo renderer with remote URL preset load + high-fidelity SVG fallback
export const BrandLogo: React.FC<{ name: string; className?: string }> = ({ name, className = "h-7" }) => {
const [hasError, setHasError] = useState(false);
const normName = name.toLowerCase().trim();
// Custom URLs provided by the user
const presetMap: Record<string, string> = {
tech4biz: 'https://www.tech4bizsolutions.com/images/logos/logo-light.webp',
cloudtopiaa: 'https://cloudtopiaa.com/images/logo.jpg',
auditraxlabs: 'https://auditraxlabs.com/favicon.svg',
audittrax: 'https://auditraxlabs.com/favicon.svg',
auditrax: 'https://auditraxlabs.com/favicon.svg',
codenuk: 'https://codenuk.com/assets/logo-C8NcFwVR.png',
};
const resolvedUrl = presetMap[normName] || (name.startsWith('http') || name.startsWith('/uploads') ? name : null);
// Reset error flag if name changes
useEffect(() => {
setHasError(false);
}, [name]);
if (resolvedUrl && !hasError) {
const isTech4biz = normName.includes('tech4biz');
return (
<div className={`inline-flex items-center justify-center p-1.5 rounded-xl transition-all ${
isTech4biz ? 'bg-ink-950/90 dark:bg-ink-0/10' : 'bg-transparent'
}`}>
<img
src={resolvedUrl}
alt={name}
className={`${className} object-contain max-w-[140px]`}
onError={() => setHasError(true)}
/>
</div>
);
}
// Fallback premium vectors
if (normName.includes('tech4biz')) {
return (
<svg viewBox="0 0 120 30" className={className} fill="currentColor">
<path d="M10 6h12v4H16v14h-4V10H10V6zm20 8h8v3h-8v4h9v3h-13V10h13v3h-9v1zm19 5c0 1.7-.6 3.1-1.7 4.1C46.2 24.2 44.7 24.7 43 24.7c-2 0-3.6-.6-4.7-1.7-1.1-1.1-1.6-2.6-1.6-4.5v-4.5c0-1.9.5-3.4 1.6-4.5 1.1-1.1 2.7-1.7 4.7-1.7 1.8 0 3.2.5 4.3 1.5 1 1 1.5 2.5 1.5 4.3h-4c0-.9-.2-1.5-.6-1.9-.4-.4-.9-.6-1.7-.6-.7 0-1.3.3-1.6.8-.3.5-.5 1.3-.5 2.3v4.3c0 1 .2 1.8.5 2.3.3.5.9.8 1.6.8.8 0 1.3-.2 1.7-.6.4-.4.6-1 .6-1.9v-1h-2.5v-3h6.5v8.5zm13-13h4v8h8V6h4v18h-4v-7h-8v7h-4V6zm21 0h3.5l5 12 5-12h3.5v18h-3.5v-12.5l-5 12.5h-1l-5-12.5V24h-3.5V6zm19 0h4v18h-4V6z" className="text-ink-950 dark:text-ink-0" />
</svg>
);
}
if (normName.includes('cloudtopiaa')) {
return (
<svg viewBox="0 0 140 30" className={className} fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 9c-3.3 0-6 2.7-6 6s2.7 6 6 6h12c2.2 0 4-1.8 4-4s-1.8-4-4-4h-2c-1.1 0-2-.9-2-2s.9-2 2-2h4v-2c0-1.1-.9-2-2-2h-8z" fill="#0EA5E9" />
<text x="36" y="21" fontFamily="sans-serif" fontWeight="900" fontSize="13" fill="currentColor" className="text-ink-950 dark:text-ink-0">Cloudtopiaa</text>
</svg>
);
}
if (normName.includes('codenuk')) {
return (
<svg viewBox="0 0 110 30" className={className} fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 8l-6 7 6 7M18 8l6 7-6 7M14 6l-3 18" stroke="#6366F1" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
<text x="32" y="21" fontFamily="sans-serif" fontWeight="900" fontSize="14" fill="currentColor" className="text-ink-950 dark:text-ink-0">CodeNuk</text>
</svg>
);
}
if (normName.includes('auditrax') || normName.includes('audittrax')) {
return (
<svg viewBox="0 0 130 30" className={className} fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4L4 7v8c0 5.5 3.8 10.7 8 12 4.2-1.3 8-6.5 8-12V7l-8-3z" fill="#10B981" />
<path d="M9 13.5l2 2 4-4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<text x="28" y="20" fontFamily="sans-serif" fontWeight="900" fontSize="11" fill="currentColor" className="text-ink-950 dark:text-ink-0">AUDITRAX LABS</text>
</svg>
);
}
return (
<div className="flex items-center gap-1 text-ink-500 font-bold text-xs uppercase tracking-wider">
<Globe className="w-4 h-4" />
<span>{name}</span>
</div>
);
};
export const EcosystemManagerPage: React.FC = () => {
const [offerings, setOfferings] = useState<EcosystemOffering[]>([]);
const [loading, setLoading] = useState(true);
const [selectedOffering, setSelectedOffering] = useState<EcosystemOffering | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
const [offeringToDelete, setOfferingToDelete] = useState<EcosystemOffering | null>(null);
// Form states
const [name, setName] = useState('');
const [type, setType] = useState<'PRODUCT' | 'SERVICE'>('PRODUCT');
const [tagline, setTagline] = useState('');
const [description, setDescription] = useState('');
const [benefitsText, setBenefitsText] = useState('');
const [websiteUrl, setWebsiteUrl] = useState('');
const [ctaText, setCtaText] = useState('');
const [logoIcon] = useState('Globe');
const [logoUrl, setLogoUrl] = useState('');
const [mediaUrl, setMediaUrl] = useState('');
const [mediaType, setMediaType] = useState<'IMAGE' | 'VIDEO' | 'GIF' | 'NONE'>('NONE');
const [orderIndex, setOrderIndex] = useState(0);
const [isActive, setIsActive] = useState(true);
const [error, setError] = useState('');
const [isSaving, setIsSaving] = useState(false);
// Upload/preset control states
const [logoTab, setLogoTab] = useState<'preset' | 'upload' | 'link'>('preset');
const [mediaTab, setMediaTab] = useState<'upload' | 'link'>('link');
const [uploadingLogo, setUploadingLogo] = useState(false);
const [uploadingMedia, setUploadingMedia] = useState(false);
const logoFileRef = useRef<HTMLInputElement>(null);
const mediaFileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetchOfferings();
}, []);
const fetchOfferings = async () => {
try {
setLoading(true);
const data = await getEcosystemOfferings();
setOfferings(data);
} catch (err) {
console.error('Failed to load offerings:', err);
} finally {
setLoading(false);
}
};
const openAddModal = () => {
setSelectedOffering(null);
setName('');
setType('PRODUCT');
setTagline('');
setDescription('');
setBenefitsText('');
setWebsiteUrl('');
setCtaText('Visit Website');
setLogoUrl('tech4biz');
setMediaUrl('');
setMediaType('NONE');
setOrderIndex(offerings.length);
setIsActive(true);
setError('');
setLogoTab('preset');
setMediaTab('link');
setIsModalOpen(true);
};
const openEditModal = (offering: EcosystemOffering) => {
setSelectedOffering(offering);
setName(offering.name);
setType(offering.type as any);
setTagline(offering.tagline);
setDescription(offering.description);
setBenefitsText(offering.benefits.join('\n'));
setWebsiteUrl(offering.websiteUrl);
setCtaText(offering.ctaText);
const rawLogo = offering.logoUrl || '';
setLogoUrl(rawLogo);
// Determine the active tab based on saved logo value
if (['tech4biz', 'cloudtopiaa', 'codenuk', 'auditraxlabs', 'audittrax'].includes(rawLogo.toLowerCase().trim())) {
setLogoTab('preset');
} else if (rawLogo.startsWith('/uploads/')) {
setLogoTab('upload');
} else if (rawLogo.startsWith('http')) {
setLogoTab('link');
} else {
setLogoTab('preset');
}
setMediaUrl(offering.mediaUrl || '');
const mType = (offering.mediaType || 'NONE') as any;
setMediaType(mType);
setMediaTab(offering.mediaUrl?.startsWith('/uploads/') ? 'upload' : 'link');
setOrderIndex(offering.orderIndex);
setIsActive(offering.isActive);
setError('');
setIsModalOpen(true);
};
const handleDeleteClick = (offering: EcosystemOffering) => {
setOfferingToDelete(offering);
setIsDeleteConfirmOpen(true);
};
const confirmDelete = async () => {
if (!offeringToDelete) return;
try {
await deleteEcosystemOffering(offeringToDelete.id);
setIsDeleteConfirmOpen(false);
setOfferingToDelete(null);
fetchOfferings();
} catch (err: any) {
console.error('Failed to delete offering:', err);
}
};
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingLogo(true);
setError('');
try {
const res = await uploadEcosystemFile(file);
setLogoUrl(res.url);
} catch (err: any) {
setError('Failed to upload logo image.');
} finally {
setUploadingLogo(false);
}
};
const handleMediaUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingMedia(true);
setError('');
try {
const res = await uploadEcosystemFile(file);
setMediaUrl(res.url);
if (file.type.startsWith('video/')) {
setMediaType('VIDEO');
} else if (file.name.endsWith('.gif')) {
setMediaType('GIF');
} else {
setMediaType('IMAGE');
}
} catch (err: any) {
setError('Failed to upload media file.');
} finally {
setUploadingMedia(false);
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!name || !tagline || !description || !websiteUrl) {
setError('Please fill in all required fields.');
return;
}
setIsSaving(true);
setError('');
const benefits = benefitsText
.split('\n')
.map(b => b.trim())
.filter(b => b.length > 0);
const payload = {
name,
type,
tagline,
description,
benefits,
websiteUrl,
ctaText: ctaText || 'Visit Website',
logoIcon,
logoUrl: logoUrl || null,
mediaUrl: mediaUrl || null,
mediaType: mediaType === 'NONE' ? null : mediaType,
orderIndex: Number(orderIndex),
isActive
};
try {
if (selectedOffering) {
await updateEcosystemOffering(selectedOffering.id, payload);
} else {
await createEcosystemOffering(payload);
}
setIsModalOpen(false);
fetchOfferings();
} catch (err: any) {
console.error('Failed to save offering:', err);
setError(err.response?.data?.error || 'Failed to save ecosystem offering.');
} finally {
setIsSaving(false);
}
};
const presetBrands = [
{ key: 'tech4biz', name: 'Tech4Biz Solutions', url: 'https://www.tech4bizsolutions.com/images/logos/logo-light.webp' },
{ key: 'cloudtopiaa', name: 'Cloudtopiaa', url: 'https://cloudtopiaa.com/images/logo.jpg' },
{ key: 'codenuk', name: 'CodeNuk', url: 'https://codenuk.com/assets/logo-C8NcFwVR.png' },
{ key: 'auditraxlabs', name: 'Audittrax Labs', url: 'https://auditraxlabs.com/favicon.svg' },
];
return (
<PageLayout
header={
<PageHeader
title="Ecosystem Offerings Manager"
subtitle="Admin interface to create, modify, and delete products, services, and associated branding media links."
/>
}
>
<div className="p-6 space-y-6 flex flex-col min-h-0 flex-1 overflow-y-auto">
<div className="flex justify-between items-center border-b border-ink-200 pb-4">
<div className="text-sm font-bold text-ink-500 uppercase tracking-widest">
{offerings.length} Ecosystem Offerings Listed
</div>
<button
onClick={openAddModal}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-xs font-black tracking-wider uppercase bg-ink-950 text-ink-0 hover:bg-ink-850 hover:shadow-lg transition-all cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Add New Offering</span>
</button>
</div>
{loading ? (
<div className="flex flex-1 items-center justify-center py-20">
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin"></div>
</div>
) : (
<div className="overflow-x-auto border border-ink-200 rounded-2xl bg-ink-0 shadow-sm">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-ink-200 bg-ink-50 text-[10px] font-black uppercase tracking-wider text-ink-500">
<th className="py-4 px-6 w-40">Logo / Brand</th>
<th className="py-4 px-6">Name</th>
<th className="py-4 px-6 w-28">Type</th>
<th className="py-4 px-6">Tagline</th>
<th className="py-4 px-6 w-36">Redirection URL</th>
<th className="py-4 px-6 w-24">Media</th>
<th className="py-4 px-6 w-24">Status</th>
<th className="py-4 px-6 w-28 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-200">
{offerings.length === 0 ? (
<tr>
<td colSpan={8} className="py-12 text-center text-sm font-semibold text-ink-400">
No offerings found. Click "Add New Offering" to create one.
</td>
</tr>
) : (
offerings.map((offering) => (
<tr key={offering.id} className="hover:bg-ink-50/50 transition-colors">
{/* Logo / Brand */}
<td className="py-4 px-6">
<div className="h-10 flex items-center">
{offering.logoUrl ? (
<BrandLogo name={offering.logoUrl} className="max-h-6 max-w-[120px] object-contain" />
) : (
<div className="w-8 h-8 rounded-lg bg-ink-100 flex items-center justify-center text-ink-855 border border-ink-200 shadow-sm">
<Globe className="w-4 h-4" />
</div>
)}
</div>
</td>
{/* Name */}
<td className="py-4 px-6 font-extrabold text-ink-955 text-sm">
{offering.name}
</td>
{/* Type */}
<td className="py-4 px-6">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-black tracking-widest uppercase border ${
offering.type === 'PRODUCT'
? 'bg-blue-500/10 text-blue-600 border-blue-500/20'
: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20'
}`}>
{offering.type}
</span>
</td>
{/* Tagline */}
<td className="py-4 px-6 text-xs font-semibold text-ink-600 max-w-xs truncate">
{offering.tagline}
</td>
{/* Redirection URL */}
<td className="py-4 px-6 text-xs font-bold text-ink-500">
<a
href={offering.websiteUrl}
target="_blank"
rel="noreferrer"
className="hover:underline flex items-center gap-1 hover:text-ink-900"
>
{new URL(offering.websiteUrl).hostname}
<ExternalLink className="w-3 h-3" />
</a>
</td>
{/* Media */}
<td className="py-4 px-6 text-xs text-ink-600 font-bold">
{offering.mediaType && offering.mediaType !== 'NONE' ? (
<span className="flex items-center gap-1">
{offering.mediaType === 'VIDEO' ? <Video className="w-3.5 h-3.5 text-blue-500" /> : <ImageIcon className="w-3.5 h-3.5 text-purple-500" />}
<span className="capitalize text-[10px]">{offering.mediaType.toLowerCase()}</span>
</span>
) : (
<span className="text-ink-300 font-normal">None</span>
)}
</td>
{/* Status */}
<td className="py-4 px-6">
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider ${
offering.isActive
? 'bg-green-500/10 text-green-600 border border-green-500/20'
: 'bg-ink-150 text-ink-500 border border-ink-300'
}`}>
{offering.isActive ? 'Active' : 'Inactive'}
</span>
</td>
{/* Actions */}
<td className="py-4 px-6 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => openEditModal(offering)}
className="p-1.5 rounded-lg border border-ink-200 text-ink-500 hover:text-ink-900 hover:bg-ink-100 transition-all cursor-pointer"
title="Edit Offering"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDeleteClick(offering)}
className="p-1.5 rounded-lg border border-red-200 text-red-500 hover:text-red-700 hover:bg-red-50 transition-all cursor-pointer"
title="Delete Offering"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
{/* ── Add / Edit Modal ── */}
<AnimatePresence>
{isModalOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsModalOpen(false)}
className="fixed inset-0 bg-ink-950/40 backdrop-blur-md z-45"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="fixed inset-y-6 right-6 w-full max-w-xl bg-ink-0 border border-ink-200 rounded-3xl p-6 md:p-8 shadow-2xl z-50 flex flex-col"
>
<div className="flex justify-between items-start border-b border-ink-200 pb-4 mb-6">
<div>
<h3 className="text-lg font-black tracking-tight text-ink-950">
{selectedOffering ? 'Edit Offering' : 'Add New Offering'}
</h3>
<p className="text-xs font-bold text-ink-500 uppercase tracking-widest mt-1">
{selectedOffering ? selectedOffering.name : 'Ecosystem Catalog'}
</p>
</div>
<button
onClick={() => setIsModalOpen(false)}
className="p-2 rounded-xl bg-ink-100 hover:bg-ink-200 text-ink-700 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSave} className="flex-1 overflow-y-auto space-y-5 pr-1">
{error && (
<div className="p-3.5 rounded-xl border border-red-200 bg-red-500/10 text-xs font-semibold text-red-600 flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{error}</span>
</div>
)}
{/* Name & Type */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-black uppercase tracking-wider text-ink-500 mb-2">Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. CodeNuk"
className="w-full px-4 py-2.5 rounded-xl border border-ink-200 text-sm font-semibold focus:outline-none focus:border-ink-600 bg-ink-0"
/>
</div>
<div>
<label className="block text-[10px] font-black uppercase tracking-wider text-ink-500 mb-2">Type *</label>
<select
value={type}
onChange={(e) => setType(e.target.value as any)}
className="w-full px-4 py-2.5 rounded-xl border border-ink-200 text-sm font-semibold focus:outline-none focus:border-ink-600 bg-ink-0"
>
<option value="PRODUCT">PRODUCT</option>
<option value="SERVICE">SERVICE</option>
</select>
</div>
</div>
{/* Tagline */}
<div>
<label className="block text-[10px] font-black uppercase tracking-wider text-ink-500 mb-2">Tagline *</label>
<input
type="text"
value={tagline}
onChange={(e) => setTagline(e.target.value)}
placeholder="e.g. AI-powered backend generator."
className="w-full px-4 py-2.5 rounded-xl border border-ink-200 text-sm font-semibold focus:outline-none focus:border-ink-600 bg-ink-0"
/>
</div>
{/* Description */}
<div>
<label className="block text-[10px] font-black uppercase tracking-wider text-ink-500 mb-2">Description *</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Detailed description of the company/offering..."
rows={3}
className="w-full px-4 py-2.5 rounded-xl border border-ink-200 text-sm font-semibold focus:outline-none focus:border-ink-600 bg-ink-0 resize-none"
/>
</div>
{/* Key Benefits */}
<div>
<label className="block text-[10px] font-black uppercase tracking-wider text-ink-500 mb-2">Key Benefits (one per line)</label>
<textarea
value={benefitsText}
onChange={(e) => setBenefitsText(e.target.value)}
placeholder="Benefit 1&#10;Benefit 2&#10;Benefit 3..."
rows={2}
className="w-full px-4 py-2.5 rounded-xl border border-ink-200 text-sm font-semibold focus:outline-none focus:border-ink-600 bg-ink-0 resize-none"
/>
</div>
{/* Redirection URL & CTA Text */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-black uppercase tracking-wider text-ink-500 mb-2">Redirection URL *</label>
<input
type="url"
value={websiteUrl}
onChange={(e) => setWebsiteUrl(e.target.value)}
placeholder="https://example.com"
className="w-full px-4 py-2.5 rounded-xl border border-ink-200 text-sm font-semibold focus:outline-none focus:border-ink-600 bg-ink-0"
/>
</div>
<div>
<label className="block text-[10px] font-black uppercase tracking-wider text-ink-500 mb-2">CTA Button Text</label>
<input
type="text"
value={ctaText}
onChange={(e) => setCtaText(e.target.value)}
placeholder="Visit Website"
className="w-full px-4 py-2.5 rounded-xl border border-ink-200 text-sm font-semibold focus:outline-none focus:border-ink-600 bg-ink-0"
/>
</div>
</div>
{/* Logo Brand Picker Tab Interface */}
<div className="border border-ink-200 rounded-2xl p-4 bg-ink-50 space-y-4">
<div className="flex justify-between items-center">
<label className="text-[10px] font-black uppercase tracking-wider text-ink-500">Logo Configuration</label>
<div className="flex bg-ink-200 p-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider">
<button
type="button"
onClick={() => setLogoTab('preset')}
className={`px-2 py-1 rounded ${logoTab === 'preset' ? 'bg-ink-0 text-ink-950 shadow-sm' : 'text-ink-500'}`}
>
Preset Brand
</button>
<button
type="button"
onClick={() => setLogoTab('upload')}
className={`px-2 py-1 rounded ${logoTab === 'upload' ? 'bg-ink-0 text-ink-950 shadow-sm' : 'text-ink-500'}`}
>
Upload File
</button>
<button
type="button"
onClick={() => setLogoTab('link')}
className={`px-2 py-1 rounded ${logoTab === 'link' ? 'bg-ink-0 text-ink-950 shadow-sm' : 'text-ink-500'}`}
>
Custom Link
</button>
</div>
</div>
{logoTab === 'preset' && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{presetBrands.map((brand) => {
const isSelected = logoUrl.toLowerCase() === brand.key.toLowerCase();
return (
<button
key={brand.key}
type="button"
onClick={() => setLogoUrl(brand.key)}
className={`flex flex-col items-center justify-center p-3 rounded-xl border transition-all relative min-h-[70px] ${
isSelected
? 'bg-ink-0 border-ink-950 shadow-md ring-2 ring-ink-950/10'
: 'bg-ink-0/60 border-ink-200 hover:border-ink-400 hover:bg-ink-0'
}`}
>
{isSelected && (
<span className="absolute top-1.5 right-1.5 bg-ink-950 text-ink-0 rounded-full p-0.5">
<Check className="w-2.5 h-2.5" />
</span>
)}
<BrandLogo name={brand.key} className="h-5" />
<span className="text-[9px] font-extrabold text-ink-500 uppercase tracking-wider mt-2.5">
{brand.name}
</span>
</button>
);
})}
</div>
)}
{logoTab === 'upload' && (
<div className="space-y-3">
<input
type="file"
ref={logoFileRef}
onChange={handleLogoUpload}
accept="image/*"
className="hidden"
/>
<div
onClick={() => logoFileRef.current?.click()}
className="border-2 border-dashed border-ink-300 hover:border-ink-500 rounded-xl p-5 text-center cursor-pointer bg-ink-0 transition-all flex flex-col items-center justify-center gap-2"
>
<Upload className="w-6 h-6 text-ink-400" />
<div className="text-xs font-bold text-ink-700">
{uploadingLogo ? 'Uploading to Storage...' : 'Click to select logo image'}
</div>
<p className="text-[10px] text-ink-400">Supports PNG, JPG, SVG, WebP</p>
</div>
{logoUrl && logoUrl.startsWith('/uploads/') && (
<div className="flex items-center gap-3 p-2 border border-green-200 bg-green-500/5 rounded-xl">
<BrandLogo name={logoUrl} className="h-6" />
<span className="text-[10px] font-bold text-green-700 truncate">{logoUrl}</span>
</div>
)}
</div>
)}
{logoTab === 'link' && (
<div>
<input
type="url"
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
placeholder="https://example.com/logo.png"
className="w-full px-4 py-2.5 rounded-xl border border-ink-200 text-sm font-semibold focus:outline-none focus:border-ink-600 bg-ink-0"
/>
{logoUrl && logoUrl.startsWith('http') && (
<div className="mt-3 flex items-center gap-3 p-2 border border-ink-200 bg-ink-0 rounded-xl">
<BrandLogo name={logoUrl} className="h-6" />
<span className="text-[10px] font-bold text-ink-500 truncate">{logoUrl}</span>
</div>
)}
</div>
)}
</div>
{/* Media Content Settings */}
<div className="border border-ink-200 rounded-2xl p-4 bg-ink-50 space-y-4">
<div className="flex justify-between items-center">
<label className="text-[10px] font-black uppercase tracking-wider text-ink-500">Media Preview / Embeds</label>
<div className="flex bg-ink-200 p-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider">
<button
type="button"
onClick={() => setMediaTab('link')}
className={`px-2 py-1 rounded ${mediaTab === 'link' ? 'bg-ink-0 text-ink-950 shadow-sm' : 'text-ink-500'}`}
>
Media Link
</button>
<button
type="button"
onClick={() => setMediaTab('upload')}
className={`px-2 py-1 rounded ${mediaTab === 'upload' ? 'bg-ink-0 text-ink-950 shadow-sm' : 'text-ink-500'}`}
>
Upload File
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[9px] font-black uppercase tracking-wider text-ink-400 mb-1.5">Media Type</label>
<select
value={mediaType}
onChange={(e) => setMediaType(e.target.value as any)}
className="w-full px-3 py-2 rounded-xl border border-ink-200 text-xs font-semibold focus:outline-none focus:border-ink-600 bg-ink-0"
>
<option value="NONE">None</option>
<option value="IMAGE">Image / Screenshot</option>
<option value="VIDEO">Video / Iframe</option>
<option value="GIF">Animated GIF</option>
</select>
</div>
{mediaTab === 'link' ? (
<div>
<label className="block text-[9px] font-black uppercase tracking-wider text-ink-400 mb-1.5">External Media Link</label>
<input
type="text"
value={mediaUrl}
onChange={(e) => setMediaUrl(e.target.value)}
placeholder="e.g. YouTube iframe or Image URL"
className="w-full px-3 py-2 rounded-xl border border-ink-200 text-xs font-semibold focus:outline-none focus:border-ink-600 bg-ink-0"
disabled={mediaType === 'NONE'}
/>
</div>
) : (
<div>
<label className="block text-[9px] font-black uppercase tracking-wider text-ink-400 mb-1.5">Upload File</label>
<input
type="file"
ref={mediaFileRef}
onChange={handleMediaUpload}
accept="image/*,video/*"
className="hidden"
/>
<button
type="button"
onClick={() => mediaFileRef.current?.click()}
disabled={mediaType === 'NONE' || uploadingMedia}
className="w-full flex items-center justify-center gap-1.5 py-2 px-3 border border-ink-300 hover:border-ink-500 rounded-xl bg-ink-0 text-xs font-bold text-ink-700 hover:bg-ink-50 transition-colors disabled:opacity-50 cursor-pointer"
>
<Upload className="w-3.5 h-3.5" />
<span>{uploadingMedia ? 'Uploading...' : 'Select File'}</span>
</button>
</div>
)}
</div>
{mediaUrl && mediaType !== 'NONE' && (
<div className="border border-ink-200 rounded-xl overflow-hidden bg-ink-0 p-1 relative aspect-video flex items-center justify-center">
{mediaType === 'VIDEO' ? (
mediaUrl.includes('youtube.com') || mediaUrl.includes('youtube-nocookie.com') || mediaUrl.includes('vimeo.com') ? (
<iframe src={mediaUrl} className="w-full h-full border-0 rounded-lg" allowFullScreen />
) : (
<video src={mediaUrl} controls className="w-full h-full object-cover rounded-lg" />
)
) : (
<img src={mediaUrl} alt="Preview" className="w-full h-full object-cover rounded-lg" />
)}
</div>
)}
</div>
{/* Order Index & Active Toggle */}
<div className="grid grid-cols-2 gap-4 pt-2">
<div>
<label className="block text-[10px] font-black uppercase tracking-wider text-ink-500 mb-2">Order Index</label>
<input
type="number"
value={orderIndex}
onChange={(e) => setOrderIndex(Number(e.target.value))}
className="w-full px-4 py-2.5 rounded-xl border border-ink-200 text-sm font-semibold focus:outline-none focus:border-ink-600 bg-ink-0"
/>
</div>
<div className="flex items-center gap-3 pt-6">
<input
type="checkbox"
id="isActiveToggle"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="w-4 h-4 rounded text-ink-900 border-ink-300 focus:ring-ink-900"
/>
<label htmlFor="isActiveToggle" className="text-xs font-bold text-ink-700 select-none">
Active Solution (Show in Catalog)
</label>
</div>
</div>
{/* Actions */}
<div className="border-t border-ink-200 pt-6 mt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-5 py-3 rounded-xl text-xs font-bold tracking-wider uppercase border border-ink-200 text-ink-700 hover:bg-ink-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isSaving}
className="px-5 py-3 rounded-xl text-xs font-black tracking-wider uppercase bg-ink-950 text-ink-0 hover:bg-ink-850 transition-colors disabled:opacity-50"
>
{isSaving ? 'Saving...' : 'Save Offering'}
</button>
</div>
</form>
</motion.div>
</>
)}
</AnimatePresence>
{/* ── Delete Confirmation Dialog ── */}
<AnimatePresence>
{isDeleteConfirmOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsDeleteConfirmOpen(false)}
className="fixed inset-0 bg-ink-950/40 backdrop-blur-md z-45"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-ink-0 border border-ink-200 rounded-3xl p-6 shadow-2xl z-50 text-center"
>
<div className="w-12 h-12 rounded-full bg-red-500/10 text-red-500 flex items-center justify-center mx-auto mb-4 border border-red-200">
<Trash2 className="w-6 h-6" />
</div>
<h3 className="text-base font-black text-ink-955 mb-2">Delete Ecosystem Offering?</h3>
<p className="text-xs font-medium text-ink-600 mb-6">
Are you sure you want to delete <strong className="text-ink-955">{offeringToDelete?.name}</strong>? This action is permanent and cannot be undone.
</p>
<div className="flex gap-3 justify-center">
<button
onClick={() => setIsDeleteConfirmOpen(false)}
className="px-4 py-2.5 rounded-xl text-xs font-bold uppercase border border-ink-200 text-ink-700 hover:bg-ink-50 transition-colors"
>
Cancel
</button>
<button
onClick={confirmDelete}
className="px-4 py-2.5 rounded-xl text-xs font-black uppercase bg-red-600 text-ink-0 hover:bg-red-700 transition-colors"
>
Yes, Delete
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
</PageLayout>
);
};
export default EcosystemManagerPage;

View File

@ -132,12 +132,12 @@ export const LegalTemplatesPage: React.FC = () => {
<PageHeader
title="Legal Agreements"
subtitle="Configure active documents required during partner onboarding."
badge={
<div className="flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-ink-100 border border-ink-200 text-[10px] font-bold text-ink-900 tracking-wider uppercase shrink-0">
<Shield className="w-3.5 h-3.5 text-ink-950" />
<span>Compliance Panel</span>
</div>
}
// badge={
// <div className="flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-ink-100 border border-ink-200 text-[10px] font-bold text-ink-900 tracking-wider uppercase shrink-0">
// <Shield className="w-3.5 h-3.5 text-ink-950" />
// <span>Compliance Panel</span>
// </div>
// }
/>
);

View File

@ -22,6 +22,7 @@ export interface Partner {
assignedMsa?: { id: string; version: string } | null;
sharedAssets?: Array<{ assetId: string }>;
inviteToken?: string | null;
showEcosystemTab?: boolean;
}
export interface InviteParams {
@ -31,6 +32,7 @@ export interface InviteParams {
assignedMsaId?: string | null;
sharedAssetIds?: string[];
mfaEnabled?: boolean;
showEcosystemTab?: boolean;
}
export interface UpdatePartnerParams {
@ -39,6 +41,7 @@ export interface UpdatePartnerParams {
assignedMsaId?: string | null;
sharedAssetIds?: string[];
mfaEnabled?: boolean;
showEcosystemTab?: boolean;
}
export interface InviteResponse {
@ -85,6 +88,23 @@ export const updatePartner = async (partnerId: string, params: UpdatePartnerPara
return response.data;
};
export interface UpdateProfileParams {
password?: string;
companyName?: string;
website?: string;
sector?: string;
companySize?: string;
defaultTheme?: string;
}
export const updateProfile = async (params: UpdateProfileParams): Promise<any> => {
const response = await axiosInstance.put<any>(
'/auth/profile',
params
);
return response.data;
};
export const resendInvitePartner = async (partnerId: string): Promise<{ success: boolean }> => {
const response = await axiosInstance.post<{ success: boolean }>(
`/auth/partners/${partnerId}/resend-invite`

View File

@ -1,40 +0,0 @@
import { axiosInstance } from './axios';
import type { BlogPost } from '../types';
export const getBlogPosts = async (params?: {
status?: string;
tag?: string;
search?: string;
}): Promise<BlogPost[]> => {
const response = await axiosInstance.get<BlogPost[]>('/blog', { params });
return response.data;
};
export const getBlogPostById = async (id: string): Promise<BlogPost> => {
const response = await axiosInstance.get<BlogPost>(`/blog/${id}`);
return response.data;
};
export const createBlogPost = async (payload: {
title: string;
content: string;
tags?: string[];
thumbnailUrl?: string;
status: 'draft' | 'published';
author?: string;
}): Promise<BlogPost> => {
const response = await axiosInstance.post<BlogPost>('/blog/new', payload);
return response.data;
};
export const updateBlogPost = async (
id: string,
payload: Partial<BlogPost>
): Promise<BlogPost> => {
const response = await axiosInstance.patch<BlogPost>(`/blog/${id}`, payload);
return response.data;
};
export const deleteBlogPost = async (id: string): Promise<void> => {
await axiosInstance.delete(`/blog/${id}`);
};

View File

@ -0,0 +1,55 @@
import { axiosInstance } from "./axios";
export interface EcosystemOffering {
id: string;
name: string;
type: string; // "PRODUCT" | "SERVICE"
tagline: string;
description: string;
benefits: string[];
websiteUrl: string;
ctaText: string;
logoIcon: string;
logoUrl?: string | null;
mediaUrl?: string | null;
mediaType?: string | null;
orderIndex: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export const getEcosystemOfferings = async (): Promise<EcosystemOffering[]> => {
const response = await axiosInstance.get<EcosystemOffering[]>("/ecosystem/offerings");
return response.data;
};
export const createEcosystemOffering = async (
payload: Omit<Partial<EcosystemOffering>, 'id' | 'createdAt' | 'updatedAt'>
): Promise<EcosystemOffering> => {
const response = await axiosInstance.post<EcosystemOffering>("/ecosystem/offerings", payload);
return response.data;
};
export const updateEcosystemOffering = async (
id: string,
payload: Partial<EcosystemOffering>
): Promise<EcosystemOffering> => {
const response = await axiosInstance.put<EcosystemOffering>(`/ecosystem/offerings/${id}`, payload);
return response.data;
};
export const deleteEcosystemOffering = async (id: string): Promise<void> => {
await axiosInstance.delete(`/ecosystem/offerings/${id}`);
};
export const uploadEcosystemFile = async (file: File): Promise<{ url: string }> => {
const formData = new FormData();
formData.append("file", file);
const response = await axiosInstance.post<{ url: string }>("/ecosystem/upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return response.data;
};

View File

@ -4,6 +4,15 @@ export interface User {
role: 'ADMIN' | 'PARTNER_USER';
organizationId: string | null;
onboardingStatus?: string;
partnerGroup?: string | null;
companyName?: string | null;
website?: string | null;
sector?: string | null;
companySize?: string | null;
defaultTheme?: string | null;
organization?: { id: string; name: string } | null;
passwordHash?: string;
showEcosystemTab?: boolean;
}
export interface AuthResponse {

View File

@ -32,6 +32,9 @@ export interface User {
date: string;
};
createdAt: string;
passwordHash?: string;
defaultTheme?: string | null;
showEcosystemTab?: boolean;
}
export interface Category {
@ -57,17 +60,7 @@ export interface Asset {
downloadsCount: number;
}
export interface BlogPost {
id: string;
title: string;
content: string;
author: string;
publishDate: string;
thumbnailUrl: string;
readTime: string;
tags: string[];
status: 'draft' | 'published';
}
export interface Announcement {
id: string;