Replace plain inputs with PrimeReact AutoComplete and add i18n to all pages
CI / ci (push) Failing after 39s
Deploy / build-and-deploy (push) Failing after 5s

OnlineBoardFilter and ScheduleStartPage city fields now use PrimeReact
AutoComplete for visual parity with Angular's PrimeNG city-autocomplete.
ScheduleStartPage labels switched from hardcoded English to i18n keys.
PopularRequestsPanel uses stable content-based keys instead of array index.
Error page loads i18n translations on mount and supports all locale strings.
This commit is contained in:
2026-04-15 20:38:46 +03:00
parent cbd47afd77
commit 130ce1f56b
4 changed files with 153 additions and 53 deletions
@@ -9,6 +9,7 @@
import { type FC, useState, useCallback, type FormEvent } from "react";
import { useNavigate, useParams } from "@modern-js/runtime/router";
import { Calendar } from "primereact/calendar";
import { AutoComplete, type AutoCompleteCompleteEvent } from "primereact/autocomplete";
import { useTranslation } from "@/i18n/provider.js";
import { buildOnlineBoardUrl } from "../url.js";
import "./OnlineBoardFilter.scss";
@@ -39,6 +40,20 @@ export const OnlineBoardFilter: FC = () => {
const [arrivalAirport, setArrivalAirport] = useState("");
const [routeDate, setRouteDate] = useState<Date>(new Date());
// AutoComplete suggestions (populated by API in future; empty for now)
const [departureSuggestions, setDepartureSuggestions] = useState<string[]>([]);
const [arrivalSuggestions, setArrivalSuggestions] = useState<string[]>([]);
const handleDepartureSearch = useCallback((_event: AutoCompleteCompleteEvent) => {
// TODO: call dictionary API to filter cities by query
setDepartureSuggestions([]);
}, []);
const handleArrivalSearch = useCallback((_event: AutoCompleteCompleteEvent) => {
// TODO: call dictionary API to filter cities by query
setArrivalSuggestions([]);
}, []);
const handleTabClick = useCallback(
(tab: FilterTab) => {
setSelectedTab(selectedTab === tab ? null : tab);
@@ -199,12 +214,14 @@ export const OnlineBoardFilter: FC = () => {
<label className="label--filter">
{t("SHARED.DEPARTURE_CITY")}
</label>
<input
type="text"
className="input--filter"
placeholder={t("SHARED.CITY_PLACEHOLDER")}
<AutoComplete
value={departureAirport}
onChange={(e) => setDepartureAirport(e.target.value)}
suggestions={departureSuggestions}
completeMethod={handleDepartureSearch}
onChange={(e) => setDepartureAirport(e.value as string)}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
className="input--filter"
inputClassName="input--filter"
data-testid="departure-airport-input"
/>
@@ -212,12 +229,14 @@ export const OnlineBoardFilter: FC = () => {
<label className="label--filter">
{t("SHARED.ARRIVAL_CITY")}
</label>
<input
type="text"
className="input--filter"
placeholder={t("SHARED.CITY_PLACEHOLDER")}
<AutoComplete
value={arrivalAirport}
onChange={(e) => setArrivalAirport(e.target.value)}
suggestions={arrivalSuggestions}
completeMethod={handleArrivalSearch}
onChange={(e) => setArrivalAirport(e.value as string)}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
className="input--filter"
inputClassName="input--filter"
data-testid="arrival-airport-input"
/>
</div>
@@ -15,6 +15,21 @@ import { PopularRequestItem } from "./PopularRequestItem.js";
import "./PopularRequestsPanel.scss";
import type { PopularRequest } from "../types.js";
/** Build a stable React key from a popular request's discriminated fields. */
function getRequestKey(request: PopularRequest): string {
switch (request.mode) {
case "FlightNumber":
return `fn-${request.carrier}-${request.flightNumber}`;
case "Arrival":
return `arr-${request.arrival}`;
case "Departure":
return `dep-${request.departure}`;
case "Route":
case "RouteWithBack":
return `${request.mode}-${request.departure}-${request.arrival}`;
}
}
export interface PopularRequestsPanelProps {
/** Callback invoked when a user clicks a popular request. The host page
* handles navigation based on the request mode and type. */
@@ -44,8 +59,8 @@ export function PopularRequestsPanel({
<h3 className="popular-requests__title">
{t("BOARD.POPULAR-CHAPTERS")}
</h3>
{visibleRequests.map((request, index) => (
<div key={index} className="popular-requests__item">
{visibleRequests.map((request) => (
<div key={getRequestKey(request)} className="popular-requests__item">
<PopularRequestItem
request={request}
onClick={onRequestClick}
@@ -10,6 +10,7 @@
import { type FC, useState, useCallback, type FormEvent } from "react";
import { useNavigate, useParams } from "@modern-js/runtime/router";
import { Calendar } from "primereact/calendar";
import { AutoComplete, type AutoCompleteCompleteEvent } from "primereact/autocomplete";
import { useTranslation } from "@/i18n/provider.js";
import { PageLayout } from "@/ui/layout/PageLayout.js";
import { PageTabs } from "@/ui/layout/PageTabs.js";
@@ -47,6 +48,18 @@ export const ScheduleStartPage: FC = () => {
const [returnDateFrom, setReturnDateFrom] = useState<Date>(addDays(today, 7));
const [returnDateTo, setReturnDateTo] = useState<Date>(addDays(today, 14));
// AutoComplete suggestions (populated by API in future; empty for now)
const [departureSuggestions, setDepartureSuggestions] = useState<string[]>([]);
const [arrivalSuggestions, setArrivalSuggestions] = useState<string[]>([]);
const handleDepartureSearch = useCallback((_event: AutoCompleteCompleteEvent) => {
setDepartureSuggestions([]);
}, []);
const handleArrivalSearch = useCallback((_event: AutoCompleteCompleteEvent) => {
setArrivalSuggestions([]);
}, []);
const handleSubmit = useCallback(
(e: FormEvent) => {
e.preventDefault();
@@ -94,33 +107,37 @@ export const ScheduleStartPage: FC = () => {
onSubmit={handleSubmit}
>
<div className="schedule-start__field">
<label htmlFor="schedule-departure">Departure</label>
<input
id="schedule-departure"
type="text"
placeholder="e.g. SVO"
maxLength={3}
<label htmlFor="schedule-departure">{t("SHARED.DEPARTURE_CITY")}</label>
<AutoComplete
value={departureAirport}
onChange={(e) => setDepartureAirport(e.target.value)}
suggestions={departureSuggestions}
completeMethod={handleDepartureSearch}
onChange={(e) => setDepartureAirport(e.value as string)}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
className="input--filter"
inputClassName="input--filter"
inputId="schedule-departure"
data-testid="departure-input"
/>
</div>
<div className="schedule-start__field">
<label htmlFor="schedule-arrival">Arrival</label>
<input
id="schedule-arrival"
type="text"
placeholder="e.g. LED"
maxLength={3}
<label htmlFor="schedule-arrival">{t("SHARED.ARRIVAL_CITY")}</label>
<AutoComplete
value={arrivalAirport}
onChange={(e) => setArrivalAirport(e.target.value)}
suggestions={arrivalSuggestions}
completeMethod={handleArrivalSearch}
onChange={(e) => setArrivalAirport(e.value as string)}
placeholder={t("SHARED.CITY_PLACEHOLDER")}
className="input--filter"
inputClassName="input--filter"
inputId="schedule-arrival"
data-testid="arrival-input"
/>
</div>
<div className="schedule-start__field">
<label htmlFor="schedule-date-from">Date from</label>
<label htmlFor="schedule-date-from">{t("SHARED.DEPARTURE_DATE")}</label>
<Calendar
value={dateFrom}
onChange={(e) => setDateFrom(e.value as Date)}
@@ -133,7 +150,7 @@ export const ScheduleStartPage: FC = () => {
</div>
<div className="schedule-start__field">
<label htmlFor="schedule-date-to">Date to</label>
<label htmlFor="schedule-date-to">{t("SHARED.ARRIVAL_DATE")}</label>
<Calendar
value={dateTo}
onChange={(e) => setDateTo(e.value as Date)}
@@ -153,14 +170,14 @@ export const ScheduleStartPage: FC = () => {
onChange={(e) => setIsRoundTrip(e.target.checked)}
data-testid="round-trip-toggle"
/>
Round trip
{t("SHARED.RETURN_FLIGHT_VIEW")}
</label>
</div>
{isRoundTrip && (
<>
<div className="schedule-start__field">
<label htmlFor="schedule-return-date-from">Return date from</label>
<label htmlFor="schedule-return-date-from">{t("SHARED.RETURN_FLIGHT_DATE")}</label>
<Calendar
value={returnDateFrom}
onChange={(e) => setReturnDateFrom(e.value as Date)}
@@ -173,7 +190,7 @@ export const ScheduleStartPage: FC = () => {
</div>
<div className="schedule-start__field">
<label htmlFor="schedule-return-date-to">Return date to</label>
<label htmlFor="schedule-return-date-to">{t("SHARED.RETURN_FLIGHT_TIME")}</label>
<Calendar
value={returnDateTo}
onChange={(e) => setReturnDateTo(e.value as Date)}
@@ -192,7 +209,7 @@ export const ScheduleStartPage: FC = () => {
className="schedule-start__submit"
data-testid="schedule-search-submit"
>
Search
{t("SHARED.SEARCH")}
</button>
</form>
);
+70 -21
View File
@@ -1,36 +1,85 @@
import { useState, useEffect } from "react";
import { useParams } from "@modern-js/runtime/router";
import { createI18nInstance } from "@/i18n/config.js";
import { resolveLocaleFromPath, type Language } from "@/i18n/resolver.js";
import "./page.scss";
const ERROR_CONFIG: Record<string, { title: string; description: string; image: string }> = {
interface ErrorConfig {
titleKey: string;
descriptionKey: string;
image: string;
buyTicketKey: string;
homeKey: string;
supportKey: string;
}
const ERROR_CONFIG: Record<string, ErrorConfig> = {
"404": {
title: "Страница не найдена",
description:
"Запрашиваемая страница не найдена или ссылка неверна.",
titleKey: "PAGE404.HEADER",
descriptionKey: "PAGE404.DESCRIPTION",
image: "/assets/img/lady404.png",
buyTicketKey: "PAGE404.BUY-TICKET",
homeKey: "PAGE404.TO-HOME",
supportKey: "PAGE404.SUPPORT",
},
"500": {
title: "Ошибка сервера",
description:
"При обработке запроса произошла внутренняя ошибка. Попробуйте позже.",
titleKey: "PAGE500.HEADER",
descriptionKey: "PAGE500.DESCRIPTION",
image: "/assets/img/lady500.png",
buyTicketKey: "PAGE500.BUY-TICKET",
homeKey: "PAGE500.TO-HOME",
supportKey: "PAGE500.SUPPORT",
},
"503": {
title: "Сервис недоступен",
description:
"Сервис временно недоступен. Попробуйте через несколько минут.",
titleKey: "PAGE500.HEADER",
descriptionKey: "PAGE500.DESCRIPTION",
image: "/assets/img/lady500.png",
buyTicketKey: "PAGE500.BUY-TICKET",
homeKey: "PAGE500.TO-HOME",
supportKey: "PAGE500.SUPPORT",
},
};
const FALLBACK = {
title: "Ошибка",
description: "Произошла непредвиденная ошибка.",
const FALLBACK_CONFIG: ErrorConfig = {
titleKey: "PAGE500.HEADER",
descriptionKey: "PAGE500.DESCRIPTION",
image: "/assets/img/lady500.png",
buyTicketKey: "PAGE500.BUY-TICKET",
homeKey: "PAGE500.TO-HOME",
supportKey: "PAGE500.SUPPORT",
};
export default function ErrorPage(): JSX.Element {
const { code } = useParams<{ code: string }>();
const config = (code ? ERROR_CONFIG[code] : undefined) ?? FALLBACK;
const config = (code ? ERROR_CONFIG[code] : undefined) ?? FALLBACK_CONFIG;
// Attempt to detect locale from referrer or default to "ru"
const [translations, setTranslations] = useState<Record<string, string> | null>(null);
useEffect(() => {
const pathname = typeof window !== "undefined" ? window.location.pathname : "";
const detected = resolveLocaleFromPath(pathname);
const locale: Language = detected ?? "ru";
void createI18nInstance({ locale }).then((i18n) => {
const t = (key: string) => i18n.t(key) as string;
setTranslations({
title: t(config.titleKey),
description: t(config.descriptionKey),
buyTicket: t(config.buyTicketKey),
home: t(config.homeKey),
support: t(config.supportKey),
});
});
}, [config]);
// Show content immediately (with hardcoded Russian fallback for SSR),
// then replace with i18n translations on the client
const title = translations?.title ?? config.titleKey;
const description = translations?.description ?? config.descriptionKey;
const buyTicket = translations?.buyTicket ?? "Купить билет";
const home = translations?.home ?? "На главную";
const support = translations?.support ?? "Поддержка";
return (
<div className="error-page">
@@ -41,26 +90,26 @@ export default function ErrorPage(): JSX.Element {
/>
<div className="error-page__content">
<div className="error-page__code">{code ?? "?"}</div>
<div className="error-page__title">{config.title}</div>
<div className="error-page__description">{config.description}</div>
<div className="error-page__title">{title}</div>
<div className="error-page__description">{description}</div>
<div className="error-page__actions">
<a
className="error-page__btn error-page__btn--primary"
href="https://www.aeroflot.ru/booking?from=404"
href={`https://www.aeroflot.ru/booking?from=${code ?? "500"}`}
>
Купить билет
{buyTicket}
</a>
<a
className="error-page__btn error-page__btn--secondary"
href="/"
>
На главную
{home}
</a>
<a
className="error-page__btn error-page__btn--link"
href="https://www.aeroflot.ru/help?from=404"
href={`https://www.aeroflot.ru/help?from=${code ?? "500"}`}
>
Поддержка
{support}
</a>
</div>
</div>