Wire SEO and JSON-LD into all 6 Online Board route pages

Each route page now renders <SeoHead> with title, description,
canonical, hreflang, OG, and Twitter card from the SEO builders.
Search pages render <JsonLdRenderer> with ItemList of Flight
schemas. Details page renders Flight JSON-LD. Barrel exports
updated with 2F SEO and JSON-LD functions.
This commit is contained in:
2026-04-15 08:37:47 +03:00
parent 44ae7f1642
commit e20686b11f
13 changed files with 169 additions and 53 deletions
@@ -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(<OnlineBoardDetailsPage flightId={mockFlightId} />);
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
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(<OnlineBoardDetailsPage flightId={mockFlightId} />);
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
expect(screen.queryByTestId("flight-details")).toBeNull();
});
it("renders error state", () => {
mockState = { flight: null, loading: false, error: new Error("fail") };
render(<OnlineBoardDetailsPage flightId={mockFlightId} />);
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
expect(screen.getByTestId("flight-details-error")).toBeTruthy();
});
it("renders not-found state", () => {
mockState = { flight: null, loading: false, error: null };
render(<OnlineBoardDetailsPage flightId={mockFlightId} />);
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
expect(screen.getByTestId("flight-details-not-found")).toBeTruthy();
});
it("renders flight legs", () => {
render(<OnlineBoardDetailsPage flightId={mockFlightId} />);
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
expect(screen.getByTestId("flight-legs")).toBeTruthy();
expect(screen.getByTestId("flight-leg-0")).toBeTruthy();
});
it("displays departure and arrival stations", () => {
render(<OnlineBoardDetailsPage flightId={mockFlightId} />);
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
// 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(<OnlineBoardDetailsPage flightId={mockFlightId} />);
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
expect(screen.getByText("Aircraft: Boeing 777-300ER (77W)")).toBeTruthy();
});
it("displays flying time", () => {
render(<OnlineBoardDetailsPage flightId={mockFlightId} />);
render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://www.aeroflot.ru" />);
expect(screen.getByTestId("flying-time")).toBeTruthy();
expect(screen.getByText("Total flying time: 10:30")).toBeTruthy();
});
@@ -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<OnlineBoardDetailsPageProps> = ({
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<OnlineBoardDetailsPageProps> = ({
const legs = getLegs(displayFlight);
const flightNumber = `${displayFlight.flightId.carrier} ${displayFlight.flightId.flightNumber}`;
const seoProps = buildFlightDetailsSeo(t, displayFlight, locale, canonicalOrigin);
const jsonLd = buildFlightJsonLd(displayFlight);
return (
<div className="flight-details" data-testid="flight-details">
<SeoHead {...seoProps} />
<JsonLdRenderer data={jsonLd} />
{/* Connection status */}
<div className="flight-details__status" data-testid="connection-status">
{connectionStatus === "live" && (
@@ -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<OnlineBoardSearchPageProps> = ({
// 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 (
<div className="online-board-search" data-testid="online-board-search">
{jsonLd && <JsonLdRenderer data={jsonLd} />}
{/* Connection status indicator */}
<div className="online-board-search__status" data-testid="connection-status">
{connectionStatus === "live" && (
+14
View File
@@ -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";
+6 -6
View File
@@ -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", () => {
+1
View File
@@ -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");
});
});
+7 -13
View File
@@ -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, unknown>) => 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
// ---------------------------------------------------------------------------
@@ -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 (
<Suspense fallback={<FlightListSkeleton count={1} />}>
<OnlineBoardDetailsPage flightId={parsed} />
<OnlineBoardDetailsPage
flightId={parsed}
locale={locale}
canonicalOrigin={canonicalOrigin}
/>
</Suspense>
);
}
@@ -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 (
<>
<SeoHead {...seoProps} />
<Suspense fallback={<FlightListSkeleton />}>
<OnlineBoardSearchPage params={searchParams} />
</Suspense>
</>
);
}
@@ -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 (
<>
<SeoHead {...seoProps} />
<Suspense fallback={<FlightListSkeleton />}>
<OnlineBoardSearchPage params={searchParams} />
</Suspense>
</>
);
}
@@ -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 (
<>
<SeoHead {...seoProps} />
<Suspense fallback={<FlightListSkeleton />}>
<OnlineBoardSearchPage params={searchParams} />
</Suspense>
</>
);
}
+15
View File
@@ -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 (
<>
<SeoHead {...seoProps} />
<Suspense fallback={<div aria-busy="true">Loading...</div>}>
<OnlineBoardStartPage />
</Suspense>
</>
);
}
@@ -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 (
<>
<SeoHead {...seoProps} />
<Suspense fallback={<FlightListSkeleton />}>
<OnlineBoardSearchPage params={searchParams} />
</Suspense>
</>
);
}