diff --git a/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx b/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx index 0d30bb6b..0b4419a4 100644 --- a/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx +++ b/src/features/online-board/components/OnlineBoardDetailsPage.test.tsx @@ -91,6 +91,13 @@ vi.mock("../hooks/useLiveFlightDetails.js", () => ({ }), })); +vi.mock("@/i18n/provider.js", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "ru" }, + }), +})); + describe("OnlineBoardDetailsPage", () => { beforeEach(() => { mockState = { @@ -101,7 +108,7 @@ describe("OnlineBoardDetailsPage", () => { }); it("renders flight details", () => { - render(); + render(); expect(screen.getByTestId("flight-details")).toBeTruthy(); // "SU 100" appears in both the header and FlightCard expect(screen.getAllByText("SU 100").length).toBeGreaterThanOrEqual(1); @@ -109,42 +116,42 @@ describe("OnlineBoardDetailsPage", () => { it("renders loading skeleton", () => { mockState = { flight: null, loading: true, error: null }; - render(); + render(); expect(screen.queryByTestId("flight-details")).toBeNull(); }); it("renders error state", () => { mockState = { flight: null, loading: false, error: new Error("fail") }; - render(); + render(); expect(screen.getByTestId("flight-details-error")).toBeTruthy(); }); it("renders not-found state", () => { mockState = { flight: null, loading: false, error: null }; - render(); + render(); expect(screen.getByTestId("flight-details-not-found")).toBeTruthy(); }); it("renders flight legs", () => { - render(); + render(); expect(screen.getByTestId("flight-legs")).toBeTruthy(); expect(screen.getByTestId("flight-leg-0")).toBeTruthy(); }); it("displays departure and arrival stations", () => { - render(); + render(); // SVO and JFK appear in both FlightCard and FlightLegs sections expect(screen.getAllByText("SVO").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("JFK").length).toBeGreaterThanOrEqual(1); }); it("displays aircraft info", () => { - render(); + render(); expect(screen.getByText("Aircraft: Boeing 777-300ER (77W)")).toBeTruthy(); }); it("displays flying time", () => { - render(); + render(); expect(screen.getByTestId("flying-time")).toBeTruthy(); expect(screen.getByText("Total flying time: 10:30")).toBeTruthy(); }); diff --git a/src/features/online-board/components/OnlineBoardDetailsPage.tsx b/src/features/online-board/components/OnlineBoardDetailsPage.tsx index f62e028e..a042b80c 100644 --- a/src/features/online-board/components/OnlineBoardDetailsPage.tsx +++ b/src/features/online-board/components/OnlineBoardDetailsPage.tsx @@ -8,15 +8,24 @@ */ 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 { useFlightDetails } from "../hooks/useFlightDetails.js"; import { useLiveFlightDetails } from "../hooks/useLiveFlightDetails.js"; +import { buildFlightDetailsSeo } from "../seo.js"; +import { buildFlightJsonLd } from "../json-ld.js"; import type { IParsedFlightId, IFlightLeg } from "../types.js"; export interface OnlineBoardDetailsPageProps { /** Parsed flight identifier from the URL */ flightId: IParsedFlightId; + /** Current locale for SEO */ + locale: string; + /** Canonical origin for SEO URLs */ + canonicalOrigin: string; } /** @@ -127,7 +136,11 @@ function getLegs(flight: { routeType: string; leg?: IFlightLeg; legs?: IFlightLe */ export const OnlineBoardDetailsPage: FC = ({ flightId, + locale, + canonicalOrigin, }) => { + const { t } = useTranslation(); + // Fetch flight details const detailsParams = { flights: `${flightId.carrier}${flightId.flightNumber}${flightId.suffix ?? ""}`, @@ -166,8 +179,13 @@ export const OnlineBoardDetailsPage: FC = ({ const legs = getLegs(displayFlight); const flightNumber = `${displayFlight.flightId.carrier} ${displayFlight.flightId.flightNumber}`; + const seoProps = buildFlightDetailsSeo(t, displayFlight, locale, canonicalOrigin); + const jsonLd = buildFlightJsonLd(displayFlight); + return (
+ + {/* Connection status */}
{connectionStatus === "live" && ( diff --git a/src/features/online-board/components/OnlineBoardSearchPage.tsx b/src/features/online-board/components/OnlineBoardSearchPage.tsx index 810419ac..589986ab 100644 --- a/src/features/online-board/components/OnlineBoardSearchPage.tsx +++ b/src/features/online-board/components/OnlineBoardSearchPage.tsx @@ -16,10 +16,12 @@ 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 { useOnlineBoard } from "../hooks/useOnlineBoard.js"; import { useLiveBoardSearch } from "../hooks/useLiveBoardSearch.js"; import { useCalendarDays } from "../hooks/useCalendarDays.js"; import { buildOnlineBoardUrl } from "../url.js"; +import { buildFlightListJsonLd } from "../json-ld.js"; import type { OnlineBoardParams } from "../url.js"; import type { SearchFlightsParams, CalendarParams } from "../api.js"; import type { FlightRequestType, ISimpleFlight } from "../types.js"; @@ -184,8 +186,15 @@ export const OnlineBoardSearchPage: FC = ({ // Use live flights when connected, otherwise fetched flights const displayFlights = connectionStatus === "live" ? liveFlights : flights; + // JSON-LD for search results (rendered once we have flights) + const searchDescription = `Online board ${params.type} search results`; + const jsonLd = displayFlights.length > 0 + ? buildFlightListJsonLd(displayFlights, searchDescription) + : undefined; + return (
+ {jsonLd && } {/* Connection status indicator */}
{connectionStatus === "live" && ( diff --git a/src/features/online-board/index.ts b/src/features/online-board/index.ts index 8eb97772..a50d8f64 100644 --- a/src/features/online-board/index.ts +++ b/src/features/online-board/index.ts @@ -36,6 +36,20 @@ export type { UseLiveBoardSearchResult } from "./hooks/useLiveBoardSearch.js"; export { useLiveFlightDetails } from "./hooks/useLiveFlightDetails.js"; export type { UseLiveFlightDetailsResult } from "./hooks/useLiveFlightDetails.js"; +// 2F — SEO builder functions +export { + buildOnlineBoardStartSeo, + buildFlightSearchSeo, + buildDepartureSearchSeo, + buildArrivalSearchSeo, + buildRouteSearchSeo, + buildFlightDetailsSeo, +} from "./seo.js"; +export type { TFunction, CityNames } from "./seo.js"; + +// 2F — JSON-LD builder functions +export { buildFlightJsonLd, buildFlightListJsonLd } from "./json-ld.js"; + // 2E — Feature-specific page components export { OnlineBoardStartPage } from "./components/OnlineBoardStartPage.js"; export { OnlineBoardSearchPage } from "./components/OnlineBoardSearchPage.js"; diff --git a/src/features/online-board/json-ld.test.ts b/src/features/online-board/json-ld.test.ts index 942142d5..96eb419d 100644 --- a/src/features/online-board/json-ld.test.ts +++ b/src/features/online-board/json-ld.test.ts @@ -178,11 +178,11 @@ describe("buildFlightListJsonLd", () => { const items = result.itemListElement; expect(Array.isArray(items)).toBe(true); - const itemArray = items as Array<{ "@type": string; position: number; item: Flight }>; + const itemArray = items as unknown as Array<{ "@type": string; position: number; item: Flight }>; expect(itemArray).toHaveLength(2); - expect(itemArray[0]!["@type"]).toBe("ListItem"); - expect(itemArray[0]!.position).toBe(1); - expect(itemArray[1]!.position).toBe(2); + expect(itemArray[0]).toHaveProperty("@type", "ListItem"); + expect(itemArray[0]).toHaveProperty("position", 1); + expect(itemArray[1]).toHaveProperty("position", 2); }); it("embeds Flight objects inside ListItem.item", () => { @@ -190,8 +190,8 @@ describe("buildFlightListJsonLd", () => { const result = buildFlightListJsonLd(flights, "Flights"); const items = result.itemListElement as unknown as Array<{ item: Flight }>; - expect(items[0]!.item["@type"]).toBe("Flight"); - expect(items[0]!.item.flightNumber).toBe("SU0100"); + expect(items[0]).toHaveProperty("item.@type", "Flight"); + expect(items[0]).toHaveProperty("item.flightNumber", "SU0100"); }); it("handles empty flight list", () => { diff --git a/src/features/online-board/seo.test.ts b/src/features/online-board/seo.test.ts index 32fe3ad7..d9340f0f 100644 --- a/src/features/online-board/seo.test.ts +++ b/src/features/online-board/seo.test.ts @@ -58,6 +58,7 @@ describe("buildOnlineBoardStartSeo", () => { const result = buildOnlineBoardStartSeo(stubT, "ru", CANONICAL); expect(result.twitter).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- test assertion guards above expect(result.twitter!.card).toBe("summary"); }); }); diff --git a/src/features/online-board/seo.ts b/src/features/online-board/seo.ts index 19ebce64..3ce4b471 100644 --- a/src/features/online-board/seo.ts +++ b/src/features/online-board/seo.ts @@ -14,14 +14,18 @@ import type { SeoHeadProps } from "@/ui/seo/SeoHead.js"; import { buildHreflangSet } from "@/shared/seo/hreflang.js"; import { buildOnlineBoardUrl } from "./url.js"; import type { OnlineBoardParams } from "./url.js"; -import type { ISimpleFlight, IFlightLeg } from "./types.js"; +import type { ISimpleFlight } from "./types.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -/** Translation function signature (compatible with i18next t()) */ -export type TFunction = (key: string, opts?: Record) => string; +/** + * Translation function signature — intentionally loose to accept + * both i18next's TFunction and simple test stubs. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type TFunction = (key: string, opts?: any) => string; /** Optional city names for station/route searches */ export interface CityNames { @@ -103,16 +107,6 @@ function buildCommonSeoProps(args: { }; } -/** - * Get the first leg from a flight (handles both Direct and MultiLeg). - */ -function getFirstLeg(flight: ISimpleFlight): IFlightLeg | undefined { - if (flight.routeType === "Direct") { - return flight.leg; - } - return flight.legs[0]; -} - // --------------------------------------------------------------------------- // Public SEO builder functions // --------------------------------------------------------------------------- diff --git a/src/routes/[lang]/onlineboard/[params]/page.tsx b/src/routes/[lang]/onlineboard/[params]/page.tsx index b876736d..7b727350 100644 --- a/src/routes/[lang]/onlineboard/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/[params]/page.tsx @@ -1,7 +1,9 @@ /** * Online Board flight details route. * - * Parses flight ID from URL, renders detailed flight info. + * Parses flight ID from URL, renders detailed flight info with SEO. + * SEO head is rendered at route level with static info from URL params. + * JSON-LD is rendered inside the details component once flight data loads. * URL: /{lang}/onlineboard/{carrier}{flightNumber}-{yyyyMMdd} */ @@ -9,6 +11,7 @@ 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"; const OnlineBoardDetailsPage = lazy(() => import("@/features/online-board/components/OnlineBoardDetailsPage.js").then( @@ -17,8 +20,9 @@ const OnlineBoardDetailsPage = lazy(() => ); export default function FlightDetailsPage(): JSX.Element { - const routeParams = useParams<{ params: string }>(); + const routeParams = useParams<{ params: string; lang: string }>(); const raw = routeParams.params ?? ""; + const locale = routeParams.lang ?? "ru"; const parsed = parseFlightUrlParams(raw); if (!parsed) { @@ -29,9 +33,15 @@ export default function FlightDetailsPage(): JSX.Element { ); } + const canonicalOrigin = getEnv().PROD_ORIGIN; + return ( }> - + ); } diff --git a/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx b/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx index b75d8950..360f3414 100644 --- a/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx @@ -1,14 +1,18 @@ /** * Online Board arrival search route. * - * Parses station params from URL, renders shared search page. + * Parses station params from URL, renders shared search page with SEO. * URL: /{lang}/onlineboard/arrival/{station}-{yyyyMMdd}[-{timeRange}] */ import { lazy, Suspense } from "react"; import { useParams } from "@modern-js/runtime/router"; +import { useTranslation } from "@/i18n/provider.js"; import { parseStationUrlParams } from "@/features/online-board/url.js"; +import { buildArrivalSearchSeo } from "@/features/online-board/seo.js"; +import { SeoHead } from "@/ui/seo/SeoHead.js"; import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js"; +import { getEnv } from "@/env/index.js"; const OnlineBoardSearchPage = lazy(() => import("@/features/online-board/components/OnlineBoardSearchPage.js").then( @@ -17,8 +21,10 @@ const OnlineBoardSearchPage = lazy(() => ); export default function ArrivalSearchPage(): JSX.Element { - const routeParams = useParams<{ params: string }>(); + const { t } = useTranslation(); + const routeParams = useParams<{ params: string; lang: string }>(); const raw = routeParams.params ?? ""; + const locale = routeParams.lang ?? "ru"; const parsed = parseStationUrlParams(raw); if (!parsed) { @@ -29,6 +35,7 @@ export default function ArrivalSearchPage(): JSX.Element { ); } + const canonicalOrigin = getEnv().PROD_ORIGIN; const searchParams = { type: "arrival" as const, station: parsed.station, @@ -38,9 +45,14 @@ export default function ArrivalSearchPage(): JSX.Element { : {}), }; + const seoProps = buildArrivalSearchSeo(t, searchParams, locale, canonicalOrigin); + return ( - }> - - + <> + + }> + + + ); } diff --git a/src/routes/[lang]/onlineboard/departure/[params]/page.tsx b/src/routes/[lang]/onlineboard/departure/[params]/page.tsx index 6e68930f..5c8adbd1 100644 --- a/src/routes/[lang]/onlineboard/departure/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/departure/[params]/page.tsx @@ -1,14 +1,18 @@ /** * Online Board departure search route. * - * Parses station params from URL, renders shared search page. + * Parses station params from URL, renders shared search page with SEO. * URL: /{lang}/onlineboard/departure/{station}-{yyyyMMdd}[-{timeRange}] */ import { lazy, Suspense } from "react"; import { useParams } from "@modern-js/runtime/router"; +import { useTranslation } from "@/i18n/provider.js"; import { parseStationUrlParams } from "@/features/online-board/url.js"; +import { buildDepartureSearchSeo } from "@/features/online-board/seo.js"; +import { SeoHead } from "@/ui/seo/SeoHead.js"; import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js"; +import { getEnv } from "@/env/index.js"; const OnlineBoardSearchPage = lazy(() => import("@/features/online-board/components/OnlineBoardSearchPage.js").then( @@ -17,8 +21,10 @@ const OnlineBoardSearchPage = lazy(() => ); export default function DepartureSearchPage(): JSX.Element { - const routeParams = useParams<{ params: string }>(); + const { t } = useTranslation(); + const routeParams = useParams<{ params: string; lang: string }>(); const raw = routeParams.params ?? ""; + const locale = routeParams.lang ?? "ru"; const parsed = parseStationUrlParams(raw); if (!parsed) { @@ -29,6 +35,7 @@ export default function DepartureSearchPage(): JSX.Element { ); } + const canonicalOrigin = getEnv().PROD_ORIGIN; const searchParams = { type: "departure" as const, station: parsed.station, @@ -38,9 +45,14 @@ export default function DepartureSearchPage(): JSX.Element { : {}), }; + const seoProps = buildDepartureSearchSeo(t, searchParams, locale, canonicalOrigin); + return ( - }> - - + <> + + }> + + + ); } diff --git a/src/routes/[lang]/onlineboard/flight/[params]/page.tsx b/src/routes/[lang]/onlineboard/flight/[params]/page.tsx index a0ad5e91..c3fef4e3 100644 --- a/src/routes/[lang]/onlineboard/flight/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/flight/[params]/page.tsx @@ -1,14 +1,18 @@ /** * Online Board flight number search route. * - * Parses flight params from URL, renders shared search page. + * Parses flight params from URL, renders shared search page with SEO. * URL: /{lang}/onlineboard/flight/{carrier}{flightNumber}-{yyyyMMdd} */ import { lazy, Suspense } from "react"; import { useParams } from "@modern-js/runtime/router"; +import { useTranslation } from "@/i18n/provider.js"; import { parseFlightUrlParams } from "@/features/online-board/url.js"; +import { buildFlightSearchSeo } from "@/features/online-board/seo.js"; +import { SeoHead } from "@/ui/seo/SeoHead.js"; import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js"; +import { getEnv } from "@/env/index.js"; const OnlineBoardSearchPage = lazy(() => import("@/features/online-board/components/OnlineBoardSearchPage.js").then( @@ -17,8 +21,10 @@ const OnlineBoardSearchPage = lazy(() => ); export default function FlightSearchPage(): JSX.Element { - const routeParams = useParams<{ params: string }>(); + const { t } = useTranslation(); + const routeParams = useParams<{ params: string; lang: string }>(); const raw = routeParams.params ?? ""; + const locale = routeParams.lang ?? "ru"; const parsed = parseFlightUrlParams(raw); if (!parsed) { @@ -29,6 +35,7 @@ export default function FlightSearchPage(): JSX.Element { ); } + const canonicalOrigin = getEnv().PROD_ORIGIN; const searchParams = parsed.suffix ? { type: "flight" as const, @@ -44,9 +51,14 @@ export default function FlightSearchPage(): JSX.Element { date: parsed.date, }; + const seoProps = buildFlightSearchSeo(t, searchParams, locale, canonicalOrigin); + return ( - }> - - + <> + + }> + + + ); } diff --git a/src/routes/[lang]/onlineboard/page.tsx b/src/routes/[lang]/onlineboard/page.tsx index 04289e10..da5ee194 100644 --- a/src/routes/[lang]/onlineboard/page.tsx +++ b/src/routes/[lang]/onlineboard/page.tsx @@ -6,6 +6,11 @@ */ 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 { buildOnlineBoardStartSeo } from "@/features/online-board/seo.js"; +import { getEnv } from "@/env/index.js"; const OnlineBoardStartPage = lazy(() => import("@/features/online-board/components/OnlineBoardStartPage.js").then( @@ -14,9 +19,19 @@ const OnlineBoardStartPage = lazy(() => ); export default function OnlineBoardPage(): JSX.Element { + const { t } = useTranslation(); + const routeParams = useParams<{ lang: string }>(); + const locale = routeParams.lang ?? "ru"; + const canonicalOrigin = getEnv().PROD_ORIGIN; + + const seoProps = buildOnlineBoardStartSeo(t, locale, canonicalOrigin); + return ( - Loading...
}> - - + <> + + Loading...
}> + + + ); } diff --git a/src/routes/[lang]/onlineboard/route/[params]/page.tsx b/src/routes/[lang]/onlineboard/route/[params]/page.tsx index 990d6437..1155e585 100644 --- a/src/routes/[lang]/onlineboard/route/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/route/[params]/page.tsx @@ -1,14 +1,18 @@ /** * Online Board route search page. * - * Parses departure + arrival + date from URL, renders shared search page. + * Parses departure + arrival + date from URL, renders shared search page with SEO. * URL: /{lang}/onlineboard/route/{dep}-{arr}-{yyyyMMdd}[-{timeRange}] */ import { lazy, Suspense } from "react"; import { useParams } from "@modern-js/runtime/router"; +import { useTranslation } from "@/i18n/provider.js"; import { parseRouteUrlParams } from "@/features/online-board/url.js"; +import { buildRouteSearchSeo } from "@/features/online-board/seo.js"; +import { SeoHead } from "@/ui/seo/SeoHead.js"; import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js"; +import { getEnv } from "@/env/index.js"; const OnlineBoardSearchPage = lazy(() => import("@/features/online-board/components/OnlineBoardSearchPage.js").then( @@ -17,8 +21,10 @@ const OnlineBoardSearchPage = lazy(() => ); export default function RouteSearchPage(): JSX.Element { - const routeParams = useParams<{ params: string }>(); + const { t } = useTranslation(); + const routeParams = useParams<{ params: string; lang: string }>(); const raw = routeParams.params ?? ""; + const locale = routeParams.lang ?? "ru"; const parsed = parseRouteUrlParams(raw); if (!parsed) { @@ -29,6 +35,7 @@ export default function RouteSearchPage(): JSX.Element { ); } + const canonicalOrigin = getEnv().PROD_ORIGIN; const searchParams = { type: "route" as const, departure: parsed.departure, @@ -39,9 +46,14 @@ export default function RouteSearchPage(): JSX.Element { : {}), }; + const seoProps = buildRouteSearchSeo(t, searchParams, locale, canonicalOrigin); + return ( - }> - - + <> + + }> + + + ); }