Replace plain inputs with PrimeReact AutoComplete and add i18n to all pages
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user