diff --git a/src/features/online-board/api.ts b/src/features/online-board/api.ts index 27b26f64..6234a877 100644 --- a/src/features/online-board/api.ts +++ b/src/features/online-board/api.ts @@ -66,6 +66,7 @@ export interface CalendarParams { export async function searchFlights( client: ApiClient, params: SearchFlightsParams, + signal?: AbortSignal, ): Promise { const query: Record = { dateFrom: params.dateFrom, @@ -78,7 +79,7 @@ export async function searchFlights( if (params.timeFrom) query["timeFrom"] = params.timeFrom; if (params.timeTo) query["timeTo"] = params.timeTo; - return client.get(`flights/v1.1/${client.locale}/board`, query); + return client.get(`flights/v1.1/${client.locale}/board`, query, signal); } /** diff --git a/src/features/online-board/components/OnlineBoardFilter.tsx b/src/features/online-board/components/OnlineBoardFilter.tsx index 3eb17e0f..a32c8d60 100644 --- a/src/features/online-board/components/OnlineBoardFilter.tsx +++ b/src/features/online-board/components/OnlineBoardFilter.tsx @@ -8,7 +8,7 @@ * @module */ -import { type FC, useState, useCallback, useEffect, useRef, type FormEvent } from "react"; +import { type FC, useState, useCallback, useEffect, useRef, type FormEvent, useMemo } from "react"; import { useNavigate } from "@modern-js/runtime/router"; import { useLocale } from "@/i18n/useLocale.js"; import { Calendar } from "primereact/calendar"; @@ -150,6 +150,23 @@ export const OnlineBoardFilter: FC = ({ const boardMinDate = useRef(getBoardMinDate()).current; const boardMaxDate = useRef(getBoardMaxDate()).current; + // §4.1.10 — submit button locked for 30 seconds after each search. + // Value is the timestamp when the lock expires (or 0 if unlocked). + // The 30-second constant is intentionally hardcoded (not configurable). + const [submitLockedUntil, setSubmitLockedUntil] = useState(0); + const [now, setNow] = useState(() => Date.now()); + // Tick every second while the lock is active so the disabled state + // updates reactively. + useEffect(() => { + if (submitLockedUntil === 0 || now >= submitLockedUntil) return; + const id = setTimeout(() => setNow(Date.now()), 1000); + return () => clearTimeout(id); + }, [submitLockedUntil, now]); + const isSubmitLocked = useMemo( + () => submitLockedUntil > 0 && now < submitLockedUntil, + [submitLockedUntil, now], + ); + // Swap the Calendar input's display text to "Сегодня" / "Завтра" per // TZ §4.1.9 Tables 11+12 — Angular ships this and the raw 'DD.MM.YYYY' // reads clinical in comparison. @@ -227,6 +244,7 @@ export const OnlineBoardFilter: FC = ({ const handleFlightSubmit = useCallback( (e: FormEvent) => { e.preventDefault(); + if (isSubmitLocked) return; const error = validateFlightNumber(flightNumber); setFlightNumberError(error); @@ -250,15 +268,20 @@ export const OnlineBoardFilter: FC = ({ searchExecuted: true, }); + // Lock submit for 30 seconds (§4.1.10 — hardcoded, not configurable) + setSubmitLockedUntil(Date.now() + 30_000); + setNow(Date.now()); + const url = buildOnlineBoardUrl({ type: "flight", carrier, flightNumber: num, date: dateParam }); void navigate(`/${locale}/${url}`); }, - [flightNumber, flightDate, navigate, locale], + [flightNumber, flightDate, navigate, locale, isSubmitLocked], ); const handleRouteSubmit = useCallback( (e: FormEvent) => { e.preventDefault(); + if (isSubmitLocked) return; // Mirrors Angular's OnlineBoardFilterService.toRoutePage + the // UrlBuilder.getRoutePageUrl switch: one-sided searches (only @@ -324,9 +347,12 @@ export const OnlineBoardFilter: FC = ({ }); url = buildOnlineBoardUrl({ type: "route", departure: depCode, arrival: arrCode, date: dateParam, ...timeExtras }); } + // Lock submit for 30 seconds (§4.1.10 — hardcoded, not configurable) + setSubmitLockedUntil(Date.now() + 30_000); + setNow(Date.now()); void navigate(`/${locale}/${url}`); }, - [routeDepartureCode, routeArrivalCode, routeDate, timeRange, navigate, locale], + [routeDepartureCode, routeArrivalCode, routeDate, timeRange, navigate, locale, isSubmitLocked], ); return ( @@ -452,6 +478,8 @@ export const OnlineBoardFilter: FC = ({ type="submit" className="search-button" data-testid="search-submit" + disabled={isSubmitLocked} + aria-disabled={isSubmitLocked} > {t("SHARED.SEARCH")} @@ -622,6 +650,8 @@ export const OnlineBoardFilter: FC = ({ type="submit" className="search-button" data-testid="search-submit" + disabled={isSubmitLocked} + aria-disabled={isSubmitLocked} > {t("SHARED.SEARCH")} diff --git a/src/features/online-board/components/OnlineBoardSearchPage.error.test.tsx b/src/features/online-board/components/OnlineBoardSearchPage.error.test.tsx new file mode 100644 index 00000000..d55fdec1 --- /dev/null +++ b/src/features/online-board/components/OnlineBoardSearchPage.error.test.tsx @@ -0,0 +1,150 @@ +/** + * Tests for per-status error messages in OnlineBoardSearchPage — TZ §4.1.10.1 / §4.1.12 + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { OnlineBoardSearchPageProps } from "./OnlineBoardSearchPage.js"; +import { ApiHttpError, ApiTimeoutError } from "@/shared/api/errors.js"; +import type { UseOnlineBoardResult } from "../hooks/useOnlineBoard.js"; + +// ── shared mocks ────────────────────────────────────────────────────────────── + +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "ru" }, + }), +})); + +vi.mock("@modern-js/runtime/router", () => ({ + useNavigate: () => vi.fn(), + useParams: () => ({ lang: "ru-ru" }), + Link: ({ children, ...props }: Record) => + {children as React.ReactNode}, +})); + +vi.mock("@/ui/layout/PageTabs.js", () => ({ + PageTabs: () =>
, +})); + +vi.mock("./OnlineBoardFilter.js", () => ({ + OnlineBoardFilter: () =>
, +})); + +vi.mock("@/shared/hooks/useSearchHistory.js", () => ({ + useSearchHistory: () => ({ items: [], add: vi.fn(), clear: vi.fn() }), +})); + +vi.mock("@/features/flights-map/hooks/useFeatureFlag.js", () => ({ + useFeatureFlag: () => false, +})); + +vi.mock("../hooks/useLiveBoardSearch.js", () => ({ + useLiveBoardSearch: (_params: unknown, initialFlights: unknown) => ({ + flights: initialFlights, + connectionStatus: "idle" as const, + }), +})); + +vi.mock("../hooks/useCalendarDays.js", () => ({ + useCalendarDays: () => ({ days: [], loading: false }), +})); + +vi.mock("@/shared/dictionaries/index.js", () => ({ + useDictionaries: () => ({ dictionaries: null, loading: false, error: null }), +})); + +// Hoisted mock for useOnlineBoard — overridden per test +const mockUseOnlineBoard = vi.fn<() => UseOnlineBoardResult>(); +vi.mock("../hooks/useOnlineBoard.js", () => ({ + useOnlineBoard: () => mockUseOnlineBoard(), +})); + +// ── fixtures ────────────────────────────────────────────────────────────────── + +const DEPARTURE_PARAMS: OnlineBoardSearchPageProps["params"] = { + type: "departure", + station: "SVO", + date: "20260115", +}; + +function noResult(): UseOnlineBoardResult { + return { + flights: [], + loading: false, + error: null, + refresh: vi.fn(), + cancel: vi.fn(), + }; +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +describe("OnlineBoardSearchPage — §4.1.10.1 per-status error messages", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows BOARD.ERROR-TIMEOUT for ApiTimeoutError", async () => { + // Dynamic import so the mock is already in place + const { OnlineBoardSearchPage } = await import("./OnlineBoardSearchPage.js"); + + mockUseOnlineBoard.mockReturnValue({ + ...noResult(), + error: new ApiTimeoutError(30_000), + }); + + render(); + expect(screen.getByTestId("search-error")).toBeTruthy(); + expect(screen.getByText("BOARD.ERROR-TIMEOUT")).toBeTruthy(); + }); + + it("shows BOARD.ERROR-5XX for ApiHttpError with status 5xx", async () => { + const { OnlineBoardSearchPage } = await import("./OnlineBoardSearchPage.js"); + + mockUseOnlineBoard.mockReturnValue({ + ...noResult(), + error: new ApiHttpError("Server error", 503), + }); + + render(); + expect(screen.getByText("BOARD.ERROR-5XX")).toBeTruthy(); + }); + + it("shows BOARD.ERROR-4XX for ApiHttpError with status 4xx", async () => { + const { OnlineBoardSearchPage } = await import("./OnlineBoardSearchPage.js"); + + mockUseOnlineBoard.mockReturnValue({ + ...noResult(), + error: new ApiHttpError("Not found", 404), + }); + + render(); + expect(screen.getByText("BOARD.ERROR-4XX")).toBeTruthy(); + }); + + it("shows cancel button while loading (§4.1.12)", async () => { + const { OnlineBoardSearchPage } = await import("./OnlineBoardSearchPage.js"); + + mockUseOnlineBoard.mockReturnValue({ + ...noResult(), + loading: true, + }); + + render(); + expect(screen.getByTestId("cancel-search-btn")).toBeTruthy(); + expect(screen.getByText("SHARED.SEARCH-CANCEL")).toBeTruthy(); + }); + + it("hides cancel button when not loading (§4.1.12)", async () => { + const { OnlineBoardSearchPage } = await import("./OnlineBoardSearchPage.js"); + + mockUseOnlineBoard.mockReturnValue(noResult()); + + render(); + expect(screen.queryByTestId("cancel-search-btn")).toBeNull(); + }); +}); diff --git a/src/features/online-board/components/OnlineBoardSearchPage.scss b/src/features/online-board/components/OnlineBoardSearchPage.scss index 30fc049d..df713faa 100644 --- a/src/features/online-board/components/OnlineBoardSearchPage.scss +++ b/src/features/online-board/components/OnlineBoardSearchPage.scss @@ -72,6 +72,41 @@ display: none; } + // §4.1.12 — loader bar with cancel button, shown while search is running + &__loader-bar { + display: flex; + justify-content: center; + padding: vars.$space-m vars.$space-xl; + } + + &__cancel-btn { + display: inline-block; + padding: vars.$space-s2 vars.$space-xl; + background-color: colors.$blue-light; + color: colors.$white; + border: none; + border-radius: vars.$border-radius; + cursor: pointer; + font-size: fonts.$font-size-m; + font-weight: fonts.$font-medium; + transition: background-color 0.2s ease; + + &:hover { + background-color: colors.$blue-light--hover; + } + } + + // §4.1.10 — while search is running, block interactions with filter, + // tabs, and breadcrumbs so the user cannot start a conflicting search. + &[data-searching="true"] { + .page-layout__left, + .page-layout__sticky, + .page-layout__breadcrumbs { + pointer-events: none; + opacity: 0.6; + } + } + // Mirrors Angular `page-footer-notes` under `pages/schedule/home.scss`: // a blue-extra-light card attached to the bottom of the flight-list // frame, with a small `*` sort-note aligned to an inner line holding diff --git a/src/features/online-board/components/OnlineBoardSearchPage.tsx b/src/features/online-board/components/OnlineBoardSearchPage.tsx index b61f2e71..8628e281 100644 --- a/src/features/online-board/components/OnlineBoardSearchPage.tsx +++ b/src/features/online-board/components/OnlineBoardSearchPage.tsx @@ -14,6 +14,7 @@ import type { FC } from "react"; import { useCallback, useEffect } from "react"; +import { ApiHttpError, ApiTimeoutError } from "@/shared/api/errors.js"; import { useNavigate } from "@modern-js/runtime/router"; import { useLocale } from "@/i18n/useLocale.js"; import { useTranslation } from "@/i18n/provider.js"; @@ -334,7 +335,17 @@ export const OnlineBoardSearchPage: FC = ({ // Data fetching const searchParams = toSearchParams(params); - const { flights, loading, error, refresh } = useOnlineBoard(searchParams); + const { flights, loading, error, refresh, cancel } = useOnlineBoard(searchParams); + + // §4.1.12 — Escape cancels while loader is showing + useEffect(() => { + if (!loading) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") cancel(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [loading, cancel]); // Live updates via SignalR const liveBoardParams = toLiveBoardParams(params); @@ -401,8 +412,23 @@ export const OnlineBoardSearchPage: FC = ({ ? buildFlightListJsonLd(displayFlights, searchDescription) : undefined; + // §4.1.10.1 — resolve per-status error message key + const errorMessageKey = (() => { + if (!error) return null; + if (error instanceof ApiTimeoutError) return "BOARD.ERROR-TIMEOUT"; + if (error instanceof ApiHttpError) { + return error.status >= 500 ? "BOARD.ERROR-5XX" : "BOARD.ERROR-4XX"; + } + return "BOARD.LOAD-FAILED-MESSAGE"; + })(); + return ( -
+
{jsonLd && } } @@ -491,7 +517,21 @@ export const OnlineBoardSearchPage: FC = ({ )}
- {/* Error state */} + {/* §4.1.12 — Cancel search button: visible while loading */} + {loading && ( +
+ +
+ )} + + {/* §4.1.10.1 — Error state with per-status message */} {error && (
@@ -499,7 +539,7 @@ export const OnlineBoardSearchPage: FC = ({ {t("BOARD.LOAD-FAILED-TITLE")}

- {t("BOARD.LOAD-FAILED-MESSAGE")} + {t(errorMessageKey ?? "BOARD.LOAD-FAILED-MESSAGE")}

diff --git a/src/features/schedule/components/ScheduleSearchPage.scss b/src/features/schedule/components/ScheduleSearchPage.scss index b7e96377..e9f3c57d 100644 --- a/src/features/schedule/components/ScheduleSearchPage.scss +++ b/src/features/schedule/components/ScheduleSearchPage.scss @@ -16,6 +16,40 @@ color: colors.$red; } + // §4.1.12 — loader bar with cancel button + &__loader-bar { + display: flex; + justify-content: center; + padding: vars.$space-m vars.$space-xl; + } + + &__cancel-btn { + display: inline-block; + padding: vars.$space-s2 vars.$space-xl; + background-color: colors.$blue-light; + color: colors.$white; + border: none; + border-radius: vars.$border-radius; + cursor: pointer; + font-size: fonts.$font-size-m; + font-weight: fonts.$font-medium; + transition: background-color 0.2s ease; + + &:hover { + background-color: colors.$blue-light--hover; + } + } + + // §4.1.11 — block filter/tabs/breadcrumbs while loading + &[data-searching="true"] { + .page-layout__left, + .page-layout__sticky, + .page-layout__breadcrumbs { + pointer-events: none; + opacity: 0.6; + } + } + &__outbound, &__inbound { padding: vars.$space-xl 0; diff --git a/src/features/schedule/components/ScheduleSearchPage.tsx b/src/features/schedule/components/ScheduleSearchPage.tsx index f5c2b56f..f838dd30 100644 --- a/src/features/schedule/components/ScheduleSearchPage.tsx +++ b/src/features/schedule/components/ScheduleSearchPage.tsx @@ -10,6 +10,7 @@ import type { FC } from "react"; import { useCallback, useEffect, useState } from "react"; +import { ApiHttpError, ApiTimeoutError } from "@/shared/api/errors.js"; import { useNavigate } from "@modern-js/runtime/router"; import { useLocale } from "@/i18n/useLocale.js"; import { useTranslation } from "@/i18n/provider.js"; @@ -263,7 +264,7 @@ export const ScheduleSearchPage: FC = ({ params }) => { // Fetch outbound flights const outboundRequest = toSearchRequest(outbound); - const { flights: outboundFlights, loading: outboundLoading, error: outboundError, refresh } = + const { flights: outboundFlights, loading: outboundLoading, error: outboundError, refresh, cancel: cancelOutbound } = useScheduleSearch(outboundRequest); // Fetch inbound flights (if round-trip) @@ -271,9 +272,36 @@ export const ScheduleSearchPage: FC = ({ params }) => { const { flights: inboundFlights, loading: inboundLoading, + cancel: cancelInbound, } = useScheduleSearch(inboundRequest); - const _loading = outboundLoading || (inbound ? inboundLoading : false); + const isLoading = outboundLoading || (inbound ? inboundLoading : false); + + // §4.1.12 — cancel both directions at once + const cancel = useCallback(() => { + cancelOutbound(); + cancelInbound(); + }, [cancelOutbound, cancelInbound]); + + // §4.1.12 — Escape cancels while loader is showing + useEffect(() => { + if (!isLoading) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") cancel(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isLoading, cancel]); + + // §4.1.11.1 — per-status error message key + const errorMessageKey = (() => { + if (!outboundError) return null; + if (outboundError instanceof ApiTimeoutError) return "BOARD.ERROR-TIMEOUT"; + if (outboundError instanceof ApiHttpError) { + return outboundError.status >= 500 ? "BOARD.ERROR-5XX" : "BOARD.ERROR-4XX"; + } + return "BOARD.LOAD-FAILED"; + })(); /** Navigate to a different week (Monday → Sunday range). Updates the * active direction's params and preserves the other direction. */ @@ -340,7 +368,12 @@ export const ScheduleSearchPage: FC = ({ params }) => { : undefined; return ( -
+
{jsonLd && } } @@ -435,10 +468,25 @@ export const ScheduleSearchPage: FC = ({ params }) => { } > + {/* §4.1.12 — cancel button visible while loading */} + {isLoading && ( +
+ +
+ )} + + {/* §4.1.11.1 — per-status error message */} {outboundError && ( -
+
-

{t("BOARD.LOAD-FAILED")}

+

{t(errorMessageKey ?? "BOARD.LOAD-FAILED")}

diff --git a/src/features/schedule/hooks/useScheduleSearch.ts b/src/features/schedule/hooks/useScheduleSearch.ts index 15cfab7e..718c7c22 100644 --- a/src/features/schedule/hooks/useScheduleSearch.ts +++ b/src/features/schedule/hooks/useScheduleSearch.ts @@ -2,6 +2,7 @@ * React hook for schedule search pages. * * Calls `searchSchedule` on param change, manages loading/error/data state. + * Supports AbortController-based cancellation (§4.1.12). * No SignalR -- schedule data is static. * * @module @@ -18,11 +19,13 @@ export interface UseScheduleSearchResult { loading: boolean; error: ApiError | null; refresh: () => void; + /** Cancel the in-flight request (§4.1.12). Resets loading to false. */ + cancel: () => void; } /** * Hook for schedule search pages. Fetches flights based on search params - * and provides refresh capability. + * and provides refresh + cancel capability. */ export function useScheduleSearch( params: IScheduleSearchRequest, @@ -36,31 +39,49 @@ export function useScheduleSearch( const paramsRef = useRef(params); paramsRef.current = params; + // AbortController for the current in-flight request (§4.1.12) + const abortRef = useRef(null); + const refresh = useCallback(() => { setRefreshKey((k) => k + 1); }, []); + const cancel = useCallback(() => { + if (abortRef.current) { + abortRef.current.abort(); + abortRef.current = null; + } + setLoading(false); + }, []); + useEffect(() => { - let cancelled = false; + // Abort any previous in-flight request (§4.1.12 — new search aborts in-flight) + if (abortRef.current) { + abortRef.current.abort(); + } + const controller = new AbortController(); + abortRef.current = controller; + setLoading(true); setError(null); - searchSchedule(client, paramsRef.current) + searchSchedule(client, paramsRef.current, controller.signal) .then((response) => { - if (!cancelled) { + if (!controller.signal.aborted) { setFlights(response); setLoading(false); } }) .catch((err: ApiError) => { - if (!cancelled) { - setError(err); - setLoading(false); - } + // Ignore aborted requests — the user cancelled intentionally + if (controller.signal.aborted) return; + setError(err); + setLoading(false); }); return () => { - cancelled = true; + controller.abort(); + abortRef.current = null; }; }, [ client, @@ -74,5 +95,5 @@ export function useScheduleSearch( refreshKey, ]); - return { flights, loading, error, refresh }; + return { flights, loading, error, refresh, cancel }; } diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 542bd41b..b70ef46a 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -44,6 +44,9 @@ "ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.", "FLIGHT-NOT-FOUND": "Flight not found.", "LOAD-FAILED": "Failed to load data. Please try again.", + "ERROR-TIMEOUT": "The server did not respond in time. Please try again.", + "ERROR-4XX": "Invalid search parameters. Please check your input and try again.", + "ERROR-5XX": "The server is temporarily unavailable. Please try again later.", "OPERATED-BY": "Operated by" }, "BOARDING-STATUSES": { diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 27c59774..d74ba65a 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -46,6 +46,9 @@ "LOAD-FAILED": "Failed to load data. Please try again.", "LOAD-FAILED-TITLE": "Failed to load data", "LOAD-FAILED-MESSAGE": "API server is unavailable. Check your connection and try again.", + "ERROR-TIMEOUT": "The server did not respond in time. Please try again.", + "ERROR-4XX": "Invalid search parameters. Please check your input and try again.", + "ERROR-5XX": "The server is temporarily unavailable. Please try again later.", "OPERATED-BY": "Operated by" }, "BREADCRUMBS": { diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 74cab158..84396df7 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -44,6 +44,9 @@ "ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.", "FLIGHT-NOT-FOUND": "Flight not found.", "LOAD-FAILED": "Failed to load data. Please try again.", + "ERROR-TIMEOUT": "The server did not respond in time. Please try again.", + "ERROR-4XX": "Invalid search parameters. Please check your input and try again.", + "ERROR-5XX": "The server is temporarily unavailable. Please try again later.", "OPERATED-BY": "Operated by" }, "BOARDING-STATUSES": { diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 1fbba4fe..db04583f 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -44,6 +44,9 @@ "ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.", "FLIGHT-NOT-FOUND": "Flight not found.", "LOAD-FAILED": "Failed to load data. Please try again.", + "ERROR-TIMEOUT": "The server did not respond in time. Please try again.", + "ERROR-4XX": "Invalid search parameters. Please check your input and try again.", + "ERROR-5XX": "The server is temporarily unavailable. Please try again later.", "OPERATED-BY": "Operated by" }, "BOARDING-STATUSES": { diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index fba7a1b6..6a67bef8 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -44,6 +44,9 @@ "ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.", "FLIGHT-NOT-FOUND": "Flight not found.", "LOAD-FAILED": "Failed to load data. Please try again.", + "ERROR-TIMEOUT": "The server did not respond in time. Please try again.", + "ERROR-4XX": "Invalid search parameters. Please check your input and try again.", + "ERROR-5XX": "The server is temporarily unavailable. Please try again later.", "OPERATED-BY": "Operated by" }, "BOARDING-STATUSES": { diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 685fa118..5f789bdd 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -44,6 +44,9 @@ "ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.", "FLIGHT-NOT-FOUND": "Flight not found.", "LOAD-FAILED": "Failed to load data. Please try again.", + "ERROR-TIMEOUT": "The server did not respond in time. Please try again.", + "ERROR-4XX": "Invalid search parameters. Please check your input and try again.", + "ERROR-5XX": "The server is temporarily unavailable. Please try again later.", "OPERATED-BY": "Operated by" }, "BOARDING-STATUSES": { diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 19d3928a..357c2c2a 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -44,6 +44,9 @@ "ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.", "FLIGHT-NOT-FOUND": "Flight not found.", "LOAD-FAILED": "Failed to load data. Please try again.", + "ERROR-TIMEOUT": "The server did not respond in time. Please try again.", + "ERROR-4XX": "Invalid search parameters. Please check your input and try again.", + "ERROR-5XX": "The server is temporarily unavailable. Please try again later.", "OPERATED-BY": "Operated by" }, "BOARDING-STATUSES": { diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 6b129826..c5509bbf 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -46,6 +46,9 @@ "LOAD-FAILED": "Не удалось загрузить данные. Попробуйте снова.", "LOAD-FAILED-TITLE": "Не удалось загрузить данные", "LOAD-FAILED-MESSAGE": "API сервер недоступен. Проверьте подключение и попробуйте снова.", + "ERROR-TIMEOUT": "Время ожидания ответа от сервера истекло. Попробуйте снова.", + "ERROR-4XX": "Неверные параметры поиска. Проверьте введённые данные и попробуйте снова.", + "ERROR-5XX": "Сервер временно недоступен. Пожалуйста, повторите попытку позже.", "OPERATED-BY": "Выполняет рейс" }, "BREADCRUMBS": { diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index e9cfea3c..126d25d2 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -44,6 +44,9 @@ "ESTIMATED-TIME-NOTE": "Arrival times and distances are estimated. Times may change depending on weather and airport load.", "FLIGHT-NOT-FOUND": "Flight not found.", "LOAD-FAILED": "Failed to load data. Please try again.", + "ERROR-TIMEOUT": "The server did not respond in time. Please try again.", + "ERROR-4XX": "Invalid search parameters. Please check your input and try again.", + "ERROR-5XX": "The server is temporarily unavailable. Please try again later.", "OPERATED-BY": "Operated by" }, "BOARDING-STATUSES": { diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index 05644416..76308804 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -58,18 +58,19 @@ export class ApiClient { async get( path: string, query?: Record, + signal?: AbortSignal, ): Promise { const url = this.buildUrl(path, query); - return this.executeWithRetry(url, { method: "GET" }); + return this.executeWithRetry(url, { method: "GET" }, signal); } - async post(path: string, body: unknown): Promise { + async post(path: string, body: unknown, signal?: AbortSignal): Promise { const url = this.buildUrl(path); return this.executeWithRetry(url, { method: "POST", body: JSON.stringify(body), headers: { "Content-Type": "application/json" }, - }); + }, signal); } private buildUrl( @@ -89,6 +90,7 @@ export class ApiClient { private async executeWithRetry( url: string, init: RequestInit, + externalSignal?: AbortSignal, ): Promise { const headers = new Headers(init.headers ?? {}); headers.set("Accept-Language", this.locale); @@ -106,11 +108,16 @@ export class ApiClient { } } + // Abort immediately if the external signal fired before we started. + if (externalSignal?.aborted) { + throw new ApiNetworkError(new Error("Aborted")); + } + try { const response = await this.fetchWithTimeout(url, { ...init, headers, - }); + }, externalSignal); if (response.ok) { return (await response.json()) as T; @@ -150,6 +157,11 @@ export class ApiClient { throw err; } if (err instanceof Error) { + // External cancellation — don't retry, re-throw as-is so the + // caller can distinguish "user cancelled" from "network error". + if (err.name === "AbortError" || (externalSignal?.aborted)) { + throw new ApiNetworkError(err); + } if (attempt < this.maxRetries) { lastError = new ApiNetworkError(err); continue; @@ -166,10 +178,19 @@ export class ApiClient { private async fetchWithTimeout( url: string, init: RequestInit, + externalSignal?: AbortSignal, ): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); + // Forward external cancellation (e.g. user clicks "Отменить поиск") + // to the internal controller so the fetch is actually aborted. + let externalListener: (() => void) | undefined; + if (externalSignal) { + externalListener = () => controller.abort(); + externalSignal.addEventListener("abort", externalListener); + } + try { return await this.fetchFn(url, { ...init, @@ -177,11 +198,18 @@ export class ApiClient { }); } catch (err) { if (err instanceof Error && err.name === "AbortError") { + // Distinguish user cancellation from timeout. + if (externalSignal?.aborted) { + throw err; // Let the caller handle it as a cancellation. + } throw new ApiTimeoutError(this.timeoutMs); } throw err; } finally { clearTimeout(timeoutId); + if (externalSignal && externalListener) { + externalSignal.removeEventListener("abort", externalListener); + } } }