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>
);
}
+138
View File
@@ -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>
);
}
+9
View File
@@ -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"
+100
View File
@@ -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');
}
+64
View File
@@ -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: '{}' }),
};