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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user