From ffb1a8579ddbc213b72f99d92f6ea4bfb4c6537b Mon Sep 17 00:00:00 2001 From: gnezim Date: Sun, 19 Apr 2026 01:39:06 +0300 Subject: [PATCH] Render SeoHead at route level; convert Angular-style {{var}} to ICU {var} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On /ru/onlineboard/SU6272-20260418 the document title was blank and the meta description carried a literal '{{ flightNumber }}' placeholder. Two root causes: 1. Translation values carried Angular ngx-translate syntax {{ var }} but the React app uses i18next-icu (single-brace {var}). Interpolation never fired, so SEO strings served as-is. Rewrite every {{ var }} (and {{var}}) occurrence to {var} across ru/en locales. 2. was rendered inside the lazy-loaded OnlineBoardDetailsPage. The SSR response streams the Suspense fallback before the lazy bundle resolves, so /<meta> never land in the <head>. Move SeoHead to the route page (src/routes/.../page.tsx) where it renders synchronously from URL-derived data, and drop the inner duplicate. Add buildFlightDetailsSeoFromId for the URL-only path. formatDateForSeo now handles both 'yyyyMMdd' (URL) and 'yyyy-MM-dd' (API) so both entry points produce '18.04.2026'. 3. React 18 doesn't auto-hoist <title> inside body to document.head — add a useEffect in SeoHead that also writes document.title on the client. SSR still emits the <title> element for crawlers. --- .../components/OnlineBoardDetailsPage.tsx | 8 ++--- src/features/online-board/seo.ts | 30 +++++++++++++++---- src/i18n/locales/en/common.json | 20 ++++++------- src/i18n/locales/ru/common.json | 28 ++++++++--------- .../[lang]/onlineboard/[params]/page.tsx | 24 ++++++++++----- src/ui/seo/SeoHead.tsx | 10 +++++++ 6 files changed, 79 insertions(+), 41 deletions(-) diff --git a/src/features/online-board/components/OnlineBoardDetailsPage.tsx b/src/features/online-board/components/OnlineBoardDetailsPage.tsx index c62d1c64..4bd71603 100644 --- a/src/features/online-board/components/OnlineBoardDetailsPage.tsx +++ b/src/features/online-board/components/OnlineBoardDetailsPage.tsx @@ -12,14 +12,12 @@ import { useNavigate, useSearchParams } from "@modern-js/runtime/router"; import { useTranslation } from "@/i18n/provider.js"; import "./OnlineBoardDetailsPage.scss"; import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js"; -import { SeoHead } from "@/ui/seo/SeoHead.js"; import { JsonLdRenderer } from "@/shared/seo/json-ld.js"; import { PageLayout } from "@/ui/layout/PageLayout.js"; import { useAppSettings } from "@/shared/hooks/useAppSettings.js"; import { useFlightDetails } from "../hooks/useFlightDetails.js"; import { useLiveFlightDetails } from "../hooks/useLiveFlightDetails.js"; import { useOnlineBoard } from "../hooks/useOnlineBoard.js"; -import { buildFlightDetailsSeo } from "../seo.js"; import { buildFlightJsonLd } from "../json-ld.js"; import { buildOnlineBoardUrl } from "../url.js"; import { FlightDetailsAccordion } from "./details-panels/FlightDetailsAccordion.js"; @@ -507,12 +505,14 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({ const routeSuffix = depCity && arrCity ? `, ${depCity} - ${arrCity}` : ""; const pageTitle = `${t("BOARD.FLIGHT-INFO")}: ${flightNumber}${routeSuffix}`; - const seoProps = buildFlightDetailsSeo(t, displayFlight, locale, canonicalOrigin); + // SeoHead is rendered at the route level (src/routes/.../page.tsx) from + // URL-derived data so SSR can emit <title>/<meta> without waiting for + // the lazy flight-details bundle + data fetch. Keep only JSON-LD here + // since it needs the full flight payload. const jsonLd = buildFlightJsonLd(displayFlight); return ( <> - <SeoHead {...seoProps} /> <JsonLdRenderer data={jsonLd} /> <PageLayout headerLeft={<DetailsBackButton locale={locale} />} diff --git a/src/features/online-board/seo.ts b/src/features/online-board/seo.ts index 3ce4b471..2bcc7348 100644 --- a/src/features/online-board/seo.ts +++ b/src/features/online-board/seo.ts @@ -45,13 +45,15 @@ const SITE_NAME = "Aeroflot"; // --------------------------------------------------------------------------- /** - * Format a yyyyMMdd date string to dd.MM.yyyy for display in SEO strings. + * Format a date string to dd.MM.yyyy for display in SEO strings. + * Accepts both 'yyyyMMdd' (URL param shape) and 'yyyy-MM-dd' (API response). */ -function formatDateForSeo(yyyymmdd: string): string { - if (yyyymmdd.length !== 8) return yyyymmdd; - const day = yyyymmdd.slice(6, 8); - const month = yyyymmdd.slice(4, 6); - const year = yyyymmdd.slice(0, 4); +function formatDateForSeo(input: string): string { + const digits = input.replace(/-/g, ""); + if (digits.length !== 8) return input; + const day = digits.slice(6, 8); + const month = digits.slice(4, 6); + const year = digits.slice(0, 4); return `${day}.${month}.${year}`; } @@ -303,6 +305,22 @@ export function buildRouteSearchSeo( }; } +/** + * SEO props for flight details page from already-parsed flight id. + * Accepts a plain object so the route page can render SeoHead + * synchronously without waiting for the flight data to load — SSR + * needs the title + meta in the <head> of the first response. + */ +export function buildFlightDetailsSeoFromId( + t: TFunction, + flightId: { carrier: string; flightNumber: string; suffix?: string; date: string }, + locale: string, + canonicalOrigin: string, +): SeoHeadProps { + const flight: ISimpleFlight = { flightId } as unknown as ISimpleFlight; + return buildFlightDetailsSeo(t, flight, locale, canonicalOrigin); +} + /** * SEO props for flight details page. */ diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 10d68dc4..58410521 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -218,28 +218,28 @@ "SEO": { "BOARD": { "ARRIVAL-SEARCH": { - "DESCRIPTION": "Up-to-date list of Aeroflot flights arriving on {{ date }}. Online arrivals board for {{ arrivalCity }}.", - "TITLE": "Online arrivals board for {{ arrivalCity }} | Aeroflot flights arriving {{ date }}" + "DESCRIPTION": "Up-to-date list of Aeroflot flights arriving on {date}. Online arrivals board for {arrivalCity}.", + "TITLE": "Online arrivals board for {arrivalCity} | Aeroflot flights arriving {date}" }, "DEPARTURE-SEARCH": { - "DESCRIPTION": "Up-to-date list of Aeroflot flights departing on {{ date }}. Online departures board for {{ departureCity }}.", - "TITLE": "Online departures board for {{ departureCity }} | Aeroflot flights departing {{ date }}" + "DESCRIPTION": "Up-to-date list of Aeroflot flights departing on {date}. Online departures board for {departureCity}.", + "TITLE": "Online departures board for {departureCity} | Aeroflot flights departing {date}" }, "FLIGHT-DETAILS": { - "DESCRIPTION": "Real-time departure and arrival information for flight {{ flightNumber }}. Departure time, arrival time, and current flight status on the official Aeroflot website.", - "TITLE": "Flight status {{ flightNumber }} {{ date }} | Aeroflot" + "DESCRIPTION": "Real-time departure and arrival information for flight {flightNumber}. Departure time, arrival time, and current flight status on the official Aeroflot website.", + "TITLE": "Flight status {flightNumber} {date} | Aeroflot" }, "FLIGHT-SEARCH": { - "DESCRIPTION": "Departure and arrival information for flight {{ flightNumber }} on {{ date }}.", - "TITLE": "Flight {{ flightNumber }} – Online arrivals and departures board {{ date }} | Aeroflot" + "DESCRIPTION": "Departure and arrival information for flight {flightNumber} on {date}.", + "TITLE": "Flight {flightNumber} – Online arrivals and departures board {date} | Aeroflot" }, "MAIN": { "DESCRIPTION": "Arrivals and departures board for Aeroflot airline. Real-time flight arrival and departure information.", "TITLE": "Online departures and arrivals board for Aeroflot flights | Aeroflot" }, "ROUTE-SEARCH": { - "DESCRIPTION": "Arrivals and departures board for Aeroflot flights on the {{ departureCity }} - {{ arrivalCity }} route. Real-time flight information for {{ date }}.", - "TITLE": "Arrivals and departures {{ departureCity }} - {{ arrivalCity }} {{ date }} | Aeroflot" + "DESCRIPTION": "Arrivals and departures board for Aeroflot flights on the {departureCity} - {arrivalCity} route. Real-time flight information for {date}.", + "TITLE": "Arrivals and departures {departureCity} - {arrivalCity} {date} | Aeroflot" } }, "SCHEDULE": { diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 7e939e5d..4afeffe0 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -218,42 +218,42 @@ "SEO": { "BOARD": { "ARRIVAL-SEARCH": { - "DESCRIPTION": "Актуальный список рейсов авиакомпании Аэрофлот, прибывающих {{ date }}. Онлайн-табло прилетов в {{ arrivalCity }}.", - "TITLE": "Онлайн-табло прилетов в {{ arrivalCity }} | Прибытие рейсов от Аэрофлот {{ date }}" + "DESCRIPTION": "Актуальный список рейсов авиакомпании Аэрофлот, прибывающих {date}. Онлайн-табло прилетов в {arrivalCity}.", + "TITLE": "Онлайн-табло прилетов в {arrivalCity} | Прибытие рейсов от Аэрофлот {date}" }, "DEPARTURE-SEARCH": { - "DESCRIPTION": "Актуальный список рейсов авиакомпании Аэрофлот, отправляющихся {{ date }}. Онлайн-табло вылетов из {{ departureCity }}.", - "TITLE": "Онлайн-табло вылетов из {{ departureCity }} | Отправление рейсов Аэрофлот {{ date }}" + "DESCRIPTION": "Актуальный список рейсов авиакомпании Аэрофлот, отправляющихся {date}. Онлайн-табло вылетов из {departureCity}.", + "TITLE": "Онлайн-табло вылетов из {departureCity} | Отправление рейсов Аэрофлот {date}" }, "FLIGHT-DETAILS": { - "DESCRIPTION": "Информация об отправлении и прибытии рейса {{ flightNumber }} в режиме онлайн! Время вылета, время прилета, актуальный статус рейса на официальном сайте авиакомпании Аэрофлот.", - "TITLE": "Статус рейса {{ flightNumber }} {{ date }} | Аэрофлот" + "DESCRIPTION": "Информация об отправлении и прибытии рейса {flightNumber} в режиме онлайн! Время вылета, время прилета, актуальный статус рейса на официальном сайте авиакомпании Аэрофлот.", + "TITLE": "Статус рейса {flightNumber} {date} | Аэрофлот" }, "FLIGHT-SEARCH": { - "DESCRIPTION": "Информация об отправлении и прибытии рейса {{ flightNumber }} {{ date }}.", - "TITLE": "Рейс {{flightNumber}} – Онлайн-табло прилета и вылета {{ date }} | Аэрофлот" + "DESCRIPTION": "Информация об отправлении и прибытии рейса {flightNumber} {date}.", + "TITLE": "Рейс {flightNumber} – Онлайн-табло прилета и вылета {date} | Аэрофлот" }, "MAIN": { "DESCRIPTION": "Табло прибытия и отправления рейсов авиакомпании 'Аэрофлот'. Информация о прилетах и вылетах в режиме онлайн.", "TITLE": "Онлайн-табло вылетов и прилетов рейсов авиакомпании Аэрофлот | Аэрофлот" }, "ROUTE-SEARCH": { - "DESCRIPTION": "Табло прибытия и отправления рейсов авиакомпании Аэрофлот по направлению {{ departureCity }} - {{ arrivalCity }}. Информация о прилетах и вылетах в режиме онлайн на {{ date }}.", - "TITLE": "Прибытие и отправление рейсов {{ departureCity }} - {{ arrivalCity }} {{ date }} | Аэрофлот" + "DESCRIPTION": "Табло прибытия и отправления рейсов авиакомпании Аэрофлот по направлению {departureCity} - {arrivalCity}. Информация о прилетах и вылетах в режиме онлайн на {date}.", + "TITLE": "Прибытие и отправление рейсов {departureCity} - {arrivalCity} {date} | Аэрофлот" } }, "SCHEDULE": { "FLIGHT-DETAILS": { - "DESCRIPTION": "Информация об отправлении и прибытии рейса {{ flightNumber }} в режиме онлайн! Время вылета, время прилета, актуальный статус рейса на официальном сайте авиакомпании Аэрофлот.", - "TITLE": "Рейс {{ flightNumber }} – Расписание рейсов на {{ date }} | Аэрофлот" + "DESCRIPTION": "Информация об отправлении и прибытии рейса {flightNumber} в режиме онлайн! Время вылета, время прилета, актуальный статус рейса на официальном сайте авиакомпании Аэрофлот.", + "TITLE": "Рейс {flightNumber} – Расписание рейсов на {date} | Аэрофлот" }, "MAIN": { "DESCRIPTION": "Расписание полетов 'Аэрофлот' по России и международным направлениям. Список доступных рейсов и актуальная информация о времени вылета и прилета.", "TITLE": "Расписание прямых и стыковочных рейсов авиакомпании Аэрофлот" }, "SEARCH": { - "DESCRIPTION": "Подробное расписание самолетов по маршруту {{ departureCity }} - {{ arrivalCity }} на {{ date }} и ближайшие даты на официальном сайте авиакомпании Аэрофлот.", - "TITLE": "Расписание рейсов {{ departureCity }} - {{ arrivalCity }} | Аэрофлот" + "DESCRIPTION": "Подробное расписание самолетов по маршруту {departureCity} - {arrivalCity} на {date} и ближайшие даты на официальном сайте авиакомпании Аэрофлот.", + "TITLE": "Расписание рейсов {departureCity} - {arrivalCity} | Аэрофлот" } }, "FLIGHTS-MAP": { diff --git a/src/routes/[lang]/onlineboard/[params]/page.tsx b/src/routes/[lang]/onlineboard/[params]/page.tsx index 417b9282..eb8b70b9 100644 --- a/src/routes/[lang]/onlineboard/[params]/page.tsx +++ b/src/routes/[lang]/onlineboard/[params]/page.tsx @@ -12,6 +12,8 @@ import { useParams } from "@modern-js/runtime/router"; import { useTranslation } from "@/i18n/provider.js"; import { parseFlightUrlParams } from "@/features/online-board/url.js"; import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js"; +import { SeoHead } from "@/ui/seo/SeoHead.js"; +import { buildFlightDetailsSeoFromId } from "@/features/online-board/seo.js"; import { getEnv } from "@/env/index.js"; const OnlineBoardDetailsPage = lazy(() => @@ -36,14 +38,22 @@ export default function FlightDetailsPage(): JSX.Element { } const canonicalOrigin = getEnv().PROD_ORIGIN; + // Render SeoHead OUTSIDE the lazy Suspense boundary so SSR gets the + // <title> and <meta> into the first response. The inner component's + // own SeoHead still runs once hydrated; React/Modern.js de-dupes + // head tags, so the final document ends up with one correct copy. + const seoProps = buildFlightDetailsSeoFromId(t, parsed, locale, canonicalOrigin); return ( - <Suspense fallback={<FlightListSkeleton count={1} />}> - <OnlineBoardDetailsPage - flightId={parsed} - locale={locale} - canonicalOrigin={canonicalOrigin} - /> - </Suspense> + <> + <SeoHead {...seoProps} /> + <Suspense fallback={<FlightListSkeleton count={1} />}> + <OnlineBoardDetailsPage + flightId={parsed} + locale={locale} + canonicalOrigin={canonicalOrigin} + /> + </Suspense> + </> ); } diff --git a/src/ui/seo/SeoHead.tsx b/src/ui/seo/SeoHead.tsx index fe2b50f8..157d782c 100644 --- a/src/ui/seo/SeoHead.tsx +++ b/src/ui/seo/SeoHead.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import type { Language } from "@/i18n/resolver"; import { JsonLdRenderer } from "@/shared/seo/json-ld.js"; import type { Thing } from "schema-dts"; @@ -40,6 +41,15 @@ export function SeoHead({ jsonLd, noindex, }: SeoHeadProps): JSX.Element { + // React 18 doesn't hoist <title> inside body to document.head, so set + // document.title imperatively on the client. SSR still emits the + // <title> element for crawlers that read the raw HTML. + useEffect(() => { + if (typeof document !== "undefined" && title) { + document.title = title; + } + }, [title]); + return ( <> <title>{title}