Add schedule route pages and feature components (Phase 3C)
Four Modern.js routes: start page, one-way search, round-trip search, and catch-all multi-flight details. Components wire hooks for data fetching and render flight results with calendar navigation.
This commit is contained in:
@@ -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>→</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} → {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} → {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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user