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:
@@ -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} />}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user