From a072cd3bd2efe8a355663f9a2fa22d5e8e2b849a Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 09:26:42 +0300 Subject: [PATCH] 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. --- .../plans/2026-04-15-phase-3c-routes.md | 20 ++ .../components/ScheduleDetailsPage.tsx | 139 +++++++++++ .../components/ScheduleSearchPage.tsx | 165 +++++++++++++ .../schedule/components/ScheduleStartPage.tsx | 199 ++++++++++++++++ src/features/schedule/json-ld.ts | 49 ++++ src/features/schedule/seo.ts | 218 ++++++++++++++++++ src/features/schedule/types.ts | 4 +- .../[lang]/schedule/[...flights]/page.tsx | 79 +++++++ src/routes/[lang]/schedule/page.tsx | 37 +++ .../route/[params]/[returnParams]/page.tsx | 53 +++++ .../[lang]/schedule/route/[params]/page.tsx | 50 ++++ 11 files changed, 1011 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-15-phase-3c-routes.md create mode 100644 src/features/schedule/components/ScheduleDetailsPage.tsx create mode 100644 src/features/schedule/components/ScheduleSearchPage.tsx create mode 100644 src/features/schedule/components/ScheduleStartPage.tsx create mode 100644 src/features/schedule/json-ld.ts create mode 100644 src/features/schedule/seo.ts create mode 100644 src/routes/[lang]/schedule/[...flights]/page.tsx create mode 100644 src/routes/[lang]/schedule/page.tsx create mode 100644 src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx create mode 100644 src/routes/[lang]/schedule/route/[params]/page.tsx diff --git a/docs/superpowers/plans/2026-04-15-phase-3c-routes.md b/docs/superpowers/plans/2026-04-15-phase-3c-routes.md new file mode 100644 index 00000000..f1051c7e --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-phase-3c-routes.md @@ -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 +``` diff --git a/src/features/schedule/components/ScheduleDetailsPage.tsx b/src/features/schedule/components/ScheduleDetailsPage.tsx new file mode 100644 index 00000000..b8491156 --- /dev/null +++ b/src/features/schedule/components/ScheduleDetailsPage.tsx @@ -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 = ({ + 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 ; + } + + if (error) { + return ( +
+

Failed to load schedule details. Please try again.

+
+ ); + } + + if (flights.length === 0) { + return ( +
+

Schedule details not found.

+
+ ); + } + + // SEO + const seoProps = buildScheduleDetailsSeo(t, flights, locale, canonicalOrigin, flightIds); + + return ( +
+ + + {flights.map((flight) => { + const jsonLd = buildScheduleFlightJsonLd(flight); + const legs = getLegs(flight); + const flightNumber = `${flight.flightId.carrier} ${flight.flightId.flightNumber}`; + + return ( +
+ + +
+

{flightNumber}

+ {flight.status} +
+ + + + {legs.map((leg) => ( +
+
+ {leg.departure.scheduled.airportCode} + + {leg.arrival.scheduled.airportCode} +
+
+ {leg.departure.times.scheduledDeparture.localTime} + - + {leg.arrival.times.scheduledArrival.localTime} +
+ {leg.equipment.name && ( +
+ {leg.equipment.name} +
+ )} +
+ ))} +
+ ); + })} +
+ ); +}; diff --git a/src/features/schedule/components/ScheduleSearchPage.tsx b/src/features/schedule/components/ScheduleSearchPage.tsx new file mode 100644 index 00000000..a1b1b7ba --- /dev/null +++ b/src/features/schedule/components/ScheduleSearchPage.tsx @@ -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 = ({ 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 ( +
+ {jsonLd && } + + {/* Calendar strip */} + {calendarDays.length > 0 && ( +
+ {calendarDays.map((day) => ( + + ))} +
+ )} + + {/* Error state */} + {outboundError && ( +
+

Failed to load schedule. Please try again.

+ +
+ )} + + {/* Outbound flights */} +
+

Outbound: {outbound.departure} → {outbound.arrival}

+ +
+ + {/* Inbound flights (round-trip) */} + {inbound && ( +
+

Return: {inbound.departure} → {inbound.arrival}

+ +
+ )} +
+ ); +}; diff --git a/src/features/schedule/components/ScheduleStartPage.tsx b/src/features/schedule/components/ScheduleStartPage.tsx new file mode 100644 index 00000000..20d26ba4 --- /dev/null +++ b/src/features/schedule/components/ScheduleStartPage.tsx @@ -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 ( +
+

Flight Schedule

+ +
+
+ + setDepartureAirport(e.target.value)} + data-testid="departure-input" + /> +
+ +
+ + setArrivalAirport(e.target.value)} + data-testid="arrival-input" + /> +
+ +
+ + setDateFrom(e.target.value)} + data-testid="date-from-input" + /> +
+ +
+ + setDateTo(e.target.value)} + data-testid="date-to-input" + /> +
+ +
+ +
+ + {isRoundTrip && ( + <> +
+ + setReturnDateFrom(e.target.value)} + data-testid="return-date-from-input" + /> +
+ +
+ + setReturnDateTo(e.target.value)} + data-testid="return-date-to-input" + /> +
+ + )} + + +
+
+ ); +}; diff --git a/src/features/schedule/json-ld.ts b/src/features/schedule/json-ld.ts new file mode 100644 index 00000000..5b977efe --- /dev/null +++ b/src/features/schedule/json-ld.ts @@ -0,0 +1,49 @@ +/** + * JSON-LD schema builders for Schedule pages. + * + * Produces schema-dts typed objects ready for . + * 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, + }; +} diff --git a/src/features/schedule/seo.ts b/src/features/schedule/seo.ts new file mode 100644 index 00000000..bbc7871a --- /dev/null +++ b/src/features/schedule/seo.ts @@ -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 . + * + * 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 { + 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", + }), + }; +} diff --git a/src/features/schedule/types.ts b/src/features/schedule/types.ts index ded1db75..392132ed 100644 --- a/src/features/schedule/types.ts +++ b/src/features/schedule/types.ts @@ -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 diff --git a/src/routes/[lang]/schedule/[...flights]/page.tsx b/src/routes/[lang]/schedule/[...flights]/page.tsx new file mode 100644 index 00000000..371ad883 --- /dev/null +++ b/src/routes/[lang]/schedule/[...flights]/page.tsx @@ -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 ( +
+

Invalid schedule flight parameters.

+
+ ); + } + + return ( + }> + + + ); +} diff --git a/src/routes/[lang]/schedule/page.tsx b/src/routes/[lang]/schedule/page.tsx new file mode 100644 index 00000000..ebb57e7e --- /dev/null +++ b/src/routes/[lang]/schedule/page.tsx @@ -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 ( + <> + + Loading...}> + + + + ); +} diff --git a/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx b/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx new file mode 100644 index 00000000..b300936f --- /dev/null +++ b/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx @@ -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 ( +
+

Invalid round-trip schedule parameters.

+
+ ); + } + + const canonicalOrigin = getEnv().PROD_ORIGIN; + const scheduleParams = { type: "roundtrip" as const, outbound, inbound }; + const seoProps = buildScheduleSearchSeo(t, scheduleParams, locale, canonicalOrigin); + + return ( + <> + + }> + + + + ); +} diff --git a/src/routes/[lang]/schedule/route/[params]/page.tsx b/src/routes/[lang]/schedule/route/[params]/page.tsx new file mode 100644 index 00000000..41257e0c --- /dev/null +++ b/src/routes/[lang]/schedule/route/[params]/page.tsx @@ -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 ( +
+

Invalid schedule route parameters.

+
+ ); + } + + const canonicalOrigin = getEnv().PROD_ORIGIN; + const scheduleParams = { type: "route" as const, outbound: parsed }; + const seoProps = buildScheduleSearchSeo(t, scheduleParams, locale, canonicalOrigin); + + return ( + <> + + }> + + + + ); +}