Render SeoHead at route level; convert Angular-style {{var}} to ICU {var}

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. <SeoHead> was rendered inside the lazy-loaded OnlineBoardDetailsPage.
   The SSR response streams the Suspense fallback before the lazy
   bundle resolves, so <title>/<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.
This commit is contained in:
2026-04-19 01:39:06 +03:00
parent d43bfb3fcb
commit ffb1a8579d
6 changed files with 79 additions and 41 deletions
@@ -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} />}
+24 -6
View File
@@ -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.
*/
+10 -10
View File
@@ -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": {
+14 -14
View File
@@ -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": {
@@ -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>
</>
);
}
+10
View File
@@ -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}</title>