feat: admin page — guard isAdmin, error handling, upload local, role assignment
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 32s
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 32s
- AuthContext: roles: string[] ajouté au type User
- Header: lien /admin masqué si !roles.includes('admin')
- AdminPage: redirect / si non-admin (Navigate)
- AdminPage: fetchError sur les 3 tabs (load silencieux → message visible)
- AdminPage: actionError sur toutes les mutations (toggle/delete/assign)
- AdminPage: loading UsersTab → skeleton list 3 cartes (aligné Videos/Plans)
- AdminPage: upload local — file input mp4/webm, multipart POST /admin/videos/upload,
storageKey auto-rempli, Créer bloqué pendant upload
- AdminPage: assignation rôle — PATCH /admin/users/:id/roles, rafraîchit la liste
This commit is contained in:
@@ -39,7 +39,7 @@ export default function Header({ theme, onToggleTheme, user, onLogout }: HeaderP
|
|||||||
Playlists
|
Playlists
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{user && (
|
{user?.roles?.includes('admin') && (
|
||||||
<Link to="/admin" className="font-mono text-xs text-od-muted hover:text-od-accent transition-colors">
|
<Link to="/admin" className="font-mono text-xs text-od-muted hover:text-od-accent transition-colors">
|
||||||
admin
|
admin
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface User {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
subscriptionLevel?: number;
|
subscriptionLevel?: number;
|
||||||
|
roles: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
|
import { useAuthContext } from '../context/AuthContext';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -42,8 +46,12 @@ interface AdminUser {
|
|||||||
type Tab = 'videos' | 'users' | 'plans';
|
type Tab = 'videos' | 'users' | 'plans';
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
|
const { user, loading: authLoading } = useAuthContext();
|
||||||
const [tab, setTab] = useState<Tab>('videos');
|
const [tab, setTab] = useState<Tab>('videos');
|
||||||
|
|
||||||
|
if (authLoading) return null;
|
||||||
|
if (!user?.roles?.includes('admin')) return <Navigate to="/" replace />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center gap-1 border-b border-od-border pb-4">
|
<div className="flex items-center gap-1 border-b border-od-border pb-4">
|
||||||
@@ -74,20 +82,45 @@ export default function AdminPage() {
|
|||||||
function VideosTab() {
|
function VideosTab() {
|
||||||
const [videos, setVideos] = useState<Video[]>([]);
|
const [videos, setVideos] = useState<Video[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
title: '', storageType: 'youtube', storageKey: '',
|
title: '', storageType: 'youtube', storageKey: '',
|
||||||
requiredLevel: 0, isPublished: false,
|
requiredLevel: 0, isPublished: false,
|
||||||
});
|
});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiFetch<{ success: boolean; data: { videos: Video[] } }>('/admin/videos')
|
apiFetch<{ success: boolean; data: { videos: Video[] } }>('/admin/videos')
|
||||||
.then((r) => setVideos(r.data.videos))
|
.then((r) => setVideos(r.data.videos))
|
||||||
.catch(() => {})
|
.catch(() => setFetchError('Impossible de charger les vidéos.'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
async function handleFileUpload(file: File) {
|
||||||
|
setUploading(true);
|
||||||
|
setUploadError(null);
|
||||||
|
setForm((f) => ({ ...f, storageKey: '' }));
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const res = await fetch(`${API_BASE}/admin/videos/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
|
const r = await res.json() as { success: boolean; data: { storageKey: string; storageType: string } };
|
||||||
|
setForm((f) => ({ ...f, storageKey: r.data.storageKey, storageType: r.data.storageType }));
|
||||||
|
} catch {
|
||||||
|
setUploadError('Échec de l\'upload — vérifier format (mp4/webm, 4 Go max).');
|
||||||
|
}
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreate(e: React.FormEvent) {
|
async function handleCreate(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!form.title || !form.storageKey || saving) return;
|
if (!form.title || !form.storageKey || saving) return;
|
||||||
@@ -107,21 +140,27 @@ function VideosTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function togglePublish(video: Video) {
|
async function togglePublish(video: Video) {
|
||||||
|
setActionError(null);
|
||||||
try {
|
try {
|
||||||
const r = await apiFetch<{ success: boolean; data: { video: Video } }>(
|
const r = await apiFetch<{ success: boolean; data: { video: Video } }>(
|
||||||
`/admin/videos/${video.id}`,
|
`/admin/videos/${video.id}`,
|
||||||
{ method: 'PATCH', body: JSON.stringify({ isPublished: !video.isPublished }) }
|
{ method: 'PATCH', body: JSON.stringify({ isPublished: !video.isPublished }) }
|
||||||
);
|
);
|
||||||
setVideos((v) => v.map((x) => x.id === video.id ? r.data.video : x));
|
setVideos((v) => v.map((x) => x.id === video.id ? r.data.video : x));
|
||||||
} catch {}
|
} catch {
|
||||||
|
setActionError('Impossible de modifier la vidéo.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
if (!confirm('Supprimer cette vidéo ?')) return;
|
if (!confirm('Supprimer cette vidéo ?')) return;
|
||||||
|
setActionError(null);
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/admin/videos/${id}`, { method: 'DELETE' });
|
await apiFetch(`/admin/videos/${id}`, { method: 'DELETE' });
|
||||||
setVideos((v) => v.filter((x) => x.id !== id));
|
setVideos((v) => v.filter((x) => x.id !== id));
|
||||||
} catch {}
|
} catch {
|
||||||
|
setActionError('Impossible de supprimer la vidéo.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -142,7 +181,7 @@ function VideosTab() {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select
|
<select
|
||||||
value={form.storageType}
|
value={form.storageType}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, storageType: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, storageType: e.target.value, storageKey: '' }))}
|
||||||
className="rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text outline-none focus:border-od-accent"
|
className="rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text outline-none focus:border-od-accent"
|
||||||
>
|
>
|
||||||
<option value="youtube">YouTube</option>
|
<option value="youtube">YouTube</option>
|
||||||
@@ -150,13 +189,30 @@ function VideosTab() {
|
|||||||
<option value="s3">S3</option>
|
<option value="s3">S3</option>
|
||||||
<option value="external">External</option>
|
<option value="external">External</option>
|
||||||
</select>
|
</select>
|
||||||
<input
|
{form.storageType === 'local' ? (
|
||||||
value={form.storageKey}
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
onChange={(e) => setForm((f) => ({ ...f, storageKey: e.target.value }))}
|
<input
|
||||||
placeholder={form.storageType === 'youtube' ? 'ID YouTube (ex: dQw4w9WgXcQ)' : 'Chemin / URL'}
|
type="file"
|
||||||
required
|
accept="video/mp4,video/webm"
|
||||||
className="flex-1 rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
|
disabled={uploading}
|
||||||
/>
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFileUpload(f); }}
|
||||||
|
className="flex-1 rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text outline-none focus:border-od-accent file:mr-2 file:rounded file:border-0 file:bg-od-surface file:px-2 file:py-0.5 file:font-mono file:text-xs file:text-od-muted"
|
||||||
|
/>
|
||||||
|
{uploading && <p className="font-mono text-xs text-od-muted">Envoi en cours…</p>}
|
||||||
|
{uploadError && <p className="font-mono text-xs text-od-crit">{uploadError}</p>}
|
||||||
|
{!uploading && !uploadError && form.storageKey && (
|
||||||
|
<p className="font-mono text-xs text-od-ok">✓ Upload réussi</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
value={form.storageKey}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, storageKey: e.target.value }))}
|
||||||
|
placeholder={form.storageType === 'youtube' ? 'ID YouTube (ex: dQw4w9WgXcQ)' : 'Chemin / URL'}
|
||||||
|
required
|
||||||
|
className="flex-1 rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -183,7 +239,7 @@ function VideosTab() {
|
|||||||
{error && <p className="text-xs text-od-crit">{error}</p>}
|
{error && <p className="text-xs text-od-crit">{error}</p>}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving || uploading}
|
||||||
className="self-start rounded border border-od-accent px-4 py-1.5 font-mono text-xs text-od-accent hover:bg-od-accent hover:text-od-bg transition-colors disabled:opacity-40"
|
className="self-start rounded border border-od-accent px-4 py-1.5 font-mono text-xs text-od-accent hover:bg-od-accent hover:text-od-bg transition-colors disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{saving ? '…' : 'Créer'}
|
{saving ? '…' : 'Créer'}
|
||||||
@@ -195,8 +251,11 @@ function VideosTab() {
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{[...Array(3)].map((_, i) => <div key={i} className="h-12 rounded border border-od-border animate-pulse" />)}
|
{[...Array(3)].map((_, i) => <div key={i} className="h-12 rounded border border-od-border animate-pulse" />)}
|
||||||
</div>
|
</div>
|
||||||
|
) : fetchError ? (
|
||||||
|
<p className="text-sm text-od-crit">{fetchError}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
{actionError && <p className="text-xs text-od-crit">{actionError}</p>}
|
||||||
{videos.map((v) => (
|
{videos.map((v) => (
|
||||||
<div key={v.id} className="flex items-center gap-3 rounded border border-od-border bg-od-surface px-4 py-3">
|
<div key={v.id} className="flex items-center gap-3 rounded border border-od-border bg-od-surface px-4 py-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -234,8 +293,12 @@ function UsersTab() {
|
|||||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
const [plans, setPlans] = useState<Plan[]>([]);
|
const [plans, setPlans] = useState<Plan[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [assigning, setAssigning] = useState<string | null>(null);
|
const [assigning, setAssigning] = useState<string | null>(null);
|
||||||
const [selectedPlan, setSelectedPlan] = useState<Record<string, string>>({});
|
const [selectedPlan, setSelectedPlan] = useState<Record<string, string>>({});
|
||||||
|
const [assigningRole, setAssigningRole] = useState<string | null>(null);
|
||||||
|
const [selectedRole, setSelectedRole] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -246,7 +309,7 @@ function UsersTab() {
|
|||||||
setUsers(ur.data.users);
|
setUsers(ur.data.users);
|
||||||
setPlans(pr.data.plans);
|
setPlans(pr.data.plans);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => setError('Impossible de charger les utilisateurs.'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -254,22 +317,49 @@ function UsersTab() {
|
|||||||
const planId = selectedPlan[userId];
|
const planId = selectedPlan[userId];
|
||||||
if (!planId || assigning) return;
|
if (!planId || assigning) return;
|
||||||
setAssigning(userId);
|
setAssigning(userId);
|
||||||
|
setActionError(null);
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/admin/users/${userId}/subscriptions`, {
|
await apiFetch(`/admin/users/${userId}/subscriptions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ planId }),
|
body: JSON.stringify({ planId }),
|
||||||
});
|
});
|
||||||
// Rafraîchir la liste users
|
|
||||||
const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users');
|
const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users');
|
||||||
setUsers(r.data.users);
|
setUsers(r.data.users);
|
||||||
} catch {}
|
} catch {
|
||||||
|
setActionError('Impossible d\'assigner le plan.');
|
||||||
|
}
|
||||||
setAssigning(null);
|
setAssigning(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="h-32 animate-pulse rounded border border-od-border" />;
|
async function assignRole(userId: string) {
|
||||||
|
const role = selectedRole[userId];
|
||||||
|
if (!role || assigningRole) return;
|
||||||
|
setAssigningRole(userId);
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
await apiFetch(`/admin/users/${userId}/roles`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ roles: [role] }),
|
||||||
|
});
|
||||||
|
const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users');
|
||||||
|
setUsers(r.data.users);
|
||||||
|
} catch {
|
||||||
|
setActionError('Impossible d\'assigner le rôle.');
|
||||||
|
}
|
||||||
|
setAssigningRole(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{[...Array(3)].map((_, i) => <div key={i} className="h-20 rounded border border-od-border animate-pulse" />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) return <p className="text-sm text-od-crit">{error}</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
{actionError && <p className="text-xs text-od-crit">{actionError}</p>}
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<div key={u.id} className="flex flex-col gap-2 rounded border border-od-border bg-od-surface px-4 py-3">
|
<div key={u.id} className="flex flex-col gap-2 rounded border border-od-border bg-od-surface px-4 py-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
@@ -277,7 +367,10 @@ function UsersTab() {
|
|||||||
<p className="text-sm font-medium text-od-text">{u.nickname}</p>
|
<p className="text-sm font-medium text-od-text">{u.nickname}</p>
|
||||||
<p className="font-mono text-xs text-od-muted">{u.email ?? '—'}</p>
|
<p className="font-mono text-xs text-od-muted">{u.email ?? '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<span className="font-mono text-xs text-od-muted">
|
||||||
|
{u.roles.map((r) => r.slug).join(', ') || '—'}
|
||||||
|
</span>
|
||||||
{u.activeSubscription ? (
|
{u.activeSubscription ? (
|
||||||
<span className="font-mono text-xs text-od-accent">
|
<span className="font-mono text-xs text-od-accent">
|
||||||
{u.activeSubscription.plan.name}
|
{u.activeSubscription.plan.name}
|
||||||
@@ -295,7 +388,7 @@ function UsersTab() {
|
|||||||
onChange={(e) => setSelectedPlan((s) => ({ ...s, [u.id]: e.target.value }))}
|
onChange={(e) => setSelectedPlan((s) => ({ ...s, [u.id]: e.target.value }))}
|
||||||
className="flex-1 rounded border border-od-border bg-od-bg px-2 py-1 text-xs text-od-text outline-none focus:border-od-accent"
|
className="flex-1 rounded border border-od-border bg-od-bg px-2 py-1 text-xs text-od-text outline-none focus:border-od-accent"
|
||||||
>
|
>
|
||||||
<option value="">— Assigner un plan —</option>
|
<option value="">— Plan —</option>
|
||||||
{plans.filter((p) => p.isActive).map((p) => (
|
{plans.filter((p) => p.isActive).map((p) => (
|
||||||
<option key={p.id} value={p.id}>{p.name} (niv. {p.level})</option>
|
<option key={p.id} value={p.id}>{p.name} (niv. {p.level})</option>
|
||||||
))}
|
))}
|
||||||
@@ -305,7 +398,24 @@ function UsersTab() {
|
|||||||
onClick={() => assignPlan(u.id)}
|
onClick={() => assignPlan(u.id)}
|
||||||
className="rounded border border-od-border px-3 py-1 font-mono text-xs text-od-muted hover:border-od-accent hover:text-od-accent transition-colors disabled:opacity-40"
|
className="rounded border border-od-border px-3 py-1 font-mono text-xs text-od-muted hover:border-od-accent hover:text-od-accent transition-colors disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{assigning === u.id ? '…' : 'Assigner'}
|
{assigning === u.id ? '…' : 'Plan'}
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
value={selectedRole[u.id] ?? ''}
|
||||||
|
onChange={(e) => setSelectedRole((s) => ({ ...s, [u.id]: e.target.value }))}
|
||||||
|
className="rounded border border-od-border bg-od-bg px-2 py-1 text-xs text-od-text outline-none focus:border-od-accent"
|
||||||
|
>
|
||||||
|
<option value="">— Rôle —</option>
|
||||||
|
<option value="user">user</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
<option value="super_admin">super_admin</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
disabled={!selectedRole[u.id] || assigningRole === u.id}
|
||||||
|
onClick={() => assignRole(u.id)}
|
||||||
|
className="rounded border border-od-border px-3 py-1 font-mono text-xs text-od-muted hover:border-od-accent hover:text-od-accent transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{assigningRole === u.id ? '…' : 'Rôle'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -320,6 +430,8 @@ function UsersTab() {
|
|||||||
function PlansTab() {
|
function PlansTab() {
|
||||||
const [plans, setPlans] = useState<Plan[]>([]);
|
const [plans, setPlans] = useState<Plan[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [form, setForm] = useState({ slug: '', name: '', level: 1, priceInCents: 0 });
|
const [form, setForm] = useState({ slug: '', name: '', level: 1, priceInCents: 0 });
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -327,7 +439,7 @@ function PlansTab() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiFetch<{ success: boolean; data: { plans: Plan[] } }>('/admin/plans')
|
apiFetch<{ success: boolean; data: { plans: Plan[] } }>('/admin/plans')
|
||||||
.then((r) => setPlans(r.data.plans))
|
.then((r) => setPlans(r.data.plans))
|
||||||
.catch(() => {})
|
.catch(() => setFetchError('Impossible de charger les plans.'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -350,16 +462,25 @@ function PlansTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleActive(plan: Plan) {
|
async function toggleActive(plan: Plan) {
|
||||||
|
setActionError(null);
|
||||||
try {
|
try {
|
||||||
const r = await apiFetch<{ success: boolean; data: { plan: Plan } }>(
|
const r = await apiFetch<{ success: boolean; data: { plan: Plan } }>(
|
||||||
`/admin/plans/${plan.id}`,
|
`/admin/plans/${plan.id}`,
|
||||||
{ method: 'PATCH', body: JSON.stringify({ isActive: !plan.isActive }) }
|
{ method: 'PATCH', body: JSON.stringify({ isActive: !plan.isActive }) }
|
||||||
);
|
);
|
||||||
setPlans((p) => p.map((x) => x.id === plan.id ? r.data.plan : x));
|
setPlans((p) => p.map((x) => x.id === plan.id ? r.data.plan : x));
|
||||||
} catch {}
|
} catch {
|
||||||
|
setActionError('Impossible de modifier le plan.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <div className="h-24 animate-pulse rounded border border-od-border" />;
|
if (loading) return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{[...Array(2)].map((_, i) => <div key={i} className="h-12 rounded border border-od-border animate-pulse" />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fetchError) return <p className="text-sm text-od-crit">{fetchError}</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -412,6 +533,7 @@ function PlansTab() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
{actionError && <p className="text-xs text-od-crit">{actionError}</p>}
|
||||||
{plans.map((p) => (
|
{plans.map((p) => (
|
||||||
<div key={p.id} className="flex items-center gap-3 rounded border border-od-border bg-od-surface px-4 py-3">
|
<div key={p.id} className="flex items-center gap-3 rounded border border-od-border bg-od-surface px-4 py-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user