Explore_Ecosystem
This commit is contained in:
parent
9e4c9cc323
commit
61f3bd8057
@ -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
|
||||
}
|
||||
|
||||
@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@ -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.' });
|
||||
|
||||
@ -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); }
|
||||
|
||||
114
Channel-Backend/src/controllers/ecosystem.controller.ts
Normal file
114
Channel-Backend/src/controllers/ecosystem.controller.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
15
Channel-Backend/src/routes/ecosystem.routes.ts
Normal file
15
Channel-Backend/src/routes/ecosystem.routes.ts
Normal 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;
|
||||
@ -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,
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@ -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'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
222
Channel-Frontend/src/pages/EcosystemPage.tsx
Normal file
222
Channel-Frontend/src/pages/EcosystemPage.tsx
Normal 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;
|
||||
@ -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>
|
||||
|
||||
886
Channel-Frontend/src/pages/admin/EcosystemManagerPage.tsx
Normal file
886
Channel-Frontend/src/pages/admin/EcosystemManagerPage.tsx
Normal 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 Benefit 2 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;
|
||||
@ -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 {
|
||||
|
||||
55
Channel-Frontend/src/services/ecosystem-api.ts
Normal file
55
Channel-Frontend/src/services/ecosystem-api.ts
Normal 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;
|
||||
};
|
||||
@ -12,6 +12,7 @@ export interface User {
|
||||
defaultTheme?: string | null;
|
||||
organization?: { id: string; name: string } | null;
|
||||
passwordHash?: string;
|
||||
showEcosystemTab?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
|
||||
@ -34,6 +34,7 @@ export interface User {
|
||||
createdAt: string;
|
||||
passwordHash?: string;
|
||||
defaultTheme?: string | null;
|
||||
showEcosystemTab?: boolean;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user