Explore_Ecosystem

This commit is contained in:
kenilkb 2026-07-16 17:27:28 +05:30
parent 9e4c9cc323
commit 61f3bd8057
18 changed files with 1496 additions and 12 deletions

View File

@ -45,6 +45,7 @@ model User {
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])
@ -163,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

@ -113,6 +113,90 @@ async function seed() {
} else {
console.log(`Partner (Approved) already exists: ${activeEmail}`);
}
// 5. Seed Ecosystem Offerings
const offerings = [
{
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
},
{
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 Ecosystem Offerings.');
console.log('Seeding completed successfully.');
}

View File

@ -15,6 +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 ecosystemRoutes from './routes/ecosystem.routes';
import { ensureBucketExists } from './utils/s3';
import { originStorage } from './utils/origin-storage';
@ -107,6 +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/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,12 +52,13 @@ 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, {
@ -64,6 +67,7 @@ export class AuthController {
assignedMsaId: assignedMsaId === undefined ? undefined : assignedMsaId,
sharedAssetIds,
mfaEnabled,
showEcosystemTab,
});
res.status(200).json(result);
} 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

@ -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

@ -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 | null, assignedNdaId?: string | null, assignedMsaId?: string | null, 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,
@ -451,6 +454,7 @@ export class AuthService {
updatedAt: true,
assignedNdaId: true,
assignedMsaId: true,
showEcosystemTab: true,
organization: {
select: {
id: true,
@ -488,6 +492,7 @@ export class AuthService {
updatedAt: true,
assignedNdaId: true,
assignedMsaId: true,
showEcosystemTab: true,
organization: {
select: {
id: true,

View File

@ -14,6 +14,7 @@ import {
Moon,
ChevronRight,
ChevronLeft,
Globe
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
@ -35,6 +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: "Ecosystem Manager", path: "/admin/ecosystem", icon: Globe },
];
return (

View File

@ -2,13 +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, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft, Settings } 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' },
];
export const ClientLayout: React.FC = () => {
const { user, logout } = useAuthStore();
const { theme, toggleTheme } = useThemeStore();
@ -17,6 +13,11 @@ 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');
@ -115,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 (
@ -228,7 +229,7 @@ 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}

View File

@ -47,11 +47,21 @@ const ClientAgreementsPage = React.lazy(() =>
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 = () => (
@ -131,6 +141,14 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: "ecosystem",
element: (
<Suspense fallback={<LoadingFallback />}>
<EcosystemPage />
</Suspense>
),
},
],
},
{
@ -189,6 +207,14 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: "ecosystem",
element: (
<Suspense fallback={<LoadingFallback />}>
<EcosystemManagerPage />
</Suspense>
),
},
],
},
]);

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,6 +55,13 @@ 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'
}
];

View File

@ -0,0 +1,222 @@
import React, { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Code,
Briefcase,
Shield,
Cloud,
Globe,
ExternalLink,
Sparkles,
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."
badge={
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-ink-950/5 dark:bg-ink-0/10 border border-ink-300 dark:border-ink-700 text-[10px] font-black text-ink-900 dark:text-ink-300 tracking-wider uppercase shrink-0">
<Sparkles className="w-3.5 h-3.5" />
<span>Ecosystem Active</span>
</div>
}
/>
);
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

@ -137,6 +137,7 @@ export const DirectoryPage: React.FC = () => {
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[]>) => {
@ -153,6 +154,7 @@ 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);
@ -370,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.`);
@ -395,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.`);
@ -439,6 +443,7 @@ export const DirectoryPage: React.FC = () => {
assignedMsaId: assignedMsaId || undefined,
sharedAssetIds: selectedAssetIds,
mfaEnabled: mfaRequired,
showEcosystemTab,
}, {
onSuccess: (data) => {
setInviteResult({ token: data.token });
@ -452,6 +457,7 @@ export const DirectoryPage: React.FC = () => {
setAssignedNdaId("");
setAssignedMsaId("");
setMfaRequired(true);
setShowEcosystemTab(true);
setSelectedAssetIds([]);
fetchPartners();
},
@ -477,6 +483,7 @@ export const DirectoryPage: React.FC = () => {
assignedMsaId: editMsaId || null,
sharedAssetIds: editAssetIds,
mfaEnabled: editMfaEnabled,
showEcosystemTab: editShowEcosystemTab,
}
}, {
onSuccess: () => {
@ -908,6 +915,7 @@ export const DirectoryPage: React.FC = () => {
setEditNdaId(partner.assignedNdaId === null ? "NONE" : partner.assignedNdaId || "");
setEditMsaId(partner.assignedMsaId === null ? "NONE" : partner.assignedMsaId || "");
setEditMfaEnabled(partner.mfaEnabled);
setEditShowEcosystemTab(partner.showEcosystemTab !== false);
const sharedIds = partner.sharedAssets?.map((sa: any) => sa.assetId) || [];
setEditAssetIds(sharedIds);
setEditShareAll(sharedIds.length === allAssets.length && allAssets.length > 0);
@ -1266,6 +1274,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>
@ -1450,6 +1472,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>

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

@ -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 {

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

@ -12,6 +12,7 @@ export interface User {
defaultTheme?: string | null;
organization?: { id: string; name: string } | null;
passwordHash?: string;
showEcosystemTab?: boolean;
}
export interface AuthResponse {

View File

@ -34,6 +34,7 @@ export interface User {
createdAt: string;
passwordHash?: string;
defaultTheme?: string | null;
showEcosystemTab?: boolean;
}
export interface Category {