feat(frontend): marketplace page with subscribe + fork

This commit is contained in:
2026-05-21 00:38:20 +02:00
parent 3356767d21
commit 6a65c5cf96

View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { marketplaceApi, type MarketplaceListResponse } from '../api/marketplace.js';
import { lessonsApi } from '../api/lessons.js';
import { ApiClientError } from '../api/client.js';
export function MarketplacePage() {
const [data, setData] = useState<MarketplaceListResponse>({ rows: [], total: 0 });
const [q, setQ] = useState('');
const [curatedOnly, setCuratedOnly] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
async function refresh() {
setBusy(true); setError(null);
try {
const r = await marketplaceApi.list({ q: q.trim() || undefined, curated: curatedOnly ? true : undefined });
setData(r);
} catch (e) {
setError(e instanceof ApiClientError ? e.message : 'Kon marketplace niet laden.');
} finally { setBusy(false); }
}
useEffect(() => { refresh(); }, [curatedOnly]);
async function subscribe(id: number) {
try { await lessonsApi.subscribe(id); await refresh(); }
catch (e) { alert(e instanceof ApiClientError ? e.message : 'Abonneren mislukt'); }
}
async function fork(id: number) {
try {
const f = await lessonsApi.fork(id);
navigate(`/admin/lessons/${f.id}`);
}
catch (e) { alert(e instanceof ApiClientError ? e.message : 'Forken mislukt'); }
}
return (
<div className="space-y-6">
<header>
<h1 className="font-display text-3xl font-bold">Marketplace 🛍</h1>
<p className="text-sm text-slate-500">Vind trainingen van andere gebruikers en officiële beheerderscontent.</p>
</header>
<div className="surface flex flex-col gap-2 p-4 sm:flex-row">
<input
className="input-field flex-1"
placeholder="Zoek op naam of beschrijving…"
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && refresh()}
/>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" className="h-4 w-4 rounded accent-brand-600" checked={curatedOnly} onChange={(e) => setCuratedOnly(e.target.checked)} />
Alleen officieel
</label>
<button className="btn-primary shrink-0" onClick={refresh} disabled={busy}>Zoek</button>
</div>
{error && <p className="text-sm text-danger-700">{error}</p>}
{data.rows.length === 0 && !busy ? (
<div className="surface p-12 text-center text-slate-500">Geen lessen gevonden.</div>
) : (
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{data.rows.map((l, i) => (
<motion.li
key={l.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.02 }}
className="surface flex flex-col p-5"
>
<div className="mb-2 flex items-center gap-2">
{l.isCurated && <span className="rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-semibold text-yellow-800"> Curated</span>}
{l.isFork && <span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300">🍴 Fork</span>}
</div>
<h2 className="font-display text-lg font-bold">{l.name}</h2>
<p className="mt-1 line-clamp-2 text-sm text-slate-500">{l.description ?? <span className="italic">geen beschrijving</span>}</p>
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
<span>door {l.ownerDisplayName}</span>
<span>{l.totalCards} kaarten · {l.subscribersCount} abonnees</span>
</div>
<div className="mt-4 flex gap-2">
<button className="btn-primary flex-1" onClick={() => subscribe(l.id)}>Abonneer</button>
<button className="btn-ghost flex-1" onClick={() => fork(l.id)}>🍴 Fork</button>
</div>
</motion.li>
))}
</ul>
)}
</div>
);
}