From e20686b11f01b6358e6a74b9a1f05aa9a0dcac6b Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 08:37:47 +0300 Subject: [PATCH] Wire SEO and JSON-LD into all 6 Online Board route pages Each route page now renders with title, description, canonical, hreflang, OG, and Twitter card from the SEO builders. Search pages render with ItemList of Flight schemas. Details page renders Flight JSON-LD. Barrel exports updated with 2F SEO and JSON-LD functions. --- .../OnlineBoardDetailsPage.test.tsx | 23 ++++++++++++------- .../components/OnlineBoardDetailsPage.tsx | 18 +++++++++++++++ .../components/OnlineBoardSearchPage.tsx | 9 ++++++++ src/features/online-board/index.ts | 14 +++++++++++ src/features/online-board/json-ld.test.ts | 12 +++++----- src/features/online-board/seo.test.ts | 1 + src/features/online-board/seo.ts | 20 ++++++---------- .../[lang]/onlineboard/[params]/page.tsx | 16 ++++++++++--- .../onlineboard/arrival/[params]/page.tsx | 22 ++++++++++++++---- .../onlineboard/departure/[params]/page.tsx | 22 ++++++++++++++---- .../onlineboard/flight/[params]/page.tsx | 22 ++++++++++++++---- src/routes/[lang]/onlineboard/page.tsx | 21 ++++++++++++++--- .../onlineboard/route/[params]/page.tsx | 22 ++++++++++++++---- 13 files changed, 169 insertions(+), 53 deletions(-) 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 ( - }> - - + <> + + }> + + + ); }