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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Link from 'next/link';
|
||||
const links = [
|
||||
{ href: '/', label: 'Галерея' },
|
||||
{ href: '/auctions', label: 'Аукционы' },
|
||||
{ href: '/club', label: 'Клуб' },
|
||||
];
|
||||
|
||||
export default function Nav() {
|
||||
@@ -27,6 +28,14 @@ export default function Nav() {
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<Link
|
||||
href="/login"
|
||||
className="transition-colors hover:text-ink dark:hover:text-paper"
|
||||
>
|
||||
Войти
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/subscribe"
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Клиентский помощник авторизации (браузер).
|
||||
// Токен храним в localStorage; редирект Яндекса приносит его в hash: /club#token=...&refresh=...
|
||||
'use client';
|
||||
|
||||
import { API_BASE } from './api';
|
||||
|
||||
const TOKEN_KEY = 'nika_token';
|
||||
const REFRESH_KEY = 'nika_refresh';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: 'owner' | 'admin' | 'buyer' | 'subscriber';
|
||||
telegram_id?: number | null;
|
||||
club_tier?: string | null;
|
||||
avatar_url?: string | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return window.localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setTokens(token: string, refresh?: string | null) {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(TOKEN_KEY, token);
|
||||
if (refresh) window.localStorage.setItem(REFRESH_KEY, refresh);
|
||||
}
|
||||
|
||||
export function clearTokens() {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
window.localStorage.removeItem(REFRESH_KEY);
|
||||
}
|
||||
|
||||
// Разбирает токен из hash после редиректа OAuth. Возвращает true, если токен найден и сохранён.
|
||||
export function consumeHashToken(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const hash = window.location.hash.replace(/^#/, '');
|
||||
if (!hash) return false;
|
||||
const params = new URLSearchParams(hash);
|
||||
const token = params.get('token');
|
||||
if (!token) return false;
|
||||
setTokens(token, params.get('refresh'));
|
||||
// Чистим hash, чтобы токен не «висел» в адресной строке.
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Авторизованный fetch к нашему API. Кидает объект { status, message } при ошибке.
|
||||
export async function authFetch<T = unknown>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const token = getToken();
|
||||
const res = await fetch(`${API_BASE}${path.startsWith('/') ? path : `/${path}`}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(init.headers || {}),
|
||||
},
|
||||
});
|
||||
let data: unknown = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
/* пустое тело */
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
(data && typeof data === 'object' && 'error' in data && (data as { error: string }).error) ||
|
||||
`${res.status} ${res.statusText}`;
|
||||
throw { status: res.status, message } as { status: number; message: string };
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
// Текущий пользователь (или null, если не авторизован / токен протух).
|
||||
export async function fetchMe(): Promise<AuthUser | null> {
|
||||
if (!getToken()) return null;
|
||||
try {
|
||||
const { user } = await authFetch<{ user: AuthUser }>('/auth/me');
|
||||
return user;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Ссылка на старт входа через Яндекс (полный путь до backend).
|
||||
export function yandexLoginUrl(): string {
|
||||
return `${API_BASE}/auth/yandex`;
|
||||
}
|
||||
|
||||
export function isAdmin(user: AuthUser | null): boolean {
|
||||
return !!user && (user.role === 'admin' || user.role === 'owner');
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Типы и API-обёртки закрытого клуба (клиентская сторона).
|
||||
'use client';
|
||||
|
||||
import { authFetch } from './auth';
|
||||
|
||||
export type ClubTierId =
|
||||
| 'vip'
|
||||
| 'collector'
|
||||
| 'partner'
|
||||
| 'photographer'
|
||||
| 'organizer'
|
||||
| 'press';
|
||||
|
||||
export interface ClubTier {
|
||||
id: ClubTierId;
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export type RequestStatus = 'pending' | 'approved' | 'declined';
|
||||
|
||||
export interface MyAccessRequest {
|
||||
id: string;
|
||||
tier: ClubTierId;
|
||||
status: RequestStatus;
|
||||
note: string | null;
|
||||
admin_note?: string | null;
|
||||
created_at: string;
|
||||
reviewed_at?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminAccessRequest extends MyAccessRequest {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_email: string;
|
||||
user_avatar: string | null;
|
||||
user_current_tier: ClubTierId | null;
|
||||
}
|
||||
|
||||
export const clubApi = {
|
||||
tiers: () => authFetch<{ tiers: ClubTier[] }>('/club/tiers').then((r) => r.tiers),
|
||||
|
||||
myRequest: () =>
|
||||
authFetch<{ club_tier: ClubTierId | null; request: MyAccessRequest | null }>(
|
||||
'/club/my-request',
|
||||
),
|
||||
|
||||
submit: (tier: ClubTierId, note: string) =>
|
||||
authFetch<{ request: MyAccessRequest }>('/club/request', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tier, note }),
|
||||
}),
|
||||
|
||||
listRequests: (status = 'pending') =>
|
||||
authFetch<{ items: AdminAccessRequest[] }>(
|
||||
`/club/requests?status=${encodeURIComponent(status)}`,
|
||||
).then((r) => r.items),
|
||||
|
||||
approve: (id: string) =>
|
||||
authFetch(`/club/requests/${id}/approve`, { method: 'POST', body: '{}' }),
|
||||
|
||||
decline: (id: string) =>
|
||||
authFetch(`/club/requests/${id}/decline`, { method: 'POST', body: '{}' }),
|
||||
};
|
||||
Reference in New Issue
Block a user