- «Winrate» -> «Винрейт» в карточке статистики; в справке добавлена расшифровка «Винрейт (WR)» — WR остаётся в компактных таблицах; - кнопки сохранения: «ОК» -> «Сохранить» (аккаунт, фракции, аккаунты в админке); отмена в модалках/формах — везде «Отмена», не «Назад»; - пустые состояния приведены к шаблону «X пока нет.»; - роли участников группы переведены: owner/member -> владелец/участник, бейдж admin -> «админ»; «Имя обновлено» -> «Ник обновлён»; - «N игр.» в значении числа игроков -> «N игроков» со склонением (новый хелпер plural в domain/format.ts); - «минимум 2 участника» -> «минимум 2 игрока» (в партии — игроки, в группе — участники). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
229 lines
7.9 KiB
TypeScript
229 lines
7.9 KiB
TypeScript
import { useState } from "react";
|
|
import { Navigate, useNavigate } from "react-router-dom";
|
|
|
|
import { ApiError } from "../api/client";
|
|
import { Spinner } from "../components/Spinner";
|
|
import { TelegramLoginButton } from "../components/TelegramLoginButton";
|
|
import {
|
|
useAuthConfig,
|
|
useCreateDevUser,
|
|
useDevUsers,
|
|
useMe,
|
|
useStubLogin,
|
|
useTelegramLogin,
|
|
useTelegramRegister,
|
|
} from "../hooks/auth";
|
|
|
|
// Данные виджета Telegram, отложенные до выбора ника (когда тег занят).
|
|
interface PendingTelegram {
|
|
payload: Record<string, unknown>;
|
|
suggested: string;
|
|
}
|
|
|
|
export function LoginPage() {
|
|
const { data: me, isLoading } = useMe();
|
|
const { data: config } = useAuthConfig();
|
|
const navigate = useNavigate();
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [pendingTg, setPendingTg] = useState<PendingTelegram | null>(null);
|
|
|
|
const methods = config?.methods ?? [];
|
|
const hasTelegram = methods.includes("telegram");
|
|
// Stub-вход показываем только в dev-сборке И если бэкенд его отдаёт (в проде
|
|
// import.meta.env.DEV === false → блок вырезается из бандла).
|
|
const showStub = import.meta.env.DEV && methods.includes("stub");
|
|
|
|
const telegram = useTelegramLogin();
|
|
|
|
if (isLoading) return <Spinner />;
|
|
if (me) return <Navigate to="/" replace />;
|
|
|
|
const onTelegram = async (user: Record<string, unknown>) => {
|
|
setError(null);
|
|
try {
|
|
await telegram.mutateAsync(user);
|
|
navigate("/", { replace: true });
|
|
} catch (e) {
|
|
// Тег занят/некорректен → просим выбрать ник и регистрируем тем же payload.
|
|
if (e instanceof ApiError && e.code === "TELEGRAM_NICKNAME_REQUIRED") {
|
|
const suggested = (e.details as { suggested?: string } | undefined)?.suggested ?? "";
|
|
setPendingTg({ payload: user, suggested });
|
|
return;
|
|
}
|
|
setError(e instanceof ApiError ? e.message : "Не удалось войти через Telegram");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="app-shell">
|
|
<div className="app-main" style={{ paddingBottom: 24 }}>
|
|
<h1 className="center" style={{ marginTop: 24 }}>
|
|
Forbidden Stars
|
|
</h1>
|
|
<p className="muted center">Учёт партий</p>
|
|
|
|
{pendingTg ? (
|
|
<TelegramNicknamePicker
|
|
pending={pendingTg}
|
|
onCancel={() => {
|
|
setPendingTg(null);
|
|
setError(null);
|
|
}}
|
|
onDone={() => navigate("/", { replace: true })}
|
|
/>
|
|
) : (
|
|
hasTelegram && (
|
|
<div className="card">
|
|
<h3>Вход через Telegram</h3>
|
|
{config?.telegram_bot_username ? (
|
|
<TelegramLoginButton
|
|
botUsername={config.telegram_bot_username}
|
|
onAuth={(u) => onTelegram(u as unknown as Record<string, unknown>)}
|
|
/>
|
|
) : (
|
|
<p className="muted small">
|
|
Telegram не настроен — укажите TELEGRAM_BOT_USERNAME и TELEGRAM_BOT_TOKEN
|
|
в .env (бот от @BotFather + /setdomain на ваш домен).
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{showStub && !pendingTg && <DevLogin onError={setError} />}
|
|
|
|
{error && <p className="error-text center">{error}</p>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Выбор ника при регистрации через Telegram, когда тег занят/некорректен.
|
|
// Повторяет регистрацию тем же payload виджета (подпись на сервере проверяется заново).
|
|
function TelegramNicknamePicker({
|
|
pending,
|
|
onCancel,
|
|
onDone,
|
|
}: {
|
|
pending: PendingTelegram;
|
|
onCancel: () => void;
|
|
onDone: () => void;
|
|
}) {
|
|
const register = useTelegramRegister();
|
|
const [nickname, setNickname] = useState(pending.suggested);
|
|
const [error, setError] = useState<string | null>(
|
|
pending.suggested ? `Ник «${pending.suggested}» уже занят — выберите другой.` : null,
|
|
);
|
|
|
|
const submit = async () => {
|
|
const nick = nickname.trim();
|
|
if (!nick) {
|
|
setError("Введите никнейм.");
|
|
return;
|
|
}
|
|
setError(null);
|
|
try {
|
|
await register.mutateAsync({ ...pending.payload, nickname: nick });
|
|
onDone();
|
|
} catch (e) {
|
|
setError(e instanceof ApiError ? e.message : "Не удалось зарегистрироваться");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="card">
|
|
<h3>Выберите никнейм</h3>
|
|
<p className="muted small">
|
|
Ваш ник в Telegram уже занят другим игроком. Укажите другой никнейм для регистрации.
|
|
</p>
|
|
<div className="field">
|
|
<div className="row">
|
|
<input
|
|
placeholder="Никнейм"
|
|
value={nickname}
|
|
autoFocus
|
|
onChange={(e) => setNickname(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") submit();
|
|
}}
|
|
/>
|
|
<button className="btn btn-primary" onClick={submit} disabled={register.isPending}>
|
|
Зарегистрироваться
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{error && <p className="error-text">{error}</p>}
|
|
<button className="btn btn-ghost small" onClick={onCancel}>
|
|
Отмена
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// DEV-вход по нику. Вынесен в отдельный компонент; импортируется только в dev-ветке
|
|
// LoginPage, поэтому в прод-сборке (import.meta.env.DEV=false) удаляется тришейкингом.
|
|
function DevLogin({ onError }: { onError: (msg: string | null) => void }) {
|
|
const { data: devUsers } = useDevUsers(true);
|
|
const stubLogin = useStubLogin();
|
|
const createUser = useCreateDevUser();
|
|
const navigate = useNavigate();
|
|
const [nickname, setNickname] = useState("");
|
|
|
|
const doLogin = async (nick: string) => {
|
|
onError(null);
|
|
try {
|
|
await stubLogin.mutateAsync(nick);
|
|
navigate("/", { replace: true });
|
|
} catch (e) {
|
|
onError(e instanceof ApiError ? e.message : "Не удалось войти");
|
|
}
|
|
};
|
|
|
|
const onCreate = async () => {
|
|
const nick = nickname.trim();
|
|
if (!nick) return;
|
|
try {
|
|
await createUser.mutateAsync(nick);
|
|
await doLogin(nick);
|
|
} catch (e) {
|
|
onError(e instanceof ApiError ? e.message : "Ошибка");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="card">
|
|
<h3>Тестовый вход (dev)</h3>
|
|
<div className="field">
|
|
<label className="label">Новый игрок</label>
|
|
<div className="row">
|
|
<input placeholder="Ваш никнейм" value={nickname} onChange={(e) => setNickname(e.target.value)} />
|
|
<button className="btn btn-primary" onClick={onCreate} disabled={createUser.isPending}>
|
|
Войти
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{devUsers && devUsers.length > 0 && (
|
|
<>
|
|
<div className="label">Или выберите существующего:</div>
|
|
<div className="stack">
|
|
{devUsers.map((u) => (
|
|
<button
|
|
key={u.id}
|
|
className="btn"
|
|
disabled={!u.is_active}
|
|
title={u.is_active ? undefined : "Аккаунт отключён администратором"}
|
|
style={u.is_active ? undefined : { opacity: 0.45, color: "var(--text-muted)" }}
|
|
onClick={() => doLogin(u.nickname)}
|
|
>
|
|
{u.nickname}
|
|
{!u.is_active && <span className="small"> · отключён</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|