diff --git a/apps/web/src/app/(portal)/marketplace/page.tsx b/apps/web/src/app/(portal)/marketplace/page.tsx new file mode 100644 index 0000000..b124197 --- /dev/null +++ b/apps/web/src/app/(portal)/marketplace/page.tsx @@ -0,0 +1,179 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useAuthStore } from '@/lib/stores/auth-store'; +import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; +import { MarketplaceCard } from './components/MarketplaceCard'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +interface Module { + id: string; + slug: string; + name: string; + version: string; + category: string; + description: Record; + icon: string | null; + isSystem: boolean; +} + +export default function MarketplacePage() { + const t = useTranslations('marketplace'); + const currentUser = useAuthStore((s) => s.user); + const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore(); + + const [modules, setModules] = useState([]); + const [activations, setActivations] = useState>(new Map()); + const [loading, setLoading] = useState(true); + const [toggling, setToggling] = useState(null); + const [error, setError] = useState(null); + + const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN'; + + const fetchModules = useCallback(async () => { + try { + const headers: Record = {}; + if (selectedTenantId) { + headers['x-tenant-id'] = selectedTenantId; + } + + const [allRes, activeRes] = await Promise.all([ + fetch(`${API_URL}/modules`, { credentials: 'include' }), + fetch(`${API_URL}/modules/active`, { credentials: 'include', headers }), + ]); + + if (allRes.ok) { + const allModules: Module[] = await allRes.json(); + setModules(allModules); + } + + if (activeRes.ok) { + const activeModules: Module[] = await activeRes.json(); + const map = new Map(); + for (const mod of activeModules) { + map.set(mod.id, true); + } + setActivations(map); + } + } catch { + setError(t('errorLoad')); + } finally { + setLoading(false); + } + }, [selectedTenantId, t]); + + useEffect(() => { + if (isAdmin) { + fetchModules(); + } else { + setLoading(false); + } + }, [isAdmin, fetchModules]); + + const toggleModule = async (moduleId: string, currentlyActive: boolean) => { + setToggling(moduleId); + setError(null); + try { + const action = currentlyActive ? 'deactivate' : 'activate'; + const headers: Record = {}; + if (selectedTenantId) { + headers['x-tenant-id'] = selectedTenantId; + } + + const res = await fetch(`${API_URL}/modules/${moduleId}/${action}`, { + method: 'POST', + credentials: 'include', + headers, + }); + + if (res.ok) { + setActivations((prev) => { + const next = new Map(prev); + if (currentlyActive) { + next.delete(moduleId); + } else { + next.set(moduleId, true); + } + return next; + }); + bumpSidebarRefresh(); + } else { + const body = await res.text().catch(() => ''); + setError(`${res.status}: ${body}`); + } + } catch (err) { + setError(String(err)); + } finally { + setToggling(null); + } + }; + + if (!isAdmin) { + return ( +
+

{t('accessDenied')}

+
+ ); + } + + return ( +
+
+

{t('title')}

+

{t('subtitle')}

+
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
+ +
+ ) : modules.length === 0 ? ( +
+ + + + + +

{t('emptyTitle')}

+

{t('emptyBody')}

+
+ ) : ( +
+ {modules.map((mod) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/lib/stores/marketplace-store.ts b/apps/web/src/lib/stores/marketplace-store.ts new file mode 100644 index 0000000..fb1ba78 --- /dev/null +++ b/apps/web/src/lib/stores/marketplace-store.ts @@ -0,0 +1,15 @@ +import { create } from 'zustand'; + +interface MarketplaceState { + sidebarRefreshKey: number; + bumpSidebarRefresh: () => void; + selectedTenantId: string | null; + setSelectedTenantId: (id: string | null) => void; +} + +export const useMarketplaceStore = create()((set) => ({ + sidebarRefreshKey: 0, + bumpSidebarRefresh: () => set((state) => ({ sidebarRefreshKey: state.sidebarRefreshKey + 1 })), + selectedTenantId: null, + setSelectedTenantId: (id) => set({ selectedTenantId: id }), +}));