Files
runners-calendar/frontend/src/pages/RaceDetailsPage.tsx
Vaka.pro e0ed0b6435
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
fix: прод — CORS, версия API, ошибки клиента и подсказка по прошедшим стартам
- CORS_ORIGIN: несколько origin через запятую; комментарии в .env.example
- Версия бэкенда: APP_VERSION, безопасное чтение package.json, футер при пустой версии
- Сообщения API: unknown_error и ответы 401/403/404 без JSON; отладочный лог при !ok
- Статус «внесите результат» для прошедшей даты + блок на карточке старта и стили
2026-04-08 01:21:11 +03:00

259 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ApiError, deleteRace, getRaceById } from "../api";
import {
formatDistance,
formatRaceDate,
getPaceLabel,
getRaceStatusClassName,
getRaceStatusLabel,
raceNeedsResultEntry,
} from "../lib";
import type { Race } from "../api";
function getErrorMessage(error: unknown): string {
if (error instanceof ApiError) {
return error.message;
}
return "Не удалось загрузить карточку старта.";
}
function DetailItem(props: { label: string; value: string | null | undefined }): JSX.Element | null {
const text = props.value?.trim();
if (!text) {
return null;
}
return (
<div className="race-details-meta__item">
<dt className="race-details-meta__key">{props.label}</dt>
<dd className="race-details-meta__value">{text}</dd>
</div>
);
}
function DetailLink(props: { label: string; url: string | null | undefined }): JSX.Element | null {
const href = props.url?.trim();
if (!href) {
return null;
}
return (
<div className="race-details-meta__item">
<dt className="race-details-meta__key">{props.label}</dt>
<dd className="race-details-meta__value">
<a href={href} target="_blank" rel="noopener noreferrer" className="race-details-meta__link">
{href}
</a>
</dd>
</div>
);
}
export function RaceDetailsPage(): JSX.Element {
const { raceId } = useParams<{ raceId: string }>();
const navigate = useNavigate();
const [race, setRace] = useState<Race | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
useEffect(() => {
const ac = new AbortController();
let isMounted = true;
async function loadRace(): Promise<void> {
if (!raceId) {
setErrorMessage("Не найден идентификатор старта.");
setIsLoading(false);
return;
}
try {
const item = await getRaceById(raceId, { signal: ac.signal });
if (!isMounted || ac.signal.aborted) {
return;
}
setRace(item);
setErrorMessage(null);
} catch (error) {
if (ac.signal.aborted || !isMounted) {
return;
}
setErrorMessage(getErrorMessage(error));
} finally {
if (isMounted && !ac.signal.aborted) {
setIsLoading(false);
}
}
}
void loadRace();
return () => {
isMounted = false;
ac.abort();
};
}, [raceId]);
const paceLabel = useMemo(() => {
if (!race || race.status !== "completed") {
return null;
}
return getPaceLabel(race.finishTime, race.distanceKm);
}, [race]);
const handleDelete = useCallback(async () => {
if (!raceId) {
return;
}
setIsDeleting(true);
try {
await deleteRace(raceId);
navigate("/races", { replace: true });
} catch (error) {
setErrorMessage(error instanceof ApiError ? error.message : "Не удалось удалить старт.");
setShowDeleteConfirm(false);
} finally {
setIsDeleting(false);
}
}, [raceId, navigate]);
if (isLoading) {
return (
<section className="page page--race-details" aria-busy="true">
<h1 className="page__title">Карточка старта</h1>
<p className="page__subtitle">Загружаем данные старта...</p>
</section>
);
}
if (errorMessage || !race) {
return (
<section className="page page--race-details" role="alert">
<h1 className="page__title">Карточка старта</h1>
<p className="page__subtitle page__subtitle--error">{errorMessage ?? "Старт не найден."}</p>
<Link className="page-link" to="/races">
Вернуться к списку стартов
</Link>
</section>
);
}
const isCompleted = race.status === "completed";
return (
<section className="page page--race-details">
<div className="race-details-header">
<div className="race-details-header__main">
<h1 className="page__title">{race.title}</h1>
<p className="page__subtitle">
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}
</p>
</div>
<span className={getRaceStatusClassName(race.status, race.date)}>{getRaceStatusLabel(race.status, race.date)}</span>
</div>
{raceNeedsResultEntry(race) ? (
<p className="race-details-past-hint" role="status">
Дата старта уже прошла {" "}
<Link className="race-details-past-hint__link" to={`/races/${race.id}/edit`}>
внесите результат или обновите статус
</Link>
.
</p>
) : null}
<div className="race-details-actions">
<Link className="btn btn--primary" to={`/races/${race.id}/edit`}>
Редактировать
</Link>
<button
className="btn btn--danger"
type="button"
onClick={() => setShowDeleteConfirm(true)}
>
Удалить
</button>
</div>
{showDeleteConfirm ? (
<div className="confirm-banner" role="alertdialog" aria-label="Подтверждение удаления">
<p className="confirm-banner__text">Удалить «{race.title}»? Это действие необратимо.</p>
<div className="confirm-banner__actions">
<button
className="btn btn--danger"
type="button"
disabled={isDeleting}
onClick={handleDelete}
>
{isDeleting ? "Удаляем…" : "Да, удалить"}
</button>
<button
className="btn btn--secondary"
type="button"
disabled={isDeleting}
onClick={() => setShowDeleteConfirm(false)}
>
Отмена
</button>
</div>
</div>
) : null}
<div className="race-details-grid">
<article className="race-details-card">
<h2 className="race-details-card__title">Основная информация</h2>
<dl className="race-details-meta">
<div className="race-details-meta__item">
<dt className="race-details-meta__key">Дата</dt>
<dd className="race-details-meta__value">{formatRaceDate(race.date)}</dd>
</div>
<div className="race-details-meta__item">
<dt className="race-details-meta__key">Дистанция</dt>
<dd className="race-details-meta__value">{formatDistance(race.distanceKm)}</dd>
</div>
<div className="race-details-meta__item">
<dt className="race-details-meta__key">Статус</dt>
<dd className="race-details-meta__value">{getRaceStatusLabel(race.status, race.date)}</dd>
</div>
<DetailLink label="Сайт организатора" url={race.officialUrl} />
<DetailItem label="Время старта" value={race.startTime} />
<DetailItem label="Расписание кластеров" value={race.clusterSchedule} />
<DetailItem label="Выдача номеров" value={race.bibPickup} />
<DetailItem label="Стартовый номер" value={race.bibNumber} />
</dl>
</article>
<article className="race-details-card">
<h2 className="race-details-card__title">Результаты</h2>
{isCompleted ? (
<dl className="race-details-meta">
<div className="race-details-meta__item">
<dt className="race-details-meta__key">Время</dt>
<dd className="race-details-meta__value">{race.finishTime ?? "время не указано"}</dd>
</div>
<div className="race-details-meta__item">
<dt className="race-details-meta__key">Темп</dt>
<dd className="race-details-meta__value">{paceLabel ?? "не удалось вычислить"}</dd>
</div>
<DetailItem label="Место" value={race.finishPlace} />
</dl>
) : (
<p className="race-details-card__empty">
Метрики появятся после завершения старта и ввода результата.
</p>
)}
</article>
</div>
<article className="race-details-card race-details-card--notes">
<h2 className="race-details-card__title">Заметки</h2>
<p className="race-details-card__notes">{race.notes?.trim() ? race.notes : "Заметок пока нет."}</p>
</article>
<Link className="page-link" to="/races">
Назад к календарю стартов
</Link>
</section>
);
}