Resolve IATA to city names in search-page <title>

Deep-linked search pages rendered `Расписание по маршруту: MOW-MMK`
in the document title because the route page called the SEO builder
synchronously with the raw URL params, before the dictionary was
available. The on-page H1 resolved correctly via `useDictionaries`
inside the child component, but the parent never re-rendered so the
title stayed frozen on IATA codes. Wire `useCityName` into the 5 deep-
link route pages (schedule one-way / round-trip, onlineboard route /
departure / arrival) so the SEO title reflects city names once the
dictionary loads — per TZ §4.1.14.1.
This commit is contained in:
2026-04-22 09:41:16 +03:00
parent a4e8d87688
commit 408afa6ab5
10 changed files with 65 additions and 5 deletions
@@ -51,6 +51,10 @@ vi.mock("@/features/online-board/seo.js", () => ({
buildArrivalSearchSeo: () => ({}),
}));
vi.mock("@/shared/hooks/useDictionaries.js", () => ({
useCityName: (code: string) => code,
}));
const mockNavigate = vi.fn();
vi.mock("@modern-js/runtime/router", () => ({
useParams: vi.fn(),
@@ -14,6 +14,7 @@ import { SeoHead } from "@/ui/seo/SeoHead.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
import { getEnv } from "@/env/index.js";
import { useCityName } from "@/shared/hooks/useDictionaries.js";
import { boardDateRedirect } from "../../_guards.js";
const OnlineBoardSearchPage = lazy(() =>
@@ -29,6 +30,9 @@ export default function ArrivalSearchPage(): JSX.Element {
const locale = routeParams.lang ?? "ru-ru";
const parsed = parseStationUrlParams(raw);
// Resolve IATA → city name for `<title>`. Hooks must run unconditionally.
const stationName = useCityName(parsed?.station ?? "");
if (!parsed) return <ErrorPage code="404" />;
const redirect = boardDateRedirect(locale, parsed.date);
@@ -44,7 +48,9 @@ export default function ArrivalSearchPage(): JSX.Element {
: {}),
};
const seoProps = buildArrivalSearchSeo(t, searchParams, locale, canonicalOrigin);
const seoProps = buildArrivalSearchSeo(t, searchParams, locale, canonicalOrigin, {
arrival: stationName,
});
return (
<>
@@ -51,6 +51,10 @@ vi.mock("@/features/online-board/seo.js", () => ({
buildDepartureSearchSeo: () => ({}),
}));
vi.mock("@/shared/hooks/useDictionaries.js", () => ({
useCityName: (code: string) => code,
}));
const mockNavigate = vi.fn();
vi.mock("@modern-js/runtime/router", () => ({
useParams: vi.fn(),
@@ -14,6 +14,7 @@ import { SeoHead } from "@/ui/seo/SeoHead.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
import { getEnv } from "@/env/index.js";
import { useCityName } from "@/shared/hooks/useDictionaries.js";
import { boardDateRedirect } from "../../_guards.js";
const OnlineBoardSearchPage = lazy(() =>
@@ -29,6 +30,9 @@ export default function DepartureSearchPage(): JSX.Element {
const locale = routeParams.lang ?? "ru-ru";
const parsed = parseStationUrlParams(raw);
// Resolve IATA → city name for `<title>`. Hooks must run unconditionally.
const stationName = useCityName(parsed?.station ?? "");
if (!parsed) return <ErrorPage code="404" />;
const redirect = boardDateRedirect(locale, parsed.date);
@@ -44,7 +48,9 @@ export default function DepartureSearchPage(): JSX.Element {
: {}),
};
const seoProps = buildDepartureSearchSeo(t, searchParams, locale, canonicalOrigin);
const seoProps = buildDepartureSearchSeo(t, searchParams, locale, canonicalOrigin, {
departure: stationName,
});
return (
<>
@@ -51,6 +51,10 @@ vi.mock("@/features/online-board/seo.js", () => ({
buildRouteSearchSeo: () => ({}),
}));
vi.mock("@/shared/hooks/useDictionaries.js", () => ({
useCityName: (code: string) => code,
}));
const mockNavigate = vi.fn();
vi.mock("@modern-js/runtime/router", () => ({
useParams: vi.fn(),
@@ -14,6 +14,7 @@ import { SeoHead } from "@/ui/seo/SeoHead.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
import { getEnv } from "@/env/index.js";
import { useCityName } from "@/shared/hooks/useDictionaries.js";
import { boardDateRedirect } from "../../_guards.js";
const OnlineBoardSearchPage = lazy(() =>
@@ -29,6 +30,10 @@ export default function RouteSearchPage(): JSX.Element {
const locale = routeParams.lang ?? "ru-ru";
const parsed = parseRouteUrlParams(raw);
// Resolve IATA → city names for `<title>`. Hooks must run unconditionally.
const depName = useCityName(parsed?.departure ?? "");
const arrName = useCityName(parsed?.arrival ?? "");
if (!parsed) return <ErrorPage code="404" />;
const redirect = boardDateRedirect(locale, parsed.date);
@@ -45,7 +50,10 @@ export default function RouteSearchPage(): JSX.Element {
: {}),
};
const seoProps = buildRouteSearchSeo(t, searchParams, locale, canonicalOrigin);
const seoProps = buildRouteSearchSeo(t, searchParams, locale, canonicalOrigin, {
departure: depName,
arrival: arrName,
});
return (
<>
@@ -52,6 +52,10 @@ vi.mock("@/features/schedule/seo.js", () => ({
buildScheduleSearchSeo: () => ({}),
}));
vi.mock("@/shared/hooks/useDictionaries.js", () => ({
useCityName: (code: string) => code,
}));
const mockNavigate = vi.fn();
vi.mock("@modern-js/runtime/router", () => ({
useParams: vi.fn(),
@@ -14,6 +14,7 @@ import { SeoHead } from "@/ui/seo/SeoHead.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
import { getEnv } from "@/env/index.js";
import { useCityName } from "@/shared/hooks/useDictionaries.js";
import { scheduleDateRedirect } from "../../../_guards.js";
const ScheduleSearchPage = lazy(() =>
@@ -32,6 +33,12 @@ export default function ScheduleRoundTripSearchPage(): JSX.Element {
const outbound = parseScheduleRouteParams(outboundRaw);
const inbound = parseScheduleRouteParams(inboundRaw);
// Resolve IATA → city names for `<title>`. Hooks must run unconditionally,
// so call with the outbound codes (which double as the SEO heading codes)
// even when parsing failed.
const depName = useCityName(outbound?.departure ?? "");
const arrName = useCityName(outbound?.arrival ?? "");
if (!outbound || !inbound) return <ErrorPage code="404" />;
const redirect =
@@ -41,7 +48,10 @@ export default function ScheduleRoundTripSearchPage(): JSX.Element {
const canonicalOrigin = getEnv().PROD_ORIGIN;
const scheduleParams = { type: "roundtrip" as const, outbound, inbound };
const seoProps = buildScheduleSearchSeo(t, scheduleParams, locale, canonicalOrigin);
const seoProps = buildScheduleSearchSeo(t, scheduleParams, locale, canonicalOrigin, {
departure: depName,
arrival: arrName,
});
return (
<>
@@ -51,6 +51,10 @@ vi.mock("@/features/schedule/seo.js", () => ({
buildScheduleSearchSeo: () => ({}),
}));
vi.mock("@/shared/hooks/useDictionaries.js", () => ({
useCityName: (code: string) => code,
}));
const mockNavigate = vi.fn();
vi.mock("@modern-js/runtime/router", () => ({
useParams: vi.fn(),
@@ -14,6 +14,7 @@ import { SeoHead } from "@/ui/seo/SeoHead.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { ErrorPage } from "@/ui/errors/ErrorPage.js";
import { getEnv } from "@/env/index.js";
import { useCityName } from "@/shared/hooks/useDictionaries.js";
import { scheduleDateRedirect } from "../../_guards.js";
const ScheduleSearchPage = lazy(() =>
@@ -29,6 +30,12 @@ export default function ScheduleRouteSearchPage(): JSX.Element {
const locale = routeParams.lang ?? "ru-ru";
const parsed = parseScheduleRouteParams(raw);
// Resolve IATA → city names so `<title>` reads
// "Расписание по маршруту: Москва-Мурманск" instead of "MOW-MMK".
// Hooks must run unconditionally; pass empty codes if parsing failed.
const depName = useCityName(parsed?.departure ?? "");
const arrName = useCityName(parsed?.arrival ?? "");
if (!parsed) return <ErrorPage code="404" />;
const redirect = scheduleDateRedirect(locale, parsed.dateFrom);
@@ -36,7 +43,10 @@ export default function ScheduleRouteSearchPage(): JSX.Element {
const canonicalOrigin = getEnv().PROD_ORIGIN;
const scheduleParams = { type: "route" as const, outbound: parsed };
const seoProps = buildScheduleSearchSeo(t, scheduleParams, locale, canonicalOrigin);
const seoProps = buildScheduleSearchSeo(t, scheduleParams, locale, canonicalOrigin, {
departure: depName,
arrival: arrName,
});
return (
<>