Rebuild schedule results page for Angular parity
Previously the /schedule/route results page rendered everything on the
dark-blue background and dumped the raw 382-char bitmask from the
/days endpoint straight into the DOM. Changes:
- Wrap the page in PageLayout with PageTabs, breadcrumb and an H1
matching Angular ('Маршрут: SVO - LED').
- Swap the inline calendar loop for the shared <DayTabs> component
(weekday + day + month labels, paging arrows).
- Replace the broken comma-split parser in getScheduleCalendarDays
with the same bitmask-to-dates conversion the board endpoint uses,
so the calendar now yields real yyyy-MM-dd strings.
- Frame the results in <section class='frame'> so they sit on a white
card (matches the board pages).
- Translate the 'Invalid …' parameter errors on every route page to
SHARED.INVALID-PARAMS ('Неверные параметры URL.') and wire t() into
the two route files that still lacked useTranslation.
This commit is contained in:
@@ -71,8 +71,9 @@ export async function getScheduleDetails(
|
||||
* Get available calendar days for a given route.
|
||||
* Maps to: `GET days/{date}/382/{param}/schedule/v1`
|
||||
*
|
||||
* The API returns `{ days: "2025-01-01,2025-01-02,..." }` -- a single
|
||||
* comma-separated string. This function splits it into `string[]`.
|
||||
* The API returns `{ days: "1111110001..." }` — a 382-char bitmask
|
||||
* where each character represents a day starting from (baseDate - 1).
|
||||
* '1' = available, '0' = no flights. Equivalent to the board endpoint.
|
||||
*/
|
||||
export async function getScheduleCalendarDays(
|
||||
client: ApiClient,
|
||||
@@ -85,14 +86,46 @@ export async function getScheduleCalendarDays(
|
||||
const path = `flights/v1/${client.locale}/days/${params.date}/382/${routeSegment}/schedule/`;
|
||||
|
||||
const response = await client.get<IScheduleDaysResponse>(path);
|
||||
return parseCalendarDays(response.days);
|
||||
return parseCalendarDays(response.days, params.date);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseCalendarDays(days: string): string[] {
|
||||
/**
|
||||
* Parse the /days response into an array of yyyy-MM-dd strings.
|
||||
*
|
||||
* Handles two shapes:
|
||||
* - legacy comma-separated ("2025-01-01,2025-01-02,…")
|
||||
* - bitmask ("1111000…") where each position maps to a day starting from
|
||||
* `baseDate - 1`. This is what the upstream actually returns today.
|
||||
*/
|
||||
function parseCalendarDays(days: string, baseDate: string): string[] {
|
||||
if (!days) return [];
|
||||
if (/^[01]+$/.test(days)) {
|
||||
return bitmaskToDates(days, baseDate);
|
||||
}
|
||||
return days.split(",").map((d) => d.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function bitmaskToDates(bitmask: string, baseDate: string): string[] {
|
||||
// baseDate is yyyy-MM-dd (possibly with Txx:xx:xx suffix).
|
||||
const iso = baseDate.includes("T") ? baseDate.split("T")[0]! : baseDate;
|
||||
const [y, m, d] = iso.split("-");
|
||||
if (!y || !m || !d) return [];
|
||||
const cursor = new Date(Number(y), Number(m) - 1, Number(d));
|
||||
cursor.setDate(cursor.getDate() - 1);
|
||||
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < bitmask.length; i++) {
|
||||
if (bitmask[i] === "1") {
|
||||
const yy = cursor.getFullYear().toString();
|
||||
const mm = (cursor.getMonth() + 1).toString().padStart(2, "0");
|
||||
const dd = cursor.getDate().toString().padStart(2, "0");
|
||||
result.push(`${yy}-${mm}-${dd}`);
|
||||
}
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import { useCallback } from "react";
|
||||
import { useNavigate, useParams } from "@modern-js/runtime/router";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import { FlightList } from "@/ui/flights/FlightList.js";
|
||||
import { PageLayout } from "@/ui/layout/PageLayout.js";
|
||||
import { PageTabs } from "@/ui/layout/PageTabs.js";
|
||||
import { DayTabs } from "@/features/online-board/components/DayTabs/index.js";
|
||||
import "./ScheduleSearchPage.scss";
|
||||
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
|
||||
import { useScheduleSearch } from "../hooks/useScheduleSearch.js";
|
||||
@@ -122,47 +125,64 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
|
||||
? buildScheduleFlightListJsonLd(outboundSimple, searchDescription)
|
||||
: undefined;
|
||||
|
||||
// DayTabs uses yyyymmdd; the calendar API returns yyyy-MM-dd. Normalize
|
||||
// once so onNavigate hands DayTabs-compatible strings back to us.
|
||||
const toYyyymmdd = (date: string): string =>
|
||||
date.includes("-") ? date.replace(/-/g, "") : date;
|
||||
const availableDates = calendarDays.map(toYyyymmdd);
|
||||
|
||||
return (
|
||||
<div className="schedule-search" data-testid="schedule-search">
|
||||
{jsonLd && <JsonLdRenderer data={jsonLd} />}
|
||||
<PageLayout
|
||||
headerLeft={<PageTabs viewType="schedule" />}
|
||||
title={
|
||||
<h1 className="text--white page-title">
|
||||
{t("BOARD.ROUTE-TEXT")}{outbound.departure} - {outbound.arrival}
|
||||
</h1>
|
||||
}
|
||||
breadcrumbs={[{ label: t("SCHEDULE.TITLE"), url: `/${lang}/schedule` }]}
|
||||
stickyContent={
|
||||
<DayTabs
|
||||
selectedDate={outbound.dateFrom}
|
||||
availableDates={availableDates}
|
||||
daysBefore={2}
|
||||
daysAfter={30}
|
||||
locale={lang}
|
||||
onNavigate={(yyyymmdd) => {
|
||||
const iso = `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
|
||||
handleDateChange(iso);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{outboundError && (
|
||||
<section className="frame" data-testid="search-error">
|
||||
<div className="schedule-search__error">
|
||||
<p>{t("BOARD.LOAD-FAILED")}</p>
|
||||
<button type="button" onClick={refresh}>{t("SHARED.RETRY")}</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Calendar strip */}
|
||||
{calendarDays.length > 0 && (
|
||||
<div className="schedule-search__calendar" data-testid="calendar-strip">
|
||||
{calendarDays.map((day) => (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
className="calendar-day"
|
||||
onClick={() => handleDateChange(day)}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<section className="frame">
|
||||
<div className="schedule-search__outbound" data-testid="outbound-results">
|
||||
<h2>
|
||||
{t("SCHEDULE.OUTBOUND")}: {outbound.departure} → {outbound.arrival}
|
||||
</h2>
|
||||
<FlightList flights={outboundSimple} loading={outboundLoading} />
|
||||
</div>
|
||||
|
||||
{/* Error state */}
|
||||
{outboundError && (
|
||||
<div className="schedule-search__error" data-testid="search-error">
|
||||
<p>{t("BOARD.LOAD-FAILED")}</p>
|
||||
<button type="button" onClick={refresh}>{t("SHARED.RETRY")}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outbound flights */}
|
||||
<div className="schedule-search__outbound" data-testid="outbound-results">
|
||||
<h2>{t("SCHEDULE.OUTBOUND")}: {outbound.departure} → {outbound.arrival}</h2>
|
||||
<FlightList flights={outboundSimple} loading={outboundLoading} />
|
||||
</div>
|
||||
|
||||
{/* Inbound flights (round-trip) */}
|
||||
{inbound && (
|
||||
<div className="schedule-search__inbound" data-testid="inbound-results">
|
||||
<h2>{t("SCHEDULE.RETURN")}: {inbound.departure} → {inbound.arrival}</h2>
|
||||
<FlightList flights={inboundSimple} loading={inboundLoading} />
|
||||
</div>
|
||||
)}
|
||||
{inbound && (
|
||||
<div className="schedule-search__inbound" data-testid="inbound-results">
|
||||
<h2>
|
||||
{t("SCHEDULE.RETURN")}: {inbound.departure} → {inbound.arrival}
|
||||
</h2>
|
||||
<FlightList flights={inboundSimple} loading={inboundLoading} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -377,7 +377,8 @@
|
||||
"RETRY": "Retry",
|
||||
"CONNECTION-LIVE": "Live",
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline"
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"INVALID-PARAMS": "Invalid URL parameters."
|
||||
},
|
||||
"WARNING": {
|
||||
"IFLY_HIGHLIGHT": "Bitte beachten Sie:",
|
||||
|
||||
@@ -404,7 +404,8 @@
|
||||
"RETRY": "Retry",
|
||||
"CONNECTION-LIVE": "Live",
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline"
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"INVALID-PARAMS": "Invalid URL parameters."
|
||||
},
|
||||
"WARNING": {
|
||||
"IFLY_HIGHLIGHT": "Please note:",
|
||||
|
||||
@@ -377,7 +377,8 @@
|
||||
"RETRY": "Retry",
|
||||
"CONNECTION-LIVE": "Live",
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline"
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"INVALID-PARAMS": "Invalid URL parameters."
|
||||
},
|
||||
"WARNING": {
|
||||
"IFLY_HIGHLIGHT": "Nota:",
|
||||
|
||||
@@ -377,7 +377,8 @@
|
||||
"RETRY": "Retry",
|
||||
"CONNECTION-LIVE": "Live",
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline"
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"INVALID-PARAMS": "Invalid URL parameters."
|
||||
},
|
||||
"WARNING": {
|
||||
"IFLY_HIGHLIGHT": "Remarque:",
|
||||
|
||||
@@ -377,7 +377,8 @@
|
||||
"RETRY": "Retry",
|
||||
"CONNECTION-LIVE": "Live",
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline"
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"INVALID-PARAMS": "Invalid URL parameters."
|
||||
},
|
||||
"WARNING": {
|
||||
"IFLY_HIGHLIGHT": "Attenzione:",
|
||||
|
||||
@@ -377,7 +377,8 @@
|
||||
"RETRY": "Retry",
|
||||
"CONNECTION-LIVE": "Live",
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline"
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"INVALID-PARAMS": "Invalid URL parameters."
|
||||
},
|
||||
"WARNING": {
|
||||
"IFLY_HIGHLIGHT": "ご注意:",
|
||||
|
||||
@@ -377,7 +377,8 @@
|
||||
"RETRY": "Retry",
|
||||
"CONNECTION-LIVE": "Live",
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline"
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"INVALID-PARAMS": "Invalid URL parameters."
|
||||
},
|
||||
"WARNING": {
|
||||
"IFLY_HIGHLIGHT": "참고:",
|
||||
|
||||
@@ -404,7 +404,8 @@
|
||||
"RETRY": "Повторить",
|
||||
"CONNECTION-LIVE": "Онлайн",
|
||||
"CONNECTION-RECONNECTING": "Соединение…",
|
||||
"CONNECTION-OFFLINE": "Нет связи"
|
||||
"CONNECTION-OFFLINE": "Нет связи",
|
||||
"INVALID-PARAMS": "Неверные параметры URL."
|
||||
},
|
||||
"SMOKE": {
|
||||
"HEADING": "Страница проверки"
|
||||
|
||||
@@ -377,7 +377,8 @@
|
||||
"RETRY": "Retry",
|
||||
"CONNECTION-LIVE": "Live",
|
||||
"CONNECTION-RECONNECTING": "Reconnecting…",
|
||||
"CONNECTION-OFFLINE": "Offline"
|
||||
"CONNECTION-OFFLINE": "Offline",
|
||||
"INVALID-PARAMS": "Invalid URL parameters."
|
||||
},
|
||||
"WARNING": {
|
||||
"IFLY_HIGHLIGHT": "请注意:",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useParams } from "@modern-js/runtime/router";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import { parseFlightUrlParams } from "@/features/online-board/url.js";
|
||||
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
|
||||
import { getEnv } from "@/env/index.js";
|
||||
@@ -20,6 +21,7 @@ const OnlineBoardDetailsPage = lazy(() =>
|
||||
);
|
||||
|
||||
export default function FlightDetailsPage(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const routeParams = useParams<{ params: string; lang: string }>();
|
||||
const raw = routeParams.params ?? "";
|
||||
const locale = routeParams.lang ?? "ru";
|
||||
@@ -28,7 +30,7 @@ export default function FlightDetailsPage(): JSX.Element {
|
||||
if (!parsed) {
|
||||
return (
|
||||
<div data-testid="invalid-params">
|
||||
<p>Invalid flight parameters.</p>
|
||||
<p>{t("SHARED.INVALID-PARAMS")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function ArrivalSearchPage(): JSX.Element {
|
||||
if (!parsed) {
|
||||
return (
|
||||
<div data-testid="invalid-params">
|
||||
<p>Invalid arrival parameters.</p>
|
||||
<p>{t("SHARED.INVALID-PARAMS")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function DepartureSearchPage(): JSX.Element {
|
||||
if (!parsed) {
|
||||
return (
|
||||
<div data-testid="invalid-params">
|
||||
<p>Invalid departure parameters.</p>
|
||||
<p>{t("SHARED.INVALID-PARAMS")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function FlightSearchPage(): JSX.Element {
|
||||
if (!parsed) {
|
||||
return (
|
||||
<div data-testid="invalid-params">
|
||||
<p>Invalid flight parameters.</p>
|
||||
<p>{t("SHARED.INVALID-PARAMS")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function RouteSearchPage(): JSX.Element {
|
||||
if (!parsed) {
|
||||
return (
|
||||
<div data-testid="invalid-params">
|
||||
<p>Invalid route parameters.</p>
|
||||
<p>{t("SHARED.INVALID-PARAMS")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useParams } from "@modern-js/runtime/router";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import { parseFlightUrlParams } from "@/features/online-board/url.js";
|
||||
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
|
||||
import { getEnv } from "@/env/index.js";
|
||||
@@ -49,6 +50,7 @@ function parseFlightSegments(segments: string[]): IScheduleFlightId[] {
|
||||
}
|
||||
|
||||
export default function ScheduleDetailsRoute(): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const routeParams = useParams<{ "*": string; lang: string }>();
|
||||
const locale = routeParams.lang ?? "ru";
|
||||
const canonicalOrigin = getEnv().PROD_ORIGIN;
|
||||
@@ -61,7 +63,7 @@ export default function ScheduleDetailsRoute(): JSX.Element {
|
||||
if (flights.length === 0) {
|
||||
return (
|
||||
<div data-testid="invalid-params">
|
||||
<p>Invalid schedule flight parameters.</p>
|
||||
<p>{t("SHARED.INVALID-PARAMS")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function ScheduleRoundTripSearchPage(): JSX.Element {
|
||||
if (!outbound || !inbound) {
|
||||
return (
|
||||
<div data-testid="invalid-params">
|
||||
<p>Invalid round-trip schedule parameters.</p>
|
||||
<p>{t("SHARED.INVALID-PARAMS")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function ScheduleRouteSearchPage(): JSX.Element {
|
||||
if (!parsed) {
|
||||
return (
|
||||
<div data-testid="invalid-params">
|
||||
<p>Invalid schedule route parameters.</p>
|
||||
<p>{t("SHARED.INVALID-PARAMS")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user