plan/react-rewrite #1

Merged
gnezim merged 138 commits from plan/react-rewrite into main 2026-04-15 12:21:16 +03:00
11 changed files with 1011 additions and 2 deletions
Showing only changes of commit a072cd3bd2 - Show all commits
@@ -0,0 +1,20 @@
# Phase 3C -- Schedule Route Pages
> **Parent:** `2026-04-15-phase-3-schedule-master.md`
> **Depends on:** 3A (URL), 3B (API/hooks)
## Deliverables
1. Schedule page components in `src/features/schedule/components/`
2. Modern.js route pages in `src/routes/[lang]/schedule/`
3. 4 routes: start, one-way, round-trip, catch-all details
## Route Structure
```
src/routes/[lang]/schedule/
page.tsx -- start page
route/[params]/page.tsx -- one-way search
route/[params]/[returnParams]/page.tsx -- round-trip search
[...flights]/page.tsx -- catch-all details
```
@@ -0,0 +1,139 @@
/**
* Schedule flight details page component.
*
* Receives parsed flight IDs from catch-all route, fetches details
* via useScheduleDetails, renders multi-leg flight info.
* No SignalR -- schedule data is static.
*
* @module
*/
import type { FC } from "react";
import { useTranslation } from "@/i18n/provider.js";
import { FlightCard } from "@/ui/flights/FlightCard.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { SeoHead } from "@/ui/seo/SeoHead.js";
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
import { useScheduleDetails } from "../hooks/useScheduleDetails.js";
import { buildScheduleDetailsSeo } from "../seo.js";
import { buildScheduleFlightJsonLd } from "../json-ld.js";
import type { IScheduleFlightId, IFlightLeg } from "../types.js";
export interface ScheduleDetailsPageProps {
/** Parsed flight identifiers from the catch-all URL */
flights: IScheduleFlightId[];
/** Current locale for SEO */
locale: string;
/** Canonical origin for SEO URLs */
canonicalOrigin: string;
}
/**
* Convert yyyyMMdd to yyyy-MM-dd for the API.
*/
function formatApiDate(yyyymmdd: string): string {
if (yyyymmdd.length !== 8) return yyyymmdd;
return `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
}
/**
* Extract legs from a flight (handles both Direct and MultiLeg).
*/
function getLegs(flight: { routeType: string; leg?: IFlightLeg; legs?: IFlightLeg[] }): IFlightLeg[] {
if (flight.routeType === "Direct" && "leg" in flight && flight.leg) {
return [flight.leg];
}
if ("legs" in flight && flight.legs) {
return flight.legs;
}
return [];
}
export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
flights: flightIds,
locale,
canonicalOrigin,
}) => {
const { t } = useTranslation();
// Build API params from flight IDs
const detailsParams = {
flights: flightIds.map(
(f) => `${f.carrier}${f.flightNumber}${f.suffix ?? ""}`,
),
dates: flightIds.map((f) => formatApiDate(f.date)),
// Use first flight's inferred departure/arrival as fallback
departure: "",
arrival: "",
};
const { flights, loading, error } = useScheduleDetails(detailsParams);
if (loading) {
return <FlightListSkeleton count={flightIds.length} />;
}
if (error) {
return (
<div className="schedule-details schedule-details--error" data-testid="schedule-details-error">
<p>Failed to load schedule details. Please try again.</p>
</div>
);
}
if (flights.length === 0) {
return (
<div className="schedule-details schedule-details--not-found" data-testid="schedule-details-not-found">
<p>Schedule details not found.</p>
</div>
);
}
// SEO
const seoProps = buildScheduleDetailsSeo(t, flights, locale, canonicalOrigin, flightIds);
return (
<div className="schedule-details" data-testid="schedule-details">
<SeoHead {...seoProps} />
{flights.map((flight) => {
const jsonLd = buildScheduleFlightJsonLd(flight);
const legs = getLegs(flight);
const flightNumber = `${flight.flightId.carrier} ${flight.flightId.flightNumber}`;
return (
<div key={flight.id} className="schedule-details__flight" data-testid={`flight-${flight.id}`}>
<JsonLdRenderer data={jsonLd} />
<div className="schedule-details__header">
<h2 className="schedule-details__flight-number">{flightNumber}</h2>
<span className="schedule-details__status">{flight.status}</span>
</div>
<FlightCard flight={flight} />
{legs.map((leg) => (
<div key={leg.index} className="schedule-details__leg" data-testid={`leg-${leg.index}`}>
<div className="schedule-details__leg-route">
<span>{leg.departure.scheduled.airportCode}</span>
<span>&rarr;</span>
<span>{leg.arrival.scheduled.airportCode}</span>
</div>
<div className="schedule-details__leg-times">
<span>{leg.departure.times.scheduledDeparture.localTime}</span>
<span> - </span>
<span>{leg.arrival.times.scheduledArrival.localTime}</span>
</div>
{leg.equipment.name && (
<div className="schedule-details__aircraft">
{leg.equipment.name}
</div>
)}
</div>
))}
</div>
);
})}
</div>
);
};
@@ -0,0 +1,165 @@
/**
* Schedule search results page for one-way and round-trip searches.
*
* Receives parsed route params, fetches flights via useScheduleSearch,
* renders results with calendar navigation.
* No SignalR -- schedule data is static.
*
* @module
*/
import type { FC } from "react";
import { useCallback } from "react";
import { useNavigate, useParams } from "@modern-js/runtime/router";
import { FlightList } from "@/ui/flights/FlightList.js";
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
import { useScheduleSearch } from "../hooks/useScheduleSearch.js";
import { useScheduleCalendar } from "../hooks/useScheduleCalendar.js";
import { buildScheduleUrl } from "../url.js";
import { buildScheduleFlightListJsonLd } from "../json-ld.js";
import type { ScheduleParams } from "../url.js";
import type { IScheduleSearchRequest, ISimpleFlight } from "../types.js";
import type { IScheduleRouteDirectionParams } from "../types.js";
export interface ScheduleSearchPageProps {
params: ScheduleParams & { type: "route" | "roundtrip" };
}
/**
* Convert parsed schedule params to API search params.
*/
function toSearchRequest(
direction: IScheduleRouteDirectionParams,
attribute?: 1 | 2,
): IScheduleSearchRequest {
const request: IScheduleSearchRequest = {
departure: direction.departure,
arrival: direction.arrival,
dateFrom: formatApiDate(direction.dateFrom),
dateTo: formatApiDate(direction.dateTo),
};
if (direction.timeFrom) request.timeFrom = direction.timeFrom;
if (direction.timeTo) request.timeTo = direction.timeTo;
if (direction.connections !== undefined) request.connections = direction.connections;
if (attribute) request.attribute = attribute;
return request;
}
/**
* Convert yyyyMMdd to yyyy-MM-dd for the API.
*/
function formatApiDate(yyyymmdd: string): string {
if (yyyymmdd.length !== 8) return yyyymmdd;
return `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
}
/**
* Extract simple flights from the mixed IFlight[] response for rendering.
*/
function extractSimpleFlights(flights: Array<{ routeType: string }>): ISimpleFlight[] {
return flights.filter(
(f): f is ISimpleFlight => f.routeType === "Direct" || f.routeType === "MultiLeg",
);
}
export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
const navigate = useNavigate();
const routeParams = useParams<{ lang: string }>();
const lang = routeParams.lang ?? "ru";
const outbound = params.outbound;
const inbound = params.type === "roundtrip" ? params.inbound : undefined;
// Fetch outbound flights
const outboundRequest = toSearchRequest(outbound);
const { flights: outboundFlights, loading: outboundLoading, error: outboundError, refresh } =
useScheduleSearch(outboundRequest);
// Fetch inbound flights (if round-trip)
const inboundRequest = inbound ? toSearchRequest(inbound, 2) : outboundRequest;
const {
flights: inboundFlights,
loading: inboundLoading,
} = useScheduleSearch(inboundRequest);
// Calendar
const calendarParams = {
date: formatApiDate(outbound.dateFrom),
departure: outbound.departure,
arrival: outbound.arrival,
connections: outbound.connections !== undefined && outbound.connections > 0,
};
const { days: calendarDays } = useScheduleCalendar(calendarParams);
const loading = outboundLoading || (inbound ? inboundLoading : false);
// Navigation: change date via calendar
const handleDateChange = useCallback(
(newDate: string) => {
// newDate is yyyy-MM-dd from calendar, convert to yyyyMMdd
const yyyymmdd = newDate.replace(/-/g, "");
const newOutbound = { ...outbound, dateFrom: yyyymmdd };
const newParams: ScheduleParams = inbound
? { type: "roundtrip", outbound: newOutbound, inbound }
: { type: "route", outbound: newOutbound };
const url = buildScheduleUrl(newParams);
void navigate(`/${lang}/${url}`);
},
[navigate, lang, outbound, inbound],
);
const outboundSimple = extractSimpleFlights(outboundFlights);
const inboundSimple = inbound ? extractSimpleFlights(inboundFlights) : [];
// JSON-LD
const searchDescription = `Schedule ${outbound.departure} to ${outbound.arrival}`;
const jsonLd = outboundSimple.length > 0
? buildScheduleFlightListJsonLd(outboundSimple, searchDescription)
: undefined;
return (
<div className="schedule-search" data-testid="schedule-search">
{jsonLd && <JsonLdRenderer data={jsonLd} />}
{/* 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>
)}
{/* Error state */}
{outboundError && (
<div className="schedule-search__error" data-testid="search-error">
<p>Failed to load schedule. Please try again.</p>
<button type="button" onClick={refresh}>Retry</button>
</div>
)}
{/* Outbound flights */}
<div className="schedule-search__outbound" data-testid="outbound-results">
<h2>Outbound: {outbound.departure} &rarr; {outbound.arrival}</h2>
<FlightList flights={outboundSimple} loading={outboundLoading} />
</div>
{/* Inbound flights (round-trip) */}
{inbound && (
<div className="schedule-search__inbound" data-testid="inbound-results">
<h2>Return: {inbound.departure} &rarr; {inbound.arrival}</h2>
<FlightList flights={inboundSimple} loading={inboundLoading} />
</div>
)}
</div>
);
};
@@ -0,0 +1,199 @@
/**
* Schedule start page -- search form for route-based schedule search.
*
* No API calls on load. Pure form that navigates to the appropriate
* search route on submit.
*
* @module
*/
import { type FC, useState, useCallback, type FormEvent } from "react";
import { useNavigate, useParams } from "@modern-js/runtime/router";
import { buildScheduleUrl } from "../url.js";
/**
* Format today's date as yyyyMMdd.
*/
function todayAsYyyymmdd(): string {
const now = new Date();
const y = now.getFullYear().toString();
const m = (now.getMonth() + 1).toString().padStart(2, "0");
const d = now.getDate().toString().padStart(2, "0");
return `${y}${m}${d}`;
}
function dateInputToYyyymmdd(value: string): string {
return value.replace(/-/g, "");
}
function yyyymmddToDateInput(value: string): string {
if (value.length !== 8) return "";
return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
}
function addDaysToDateInput(value: string, days: number): string {
const date = new Date(value);
date.setDate(date.getDate() + days);
const y = date.getFullYear().toString();
const m = (date.getMonth() + 1).toString().padStart(2, "0");
const d = date.getDate().toString().padStart(2, "0");
return `${y}-${m}-${d}`;
}
export const ScheduleStartPage: FC = () => {
const navigate = useNavigate();
const routeParams = useParams<{ lang: string }>();
const lang = routeParams.lang ?? "ru";
const today = yyyymmddToDateInput(todayAsYyyymmdd());
const [departureAirport, setDepartureAirport] = useState("");
const [arrivalAirport, setArrivalAirport] = useState("");
const [dateFrom, setDateFrom] = useState(today);
const [dateTo, setDateTo] = useState(addDaysToDateInput(today, 7));
const [isRoundTrip, setIsRoundTrip] = useState(false);
const [returnDateFrom, setReturnDateFrom] = useState(addDaysToDateInput(today, 7));
const [returnDateTo, setReturnDateTo] = useState(addDaysToDateInput(today, 14));
const handleSubmit = useCallback(
(e: FormEvent) => {
e.preventDefault();
const dep = departureAirport.trim().toUpperCase();
const arr = arrivalAirport.trim().toUpperCase();
if (!dep || !arr) return;
const dateFromParam = dateInputToYyyymmdd(dateFrom);
const dateToParam = dateInputToYyyymmdd(dateTo);
if (dateFromParam.length !== 8 || dateToParam.length !== 8) return;
let url: string;
if (isRoundTrip) {
const retDateFromParam = dateInputToYyyymmdd(returnDateFrom);
const retDateToParam = dateInputToYyyymmdd(returnDateTo);
if (retDateFromParam.length !== 8 || retDateToParam.length !== 8) return;
url = buildScheduleUrl({
type: "roundtrip",
outbound: { departure: dep, arrival: arr, dateFrom: dateFromParam, dateTo: dateToParam },
inbound: { departure: arr, arrival: dep, dateFrom: retDateFromParam, dateTo: retDateToParam },
});
} else {
url = buildScheduleUrl({
type: "route",
outbound: { departure: dep, arrival: arr, dateFrom: dateFromParam, dateTo: dateToParam },
});
}
void navigate(`/${lang}/${url}`);
},
[departureAirport, arrivalAirport, dateFrom, dateTo, isRoundTrip, returnDateFrom, returnDateTo, navigate, lang],
);
return (
<div className="schedule-start" data-testid="schedule-start">
<h1 className="schedule-start__title">Flight Schedule</h1>
<form
className="schedule-start__form"
data-testid="schedule-search-form"
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}
value={departureAirport}
onChange={(e) => setDepartureAirport(e.target.value)}
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}
value={arrivalAirport}
onChange={(e) => setArrivalAirport(e.target.value)}
data-testid="arrival-input"
/>
</div>
<div className="schedule-start__field">
<label htmlFor="schedule-date-from">Date from</label>
<input
id="schedule-date-from"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
data-testid="date-from-input"
/>
</div>
<div className="schedule-start__field">
<label htmlFor="schedule-date-to">Date to</label>
<input
id="schedule-date-to"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
data-testid="date-to-input"
/>
</div>
<div className="schedule-start__field">
<label>
<input
type="checkbox"
checked={isRoundTrip}
onChange={(e) => setIsRoundTrip(e.target.checked)}
data-testid="round-trip-toggle"
/>
Round trip
</label>
</div>
{isRoundTrip && (
<>
<div className="schedule-start__field">
<label htmlFor="schedule-return-date-from">Return date from</label>
<input
id="schedule-return-date-from"
type="date"
value={returnDateFrom}
onChange={(e) => setReturnDateFrom(e.target.value)}
data-testid="return-date-from-input"
/>
</div>
<div className="schedule-start__field">
<label htmlFor="schedule-return-date-to">Return date to</label>
<input
id="schedule-return-date-to"
type="date"
value={returnDateTo}
onChange={(e) => setReturnDateTo(e.target.value)}
data-testid="return-date-to-input"
/>
</div>
</>
)}
<button
type="submit"
className="schedule-start__submit"
data-testid="schedule-search-submit"
>
Search
</button>
</form>
</div>
);
};
+49
View File
@@ -0,0 +1,49 @@
/**
* JSON-LD schema builders for Schedule pages.
*
* Produces schema-dts typed objects ready for <JsonLdRenderer>.
* Uses schema.org Flight and ItemList types.
*
* Reuses online-board's buildFlightJsonLd for individual flight mapping,
* since the flight data model is shared.
*
* @module
*/
import type { Flight, ItemList, ListItem } from "schema-dts";
import { buildFlightJsonLd } from "../online-board/json-ld.js";
import type { ISimpleFlight } from "./types.js";
// ---------------------------------------------------------------------------
// Public JSON-LD builders
// ---------------------------------------------------------------------------
/**
* Build a schema.org Flight JSON-LD object for a schedule flight.
* Delegates to the shared online-board builder.
*/
export function buildScheduleFlightJsonLd(flight: ISimpleFlight): Flight {
return buildFlightJsonLd(flight);
}
/**
* Build a schema.org ItemList of Flight JSON-LD objects from schedule search results.
*
* Each flight becomes a ListItem with a position (1-indexed).
*/
export function buildScheduleFlightListJsonLd(
flights: ISimpleFlight[],
searchDescription: string,
): ItemList {
const items: ListItem[] = flights.map((flight, index) => ({
"@type": "ListItem" as const,
position: index + 1,
item: buildScheduleFlightJsonLd(flight),
}));
return {
"@type": "ItemList",
description: searchDescription,
itemListElement: items,
};
}
+218
View File
@@ -0,0 +1,218 @@
/**
* SEO builder functions for Schedule route pages.
*
* Each function is PURE -- all data arrives via parameters, no hooks or
* side effects. Returns a SeoHeadProps object ready for <SeoHead>.
*
* Translation keys follow the pattern:
* SEO.SCHEDULE.{MAIN,SEARCH,DETAILS}.{TITLE,DESCRIPTION}
*
* @module
*/
import type { SeoHeadProps } from "@/ui/seo/SeoHead.js";
import { buildHreflangSet } from "@/shared/seo/hreflang.js";
import { buildScheduleUrl } from "./url.js";
import type { ScheduleParams } from "./url.js";
import type { ISimpleFlight, IScheduleFlightId } from "./types.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type TFunction = (key: string, opts?: any) => string;
export interface CityNames {
departure?: string;
arrival?: string;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const OG_IMAGE = "https://www.aeroflot.ru/static/images/aeroflot-og-default.png";
const SITE_NAME = "Aeroflot";
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function formatDateForSeo(yyyymmdd: string): string {
if (yyyymmdd.length !== 8) return yyyymmdd;
const day = yyyymmdd.slice(6, 8);
const month = yyyymmdd.slice(4, 6);
const year = yyyymmdd.slice(0, 4);
return `${day}.${month}.${year}`;
}
function buildCanonical(
canonicalOrigin: string,
locale: string,
params: ScheduleParams,
): string {
const path = buildScheduleUrl(params);
return `${canonicalOrigin}/${locale}/${path}`;
}
function buildPathWithoutLocale(params: ScheduleParams): string {
const path = buildScheduleUrl(params);
return `/${path}`;
}
function buildCommonSeoProps(args: {
title: string;
description: string;
canonical: string;
hreflangPath: string;
canonicalOrigin: string;
locale: string;
ogType: "website" | "article";
}): Pick<SeoHeadProps, "og" | "twitter" | "hreflang"> {
return {
hreflang: buildHreflangSet({
canonicalOrigin: args.canonicalOrigin,
pathWithoutLocale: args.hreflangPath,
}),
og: {
title: args.title,
description: args.description,
url: args.canonical,
image: OG_IMAGE,
type: args.ogType,
locale: args.locale,
siteName: SITE_NAME,
},
twitter: {
card: "summary",
},
};
}
// ---------------------------------------------------------------------------
// Public SEO builder functions
// ---------------------------------------------------------------------------
/**
* SEO props for the Schedule start page.
*/
export function buildScheduleStartSeo(
t: TFunction,
locale: string,
canonicalOrigin: string,
): SeoHeadProps {
const params: ScheduleParams = { type: "start" };
const title = t("SEO.SCHEDULE.MAIN.TITLE");
const description = t("SEO.SCHEDULE.MAIN.DESCRIPTION");
const canonical = buildCanonical(canonicalOrigin, locale, params);
const hreflangPath = buildPathWithoutLocale(params);
return {
title,
description,
canonical,
...buildCommonSeoProps({
title,
description,
canonical,
hreflangPath,
canonicalOrigin,
locale,
ogType: "website",
}),
};
}
/**
* SEO props for schedule search results pages (one-way and round-trip).
*/
export function buildScheduleSearchSeo(
t: TFunction,
params: ScheduleParams & { type: "route" | "roundtrip" },
locale: string,
canonicalOrigin: string,
cityNames?: CityNames,
): SeoHeadProps {
const outbound = params.outbound;
const departureCity = cityNames?.departure ?? outbound.departure;
const arrivalCity = cityNames?.arrival ?? outbound.arrival;
const dateFromDisplay = formatDateForSeo(outbound.dateFrom);
const dateToDisplay = formatDateForSeo(outbound.dateTo);
const title = t("SEO.SCHEDULE.SEARCH.TITLE", {
departureCity,
arrivalCity,
dateFrom: dateFromDisplay,
dateTo: dateToDisplay,
});
const description = t("SEO.SCHEDULE.SEARCH.DESCRIPTION", {
departureCity,
arrivalCity,
dateFrom: dateFromDisplay,
dateTo: dateToDisplay,
});
const canonical = buildCanonical(canonicalOrigin, locale, params);
const hreflangPath = buildPathWithoutLocale(params);
return {
title,
description,
canonical,
...buildCommonSeoProps({
title,
description,
canonical,
hreflangPath,
canonicalOrigin,
locale,
ogType: "website",
}),
};
}
/**
* SEO props for schedule details page.
*/
export function buildScheduleDetailsSeo(
t: TFunction,
flights: ISimpleFlight[],
locale: string,
canonicalOrigin: string,
flightIds: IScheduleFlightId[],
): SeoHeadProps {
const flightDisplay = flightIds
.map((f) => `${f.carrier} ${f.flightNumber}${f.suffix ?? ""}`)
.join(", ");
const dateDisplay = flightIds[0]
? formatDateForSeo(flightIds[0].date)
: "";
const title = t("SEO.SCHEDULE.DETAILS.TITLE", {
flights: flightDisplay,
date: dateDisplay,
});
const description = t("SEO.SCHEDULE.DETAILS.DESCRIPTION", {
flights: flightDisplay,
date: dateDisplay,
});
const detailsParams: ScheduleParams = { type: "details", flights: flightIds };
const canonical = buildCanonical(canonicalOrigin, locale, detailsParams);
const hreflangPath = buildPathWithoutLocale(detailsParams);
return {
title,
description,
canonical,
...buildCommonSeoProps({
title,
description,
canonical,
hreflangPath,
canonicalOrigin,
locale,
ogType: "article",
}),
};
}
+2 -2
View File
@@ -7,10 +7,10 @@
* @module
*/
import type { IFlight, ISimpleFlight, IBoardResponse } from "../online-board/types.js";
import type { IFlight, ISimpleFlight, IBoardResponse, IFlightLeg } from "../online-board/types.js";
// Re-export shared flight types used by schedule
export type { IFlight, ISimpleFlight, IBoardResponse };
export type { IFlight, ISimpleFlight, IBoardResponse, IFlightLeg };
// ---------------------------------------------------------------------------
// URL parameter types
@@ -0,0 +1,79 @@
/**
* Schedule flight details catch-all route.
*
* Captures variable-length paths for multi-flight chains.
* URL: /{lang}/schedule/{flight1-date}[/{flight2-date}]...
* Also handles: /{lang}/schedule/{depCode}/{flight-date}/{arrCode}/...
*
* Modern.js catch-all route: [...flights] captures all remaining segments.
*/
import { lazy, Suspense } from "react";
import { useParams } from "@modern-js/runtime/router";
import { parseFlightUrlParams } from "@/features/online-board/url.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { getEnv } from "@/env/index.js";
import type { IScheduleFlightId } from "@/features/schedule/types.js";
const ScheduleDetailsPage = lazy(() =>
import("@/features/schedule/components/ScheduleDetailsPage.js").then(
(m) => ({ default: m.ScheduleDetailsPage }),
),
);
/**
* Parse the catch-all segments into flight IDs.
* Segments that are 3 chars or less are treated as airport codes and skipped.
*/
function parseFlightSegments(segments: string[]): IScheduleFlightId[] {
const flights: IScheduleFlightId[] = [];
for (const segment of segments) {
if (segment.length <= 3) continue;
const parsed = parseFlightUrlParams(segment);
if (parsed) {
const flight: IScheduleFlightId = {
carrier: parsed.carrier,
flightNumber: parsed.flightNumber,
date: parsed.date,
};
if (parsed.suffix !== undefined) {
flight.suffix = parsed.suffix;
}
flights.push(flight);
}
}
return flights;
}
export default function ScheduleDetailsRoute(): JSX.Element {
const routeParams = useParams<{ flights: string; lang: string }>();
const locale = routeParams.lang ?? "ru";
const canonicalOrigin = getEnv().PROD_ORIGIN;
// Modern.js catch-all provides the remaining path as a single string
// joined by "/". We split it back to segments.
const rawFlights = routeParams.flights ?? "";
const segments = rawFlights.split("/").filter(Boolean);
const flights = parseFlightSegments(segments);
if (flights.length === 0) {
return (
<div data-testid="invalid-params">
<p>Invalid schedule flight parameters.</p>
</div>
);
}
return (
<Suspense fallback={<FlightListSkeleton count={flights.length} />}>
<ScheduleDetailsPage
flights={flights}
locale={locale}
canonicalOrigin={canonicalOrigin}
/>
</Suspense>
);
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Schedule start page route.
*
* Renders the search form landing page. No API calls on load.
* URL: /{lang}/schedule
*/
import { lazy, Suspense } from "react";
import { useParams } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import { SeoHead } from "@/ui/seo/SeoHead.js";
import { buildScheduleStartSeo } from "@/features/schedule/seo.js";
import { getEnv } from "@/env/index.js";
const ScheduleStartPage = lazy(() =>
import("@/features/schedule/components/ScheduleStartPage.js").then(
(m) => ({ default: m.ScheduleStartPage }),
),
);
export default function SchedulePage(): JSX.Element {
const { t } = useTranslation();
const routeParams = useParams<{ lang: string }>();
const locale = routeParams.lang ?? "ru";
const canonicalOrigin = getEnv().PROD_ORIGIN;
const seoProps = buildScheduleStartSeo(t, locale, canonicalOrigin);
return (
<>
<SeoHead {...seoProps} />
<Suspense fallback={<div aria-busy="true">Loading...</div>}>
<ScheduleStartPage />
</Suspense>
</>
);
}
@@ -0,0 +1,53 @@
/**
* Schedule round-trip route search page.
*
* Parses outbound + return route direction params from URL.
* URL: /{lang}/schedule/route/{outboundParams}/{returnParams}
*/
import { lazy, Suspense } from "react";
import { useParams } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import { parseScheduleRouteParams } from "@/features/schedule/url.js";
import { buildScheduleSearchSeo } from "@/features/schedule/seo.js";
import { SeoHead } from "@/ui/seo/SeoHead.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { getEnv } from "@/env/index.js";
const ScheduleSearchPage = lazy(() =>
import("@/features/schedule/components/ScheduleSearchPage.js").then(
(m) => ({ default: m.ScheduleSearchPage }),
),
);
export default function ScheduleRoundTripSearchPage(): JSX.Element {
const { t } = useTranslation();
const routeParams = useParams<{ params: string; returnParams: string; lang: string }>();
const outboundRaw = routeParams.params ?? "";
const inboundRaw = routeParams.returnParams ?? "";
const locale = routeParams.lang ?? "ru";
const outbound = parseScheduleRouteParams(outboundRaw);
const inbound = parseScheduleRouteParams(inboundRaw);
if (!outbound || !inbound) {
return (
<div data-testid="invalid-params">
<p>Invalid round-trip schedule parameters.</p>
</div>
);
}
const canonicalOrigin = getEnv().PROD_ORIGIN;
const scheduleParams = { type: "roundtrip" as const, outbound, inbound };
const seoProps = buildScheduleSearchSeo(t, scheduleParams, locale, canonicalOrigin);
return (
<>
<SeoHead {...seoProps} />
<Suspense fallback={<FlightListSkeleton />}>
<ScheduleSearchPage params={scheduleParams} />
</Suspense>
</>
);
}
@@ -0,0 +1,50 @@
/**
* Schedule one-way route search page.
*
* Parses route direction params from URL, renders search page with SEO.
* URL: /{lang}/schedule/route/{dep}-{arr}-{dateFrom}-{dateTo}[-{timeRange}][-C{conn}]
*/
import { lazy, Suspense } from "react";
import { useParams } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import { parseScheduleRouteParams } from "@/features/schedule/url.js";
import { buildScheduleSearchSeo } from "@/features/schedule/seo.js";
import { SeoHead } from "@/ui/seo/SeoHead.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { getEnv } from "@/env/index.js";
const ScheduleSearchPage = lazy(() =>
import("@/features/schedule/components/ScheduleSearchPage.js").then(
(m) => ({ default: m.ScheduleSearchPage }),
),
);
export default function ScheduleRouteSearchPage(): JSX.Element {
const { t } = useTranslation();
const routeParams = useParams<{ params: string; lang: string }>();
const raw = routeParams.params ?? "";
const locale = routeParams.lang ?? "ru";
const parsed = parseScheduleRouteParams(raw);
if (!parsed) {
return (
<div data-testid="invalid-params">
<p>Invalid schedule route parameters.</p>
</div>
);
}
const canonicalOrigin = getEnv().PROD_ORIGIN;
const scheduleParams = { type: "route" as const, outbound: parsed };
const seoProps = buildScheduleSearchSeo(t, scheduleParams, locale, canonicalOrigin);
return (
<>
<SeoHead {...seoProps} />
<Suspense fallback={<FlightListSkeleton />}>
<ScheduleSearchPage params={scheduleParams} />
</Suspense>
</>
);
}