feat(04-01): implement marketplace page and marketplace-store
Create marketplace-store (Zustand) with sidebarRefreshKey signal and tenant context. Build /marketplace page with parallel fetch, activation Map, role gate, empty state, and responsive card grid. All 9 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string>;
|
||||||
|
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<Module[]>([]);
|
||||||
|
const [activations, setActivations] = useState<Map<string, boolean>>(new Map());
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [toggling, setToggling] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||||
|
|
||||||
|
const fetchModules = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
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<string, boolean>();
|
||||||
|
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<string, string> = {};
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<p className="text-lg text-muted-foreground">{t('accessDenied')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{t('subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center min-h-[40vh]">
|
||||||
|
<span className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : modules.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[40vh] text-center">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-muted-foreground/40 mb-4"
|
||||||
|
>
|
||||||
|
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
|
||||||
|
<path d="m3.3 7 8.7 5 8.7-5" />
|
||||||
|
<path d="M12 22V12" />
|
||||||
|
</svg>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">{t('emptyTitle')}</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground max-w-md">{t('emptyBody')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{modules.map((mod) => (
|
||||||
|
<MarketplaceCard
|
||||||
|
key={mod.id}
|
||||||
|
id={mod.id}
|
||||||
|
name={mod.name}
|
||||||
|
slug={mod.slug}
|
||||||
|
description={mod.description}
|
||||||
|
icon={mod.icon ?? undefined}
|
||||||
|
category={mod.category}
|
||||||
|
isActive={activations.has(mod.id)}
|
||||||
|
isToggling={toggling === mod.id}
|
||||||
|
onToggle={toggleModule}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<MarketplaceState>()((set) => ({
|
||||||
|
sidebarRefreshKey: 0,
|
||||||
|
bumpSidebarRefresh: () => set((state) => ({ sidebarRefreshKey: state.sidebarRefreshKey + 1 })),
|
||||||
|
selectedTenantId: null,
|
||||||
|
setSelectedTenantId: (id) => set({ selectedTenantId: id }),
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user