From 408afa6ab546df39410ff06f30b189ec4fc94dac Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 22 Apr 2026 09:41:16 +0300 Subject: [PATCH] Resolve IATA to city names in search-page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../onlineboard/arrival/[params]/page.test.tsx | 4 ++++ .../[lang]/onlineboard/arrival/[params]/page.tsx | 8 +++++++- .../onlineboard/departure/[params]/page.test.tsx | 4 ++++ .../[lang]/onlineboard/departure/[params]/page.tsx | 8 +++++++- .../[lang]/onlineboard/route/[params]/page.test.tsx | 4 ++++ .../[lang]/onlineboard/route/[params]/page.tsx | 10 +++++++++- .../route/[params]/[returnParams]/page.test.tsx | 4 ++++ .../schedule/route/[params]/[returnParams]/page.tsx | 12 +++++++++++- .../[lang]/schedule/route/[params]/page.test.tsx | 4 ++++ src/routes/[lang]/schedule/route/[params]/page.tsx | 12 +++++++++++- 10 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/routes/[lang]/onlineboard/arrival/[params]/page.test.tsx b/src/routes/[lang]/onlineboard/arrival/[params]/page.test.tsx index 3b28ac0f..60686ffc 100644 --- a/src/routes/[lang]/onlineboard/arrival/[params]/page.test.tsx +++ b/src/routes/[lang]/onlineboard/arrival/[params]/page.test.tsx @@ -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(), diff --git a/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx b/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx index 056582aa..3ff4fb1e 100644 --- a/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/arrival/[params]/page.tsx @@ -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 ( <> diff --git a/src/routes/[lang]/onlineboard/departure/[params]/page.test.tsx b/src/routes/[lang]/onlineboard/departure/[params]/page.test.tsx index a24d71ef..0ac6337d 100644 --- a/src/routes/[lang]/onlineboard/departure/[params]/page.test.tsx +++ b/src/routes/[lang]/onlineboard/departure/[params]/page.test.tsx @@ -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(), diff --git a/src/routes/[lang]/onlineboard/departure/[params]/page.tsx b/src/routes/[lang]/onlineboard/departure/[params]/page.tsx index 34ef604c..44c3b62d 100644 --- a/src/routes/[lang]/onlineboard/departure/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/departure/[params]/page.tsx @@ -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 ( <> diff --git a/src/routes/[lang]/onlineboard/route/[params]/page.test.tsx b/src/routes/[lang]/onlineboard/route/[params]/page.test.tsx index 99cd58e6..27bfed6c 100644 --- a/src/routes/[lang]/onlineboard/route/[params]/page.test.tsx +++ b/src/routes/[lang]/onlineboard/route/[params]/page.test.tsx @@ -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(), diff --git a/src/routes/[lang]/onlineboard/route/[params]/page.tsx b/src/routes/[lang]/onlineboard/route/[params]/page.tsx index 995064c4..b28331f4 100644 --- a/src/routes/[lang]/onlineboard/route/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/route/[params]/page.tsx @@ -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 ( <> diff --git a/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.test.tsx b/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.test.tsx index d5d73651..365fbaeb 100644 --- a/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.test.tsx +++ b/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.test.tsx @@ -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(), diff --git a/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx b/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx index c33a3f10..f69ebb3b 100644 --- a/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx +++ b/src/routes/[lang]/schedule/route/[params]/[returnParams]/page.tsx @@ -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 ( <> diff --git a/src/routes/[lang]/schedule/route/[params]/page.test.tsx b/src/routes/[lang]/schedule/route/[params]/page.test.tsx index c4e6b755..5b6cfdbd 100644 --- a/src/routes/[lang]/schedule/route/[params]/page.test.tsx +++ b/src/routes/[lang]/schedule/route/[params]/page.test.tsx @@ -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(), diff --git a/src/routes/[lang]/schedule/route/[params]/page.tsx b/src/routes/[lang]/schedule/route/[params]/page.tsx index d505acd2..6a9ac6b5 100644 --- a/src/routes/[lang]/schedule/route/[params]/page.tsx +++ b/src/routes/[lang]/schedule/route/[params]/page.tsx @@ -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 ( <>