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.
*/