Files
nika-gallery/frontend/app/club/page.tsx
T
germanandClaude Opus 4.8 a355188301 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>
2026-08-05 21:49:15 +03:00

243 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useCallback, useEffect, useState } from 'react';
import {
consumeHashToken,
fetchMe,
isAdmin,
yandexLoginUrl,
type AuthUser,
} from '@/lib/auth';
import {
clubApi,
type ClubTier,
type ClubTierId,
type MyAccessRequest,
type RequestStatus,
} from '@/lib/club';
import AdminRequests from '@/components/AdminRequests';
// Фолбэк-описание уровней (если API недоступен) — синхронно с backend CLUB_TIERS.
const FALLBACK_TIERS: ClubTier[] = [
{ id: 'vip', title: 'VIP', desc: 'Полный доступ ко всем закрытым работам, приоритет на аукционах, личные превью.' },
{ id: 'collector', title: 'Коллекционеры', desc: 'Ранний доступ к новым работам и коллекционным сериям, консультации по подбору.' },
{ id: 'partner', title: 'Партнёры', desc: 'Совместные проекты и коллаборации, специальные условия сотрудничества.' },
{ id: 'photographer', title: 'Фотографы', desc: 'Backstage-материалы и совместные съёмки body art.' },
{ id: 'organizer', title: 'Организаторы', desc: 'Работы и материалы для выставок, перформансов и мероприятий.' },
{ id: 'press', title: 'Пресса', desc: 'Пресс-материалы, изображения в высоком разрешении, интервью.' },
];
const STATUS_LABEL: Record<RequestStatus, string> = {
pending: 'на рассмотрении',
approved: 'одобрена',
declined: 'отклонена',
};
function tierTitle(tiers: ClubTier[], id: ClubTierId): string {
return tiers.find((t) => t.id === id)?.title ?? id;
}
export default function ClubPage() {
const [ready, setReady] = useState(false);
const [user, setUser] = useState<AuthUser | null>(null);
const [tiers, setTiers] = useState<ClubTier[]>(FALLBACK_TIERS);
const [clubTier, setClubTier] = useState<ClubTierId | null>(null);
const [myRequest, setMyRequest] = useState<MyAccessRequest | null>(null);
// Форма заявки.
const [selected, setSelected] = useState<ClubTierId | ''>('');
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const refreshMine = useCallback(async () => {
try {
const r = await clubApi.myRequest();
setClubTier(r.club_tier);
setMyRequest(r.request);
} catch {
/* не критично */
}
}, []);
useEffect(() => {
consumeHashToken();
clubApi.tiers().then(setTiers).catch(() => setTiers(FALLBACK_TIERS));
(async () => {
const me = await fetchMe();
setUser(me);
if (me) await refreshMine();
setReady(true);
})();
}, [refreshMine]);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!selected) return;
setBusy(true);
setMsg(null);
try {
await clubApi.submit(selected, note.trim());
setMsg({ ok: true, text: 'Заявка отправлена. Мы сообщим о решении.' });
setNote('');
setSelected('');
await refreshMine();
} catch (err) {
const m = (err as { message?: string })?.message || 'Не удалось отправить заявку.';
setMsg({ ok: false, text: m });
} finally {
setBusy(false);
}
}
const canRequest = user && !myRequest && !clubTier;
const pendingSame = myRequest?.status === 'pending';
return (
<div className="mx-auto max-w-content px-6 py-20 md:px-10">
{/* Заголовок */}
<header className="mx-auto max-w-2xl text-center">
<p className="text-sm uppercase tracking-[0.25em] text-muted">Только по приглашению</p>
<h1 className="mt-4 font-serif text-5xl tracking-tightest md:text-6xl">Закрытый клуб</h1>
<p className="mt-6 leading-relaxed text-muted">
Пространство для тех, кто ближе к искусству Ники: закрытые серии body art,
приватные превью, приоритет на аукционах и прямой диалог. Доступ — по заявке
и решению автора.
</p>
</header>
{/* Уровни доступа */}
<section className="mt-16 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{tiers.map((t) => {
const active = clubTier === t.id;
return (
<article
key={t.id}
className={`group relative rounded-3xl border p-7 transition-colors ${
active
? 'border-accent bg-accent/5'
: 'border-line hover:border-ink/30 dark:border-white/10'
}`}
>
<div className="flex items-center justify-between">
<h3 className="font-serif text-2xl tracking-tightest">{t.title}</h3>
{active && (
<span className="rounded-full bg-accent px-3 py-1 text-xs text-paper">
ваш уровень
</span>
)}
</div>
<p className="mt-3 text-sm leading-relaxed text-muted">{t.desc}</p>
</article>
);
})}
</section>
{/* Блок действия: вход / заявка / статус */}
<section className="mx-auto mt-16 max-w-xl">
{!ready ? (
<div className="h-40 animate-shimmer rounded-3xl border border-line dark:border-white/10" />
) : !user ? (
<div className="rounded-3xl border border-line p-8 text-center dark:border-white/10">
<h2 className="font-serif text-2xl tracking-tightest">Войдите, чтобы подать заявку</h2>
<p className="mt-3 text-sm text-muted">
Быстрый вход через Яндекс ID — без пароля и регистрации.
</p>
<a
href={yandexLoginUrl()}
className="mt-6 inline-flex items-center justify-center gap-3 rounded-full border border-ink/15 bg-white px-6 py-3.5 text-sm font-medium text-ink shadow-sm transition-colors hover:border-ink/40 dark:bg-white/5 dark:text-paper"
>
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[#FC3F1D] text-[13px] font-bold leading-none text-white">
Я
</span>
Войти через Яндекс
</a>
</div>
) : clubTier ? (
<div className="rounded-3xl border border-accent bg-accent/5 p-8 text-center">
<p className="text-sm uppercase tracking-[0.2em] text-accent">Добро пожаловать</p>
<h2 className="mt-3 font-serif text-3xl tracking-tightest">
Вы — {tierTitle(tiers, clubTier)}
</h2>
<p className="mt-3 text-sm text-muted">
Закрытая галерея открыта для вас.
</p>
<a
href="/club/gallery"
className="mt-6 inline-block rounded-full bg-ink px-6 py-3 text-sm text-paper transition-colors hover:bg-accent"
>
Открыть закрытую галерею
</a>
</div>
) : myRequest && pendingSame ? (
<div className="rounded-3xl border border-line p-8 text-center dark:border-white/10">
<h2 className="font-serif text-2xl tracking-tightest">Заявка на рассмотрении</h2>
<p className="mt-3 text-sm text-muted">
Уровень «{tierTitle(tiers, myRequest.tier)}» — статус:{' '}
<span className="text-ink dark:text-paper">{STATUS_LABEL[myRequest.status]}</span>.
Мы сообщим о решении.
</p>
</div>
) : (
<form
onSubmit={onSubmit}
className="rounded-3xl border border-line p-8 dark:border-white/10"
>
<h2 className="font-serif text-2xl tracking-tightest">Подать заявку</h2>
{myRequest?.status === 'declined' && (
<p className="mt-2 text-sm text-muted">
Предыдущая заявка («{tierTitle(tiers, myRequest.tier)}») была отклонена. Вы можете
подать новую.
</p>
)}
<label className="mt-6 block text-sm text-muted">Уровень</label>
<select
required
value={selected}
onChange={(e) => setSelected(e.target.value as ClubTierId)}
className="mt-2 w-full rounded-2xl border border-line bg-transparent px-4 py-3 text-sm outline-none focus:border-ink dark:border-white/15 dark:bg-transparent"
>
<option value="" disabled>
Выберите уровень…
</option>
{tiers.map((t) => (
<option key={t.id} value={t.id} className="text-ink">
{t.title}
</option>
))}
</select>
<label className="mt-5 block text-sm text-muted">О себе (необязательно)</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
rows={4}
maxLength={2000}
placeholder="Пара слов о себе и почему вам интересен клуб…"
className="mt-2 w-full resize-none rounded-2xl border border-line bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted focus:border-ink dark:border-white/15"
/>
{msg && (
<p className={`mt-4 text-sm ${msg.ok ? 'text-accent' : 'text-[#b23b3b]'}`}>
{msg.text}
</p>
)}
<button
type="submit"
disabled={busy || !canRequest}
className="mt-6 w-full rounded-full bg-ink px-6 py-3 text-sm text-paper transition-colors hover:bg-accent disabled:opacity-60"
>
{busy ? 'Отправляем…' : 'Отправить заявку'}
</button>
</form>
)}
</section>
{/* Админ-панель модерации */}
{isAdmin(user) && <AdminRequests tiers={tiers} />}
</div>
);
}