feat: русский UI, версии в футере, даты и устойчивость загрузки API
Some checks failed
CI / build-and-test (pull_request) Has been cancelled

- API: дата старта всегда YYYY-MM-DD; фронт: parseRaceDate без двойного T00:00:00
- GET /health с version из package.json; Vite define __FRONTEND_VERSION__
- Футер с версиями клиента/сервера (BEM), сетка app-shell на три ряда
- AbortController для карточки старта; ретраи GET при 502–504 и понятные ошибки шлюза
- Русские подписи навигации/страниц, lang=ru, без английских фраз в интерфейсе
This commit is contained in:
Vaka.pro
2026-04-08 00:40:03 +03:00
parent fc995ed07d
commit 42ee36d0a2
22 changed files with 251 additions and 77 deletions

View File

@@ -42,7 +42,26 @@ function normalizeApiCode(value: string | undefined): ApiErrorCode {
return "unknown_error";
}
function isGatewayStatus(status: number): boolean {
return status === 502 || status === 503 || status === 504;
}
function hasStructuredApiError(payload: unknown): payload is ApiErrorPayload {
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
return false;
}
return typeof (payload as ApiErrorPayload).error === "string";
}
export function toApiError(status: number, payload: unknown): ApiError {
if (isGatewayStatus(status) && !hasStructuredApiError(payload)) {
return new ApiError({
code: "network_error",
status,
message: "Сервер временно недоступен. Попробуйте обновить страницу.",
});
}
const maybePayload = payload as ApiErrorPayload;
const code = normalizeApiCode(maybePayload?.error);
const details = Array.isArray(maybePayload?.details)

View File

@@ -0,0 +1,10 @@
import { requestJson } from "./http";
export type HealthResponse = {
status: string;
version: string;
};
export async function getHealth(init?: RequestInit): Promise<HealthResponse> {
return requestJson<HealthResponse>("/health", init);
}

View File

@@ -20,36 +20,68 @@ async function parseResponseBody(response: Response): Promise<unknown> {
}
}
export async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
try {
const response = await fetch(buildUrl(path), {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
const GATEWAY_RETRY_STATUSES = new Set([502, 503, 504]);
if (response.status === 204) {
return undefined as T;
}
const payload = await parseResponseBody(response);
if (!response.ok) {
throw toApiError(response.status, payload);
}
return payload as T;
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
throw new ApiError({
code: "network_error",
status: null,
message: "Не удалось связаться с сервером.",
});
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const method = (init?.method ?? "GET").toUpperCase();
const idempotent = method === "GET" || method === "HEAD";
const maxAttempts = idempotent ? 3 : 1;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const response = await fetch(buildUrl(path), {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
if (response.status === 204) {
return undefined as T;
}
const payload = await parseResponseBody(response);
if (!response.ok) {
const retryable = idempotent && GATEWAY_RETRY_STATUSES.has(response.status) && attempt < maxAttempts;
if (retryable) {
await delay(80 * attempt);
continue;
}
throw toApiError(response.status, payload);
}
return payload as T;
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
if (error instanceof DOMException && error.name === "AbortError") {
throw error;
}
const retryable = idempotent && attempt < maxAttempts;
if (retryable) {
await delay(80 * attempt);
continue;
}
throw new ApiError({
code: "network_error",
status: null,
message: "Не удалось связаться с сервером.",
});
}
}
throw new ApiError({
code: "network_error",
status: null,
message: "Не удалось связаться с сервером.",
});
}

View File

@@ -1,3 +1,5 @@
export type { CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
export { ApiError, getApiErrorMessage } from "./errors";
export type { HealthResponse } from "./health";
export { getHealth } from "./health";
export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races";

View File

@@ -77,8 +77,8 @@ function buildRacesQuery(query?: RacesQuery): string {
return serialized ? `?${serialized}` : "";
}
export async function getRaces(query?: RacesQuery): Promise<Race[]> {
const response = await requestJson<unknown[]>(`/races${buildRacesQuery(query)}`);
export async function getRaces(query?: RacesQuery, init?: RequestInit): Promise<Race[]> {
const response = await requestJson<unknown[]>(`/races${buildRacesQuery(query)}`, init);
if (!Array.isArray(response)) {
throw new ApiError({
code: "unknown_error",
@@ -90,8 +90,8 @@ export async function getRaces(query?: RacesQuery): Promise<Race[]> {
return response.map(normalizeRace);
}
export async function getRaceById(id: string): Promise<Race> {
return normalizeRace(await requestJson<unknown>(`/races/${id}`));
export async function getRaceById(id: string, init?: RequestInit): Promise<Race> {
return normalizeRace(await requestJson<unknown>(`/races/${id}`, init));
}
export async function createRace(payload: CreateRacePayload): Promise<Race> {