feat(club): frontend закрытого клуба + вход через Яндекс
Страница /club (уровни, форма заявки, статус, админ-модерация), /club/gallery (закрытая галерея), /login (кнопка «Войти через Яндекс» + email-вход, разбор #token). Хелперы lib/auth.ts, lib/club.ts, компонент AdminRequests. Ссылки «Клуб»/«Войти» в Nav. Эстетика по существующим токенам (paper/ink/accent, Cormorant/Inter). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8291d0f725
commit
a355188301
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
clubApi,
|
||||
type AdminAccessRequest,
|
||||
type ClubTier,
|
||||
type ClubTierId,
|
||||
} from '@/lib/club';
|
||||
|
||||
const STATUS_TABS: { id: string; label: string }[] = [
|
||||
{ id: 'pending', label: 'Новые' },
|
||||
{ id: 'approved', label: 'Одобренные' },
|
||||
{ id: 'declined', label: 'Отклонённые' },
|
||||
];
|
||||
|
||||
function tierTitle(tiers: ClubTier[], id: ClubTierId): string {
|
||||
return tiers.find((t) => t.id === id)?.title ?? id;
|
||||
}
|
||||
|
||||
// Мини-панель модерации заявок (видна только админам).
|
||||
export default function AdminRequests({ tiers }: { tiers: ClubTier[] }) {
|
||||
const [tab, setTab] = useState('pending');
|
||||
const [items, setItems] = useState<AdminAccessRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actingId, setActingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (status: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setItems(await clubApi.listRequests(status));
|
||||
} catch (err) {
|
||||
setError((err as { message?: string })?.message || 'Не удалось загрузить заявки.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load(tab);
|
||||
}, [tab, load]);
|
||||
|
||||
async function act(id: string, action: 'approve' | 'decline') {
|
||||
setActingId(id);
|
||||
setError(null);
|
||||
try {
|
||||
if (action === 'approve') await clubApi.approve(id);
|
||||
else await clubApi.decline(id);
|
||||
// Убираем из текущего списка (pending → пусто), либо перезагружаем.
|
||||
setItems((prev) => prev.filter((r) => r.id !== id));
|
||||
} catch (err) {
|
||||
setError((err as { message?: string })?.message || 'Ошибка. Повторите.');
|
||||
} finally {
|
||||
setActingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto mt-24 max-w-3xl">
|
||||
<div className="flex items-center justify-between border-b border-line pb-4 dark:border-white/10">
|
||||
<h2 className="font-serif text-3xl tracking-tightest">Модерация заявок</h2>
|
||||
<div className="flex gap-1 rounded-full border border-line p-1 text-xs dark:border-white/10">
|
||||
{STATUS_TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`rounded-full px-3 py-1.5 transition-colors ${
|
||||
tab === t.id ? 'bg-ink text-paper' : 'text-muted hover:text-ink dark:hover:text-paper'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-4 text-sm text-[#b23b3b]">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-6 h-24 animate-shimmer rounded-2xl border border-line dark:border-white/10" />
|
||||
) : items.length === 0 ? (
|
||||
<p className="mt-10 text-center text-sm text-muted">Заявок нет.</p>
|
||||
) : (
|
||||
<ul className="mt-6 space-y-4">
|
||||
{items.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
className="flex flex-col gap-4 rounded-2xl border border-line p-5 sm:flex-row sm:items-start sm:justify-between dark:border-white/10"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-ink dark:text-paper">{r.user_name}</span>
|
||||
<span className="rounded-full bg-accent/10 px-2.5 py-0.5 text-xs text-accent">
|
||||
{tierTitle(tiers, r.tier)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted">{r.user_email}</p>
|
||||
{r.note && (
|
||||
<p className="mt-2 max-w-prose text-sm leading-relaxed text-muted">«{r.note}»</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
{new Date(r.created_at).toLocaleString('ru-RU')}
|
||||
{r.user_current_tier && ` · текущий уровень: ${tierTitle(tiers, r.user_current_tier)}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{r.status === 'pending' && (
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
disabled={actingId === r.id}
|
||||
onClick={() => act(r.id, 'approve')}
|
||||
className="rounded-full bg-ink px-4 py-2 text-xs text-paper transition-colors hover:bg-accent disabled:opacity-60"
|
||||
>
|
||||
Одобрить
|
||||
</button>
|
||||
<button
|
||||
disabled={actingId === r.id}
|
||||
onClick={() => act(r.id, 'decline')}
|
||||
className="rounded-full border border-line px-4 py-2 text-xs text-muted transition-colors hover:border-ink hover:text-ink disabled:opacity-60 dark:border-white/15 dark:hover:text-paper"
|
||||
>
|
||||
Отклонить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{r.status !== 'pending' && (
|
||||
<span className="shrink-0 self-start rounded-full border border-line px-3 py-1 text-xs text-muted dark:border-white/10">
|
||||
{r.status === 'approved' ? 'одобрена' : 'отклонена'}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user