Страница /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>
243 lines
10 KiB
TypeScript
243 lines
10 KiB
TypeScript
'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>
|
||
);
|
||
}
|