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:
german
2026-08-05 21:49:15 +03:00
co-authored by Claude Opus 4.8
parent 8291d0f725
commit a355188301
7 changed files with 775 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { authFetch, consumeHashToken, getToken } from '@/lib/auth';
import type { Work } from '@/lib/types';
export default function ClubGalleryPage() {
const [state, setState] = useState<'loading' | 'ok' | 'forbidden' | 'guest'>('loading');
const [items, setItems] = useState<Work[]>([]);
useEffect(() => {
consumeHashToken();
if (!getToken()) {
setState('guest');
return;
}
authFetch<{ items: Work[] }>('/club/gallery')
.then((r) => {
setItems(r.items);
setState('ok');
})
.catch((err) => {
setState((err as { status?: number })?.status === 403 ? 'forbidden' : 'guest');
});
}, []);
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-accent">Только для членов клуба</p>
<h1 className="mt-4 font-serif text-5xl tracking-tightest">Закрытая галерея</h1>
</header>
{state === 'loading' && (
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="aspect-[4/5] animate-shimmer rounded-2xl border border-line dark:border-white/10" />
))}
</div>
)}
{(state === 'forbidden' || state === 'guest') && (
<div className="mx-auto mt-16 max-w-md rounded-3xl border border-line p-8 text-center dark:border-white/10">
<p className="text-muted">
{state === 'guest'
? 'Войдите и получите уровень доступа, чтобы увидеть закрытые работы.'
: 'Этот раздел открыт только членам закрытого клуба.'}
</p>
<Link
href="/club"
className="mt-6 inline-block rounded-full bg-ink px-6 py-3 text-sm text-paper transition-colors hover:bg-accent"
>
Перейти в клуб
</Link>
</div>
)}
{state === 'ok' && items.length === 0 && (
<p className="mt-16 text-center text-muted">Пока здесь нет работ. Загляните позже.</p>
)}
{state === 'ok' && items.length > 0 && (
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{items.map((w) => (
<article
key={w.id}
className="group overflow-hidden rounded-2xl border border-line dark:border-white/10"
>
<div className="aspect-[4/5] overflow-hidden bg-line/40">
{w.cover_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={w.cover_thumb || w.cover_url}
alt={w.title}
className="h-full w-full object-cover transition-transform duration-700 ease-gallery group-hover:scale-105"
/>
) : null}
</div>
<div className="p-4">
<h3 className="font-serif text-xl tracking-tightest">{w.title}</h3>
{w.medium && <p className="mt-1 text-sm text-muted">{w.medium}</p>}
</div>
</article>
))}
</div>
)}
</div>
);
}
+242
View File
@@ -0,0 +1,242 @@
'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>
);
}
+132
View File
@@ -0,0 +1,132 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { API_BASE } from '@/lib/api';
import {
consumeHashToken,
fetchMe,
getToken,
setTokens,
yandexLoginUrl,
type AuthUser,
} from '@/lib/auth';
// Иконка Яндекса (моно, вписана в кнопку).
function YandexMark() {
return (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[#FC3F1D] text-[13px] font-bold leading-none text-white">
Я
</span>
);
}
export default function LoginPage() {
const router = useRouter();
const [user, setUser] = useState<AuthUser | null>(null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// На случай, если Яндекс вернул токен именно на /login.
consumeHashToken();
if (getToken()) fetchMe().then(setUser);
}, []);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error || 'Не удалось войти.');
setTokens(data.access_token, data.refresh_token);
router.push('/club');
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка входа.');
} finally {
setBusy(false);
}
}
if (user) {
return (
<section className="mx-auto max-w-md px-6 py-24 text-center md:px-10">
<p className="text-sm uppercase tracking-[0.25em] text-muted">Вход</p>
<h1 className="mt-4 font-serif text-4xl tracking-tightest">
Вы вошли как {user.name}
</h1>
<Link
href="/club"
className="mt-8 inline-block rounded-full bg-ink px-6 py-3 text-sm text-paper transition-colors hover:bg-accent"
>
Перейти в закрытый клуб
</Link>
</section>
);
}
return (
<section className="mx-auto max-w-md px-6 py-24 md:px-10">
<p className="text-center text-sm uppercase tracking-[0.25em] text-muted">Личный кабинет</p>
<h1 className="mt-4 text-center font-serif text-4xl tracking-tightest">Вход</h1>
<a
href={yandexLoginUrl()}
className="mt-10 flex w-full 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"
>
<YandexMark />
Войти через Яндекс
</a>
<div className="my-8 flex items-center gap-4 text-xs uppercase tracking-widest text-muted">
<span className="h-px flex-1 bg-line" />
или по e-mail
<span className="h-px flex-1 bg-line" />
</div>
<form onSubmit={onSubmit} className="flex flex-col gap-3">
<input
type="email"
required
placeholder="e-mail"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="rounded-2xl border border-line bg-transparent px-5 py-3 text-sm outline-none placeholder:text-muted focus:border-ink dark:border-white/15"
/>
<input
type="password"
required
placeholder="пароль"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="rounded-2xl border border-line bg-transparent px-5 py-3 text-sm outline-none placeholder:text-muted focus:border-ink dark:border-white/15"
/>
{error && <p className="text-sm text-[#b23b3b]">{error}</p>}
<button
type="submit"
disabled={busy}
className="mt-2 rounded-full bg-ink px-6 py-3 text-sm text-paper transition-colors hover:bg-accent disabled:opacity-60"
>
{busy ? 'Входим…' : 'Войти'}
</button>
</form>
<p className="mt-8 text-center text-sm text-muted">
Закрытая платформа. Регистрация — по приглашению или через{' '}
<Link href="/club" className="text-accent underline-offset-4 hover:underline">
заявку в клуб
</Link>
.
</p>
</section>
);
}