Align Online-Board page titles with TZ Table 6 (сегодня/завтра/ДД.ММ.ГГГГ date display)
- Add formatDateForTitle helper: returns today/tomorrow labels or dd.MM.yyyy - Switch all search page title builders to use formatDateForTitle; descriptions keep dd.MM.yyyy - FLIGHT-DETAILS title now uses routeCities (no date) per TZ rows 6-8; adds TITLE-NO-ROUTE fallback for SSR when cities not yet loaded - buildFlightDetailsSeoFromId accepts optional cityNames param - Update ru/en i18n TITLE strings to TZ Table 6 format; add TITLE-NO-ROUTE to all 9 locales - Tests: 32 cases covering today/tomorrow/arbitrary-date branches and routeCities logic
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||||
import {
|
import {
|
||||||
buildOnlineBoardStartSeo,
|
buildOnlineBoardStartSeo,
|
||||||
buildFlightSearchSeo,
|
buildFlightSearchSeo,
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
buildArrivalSearchSeo,
|
buildArrivalSearchSeo,
|
||||||
buildRouteSearchSeo,
|
buildRouteSearchSeo,
|
||||||
buildFlightDetailsSeo,
|
buildFlightDetailsSeo,
|
||||||
|
buildFlightDetailsSeoFromId,
|
||||||
} from "./seo.js";
|
} from "./seo.js";
|
||||||
import type { ISimpleFlight } from "./types.js";
|
import type { ISimpleFlight } from "./types.js";
|
||||||
|
|
||||||
@@ -20,8 +21,92 @@ function stubT(key: string, opts?: Record<string, unknown>): string {
|
|||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub t() that resolves SHARED.TODAY / SHARED.TOMORROW to their Russian
|
||||||
|
* equivalents so we can assert the rendered value directly.
|
||||||
|
*/
|
||||||
|
function stubTRu(key: string, opts?: Record<string, unknown>): string {
|
||||||
|
if (key === "SHARED.TODAY") return "сегодня";
|
||||||
|
if (key === "SHARED.TOMORROW") return "завтра";
|
||||||
|
return stubT(key, opts);
|
||||||
|
}
|
||||||
|
|
||||||
const CANONICAL = "https://www.aeroflot.ru";
|
const CANONICAL = "https://www.aeroflot.ru";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// formatDateForTitle — via buildFlightSearchSeo with frozen clock
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("formatDateForTitle date branches", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'сегодня' when date matches today (local)", () => {
|
||||||
|
// Freeze clock to 2026-05-15T12:00:00 local
|
||||||
|
vi.useFakeTimers({ now: new Date(2026, 4, 15, 12, 0, 0) });
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
type: "flight" as const,
|
||||||
|
carrier: "SU",
|
||||||
|
flightNumber: "0100",
|
||||||
|
date: "20260515",
|
||||||
|
};
|
||||||
|
const result = buildFlightSearchSeo(stubTRu, params, "ru", CANONICAL);
|
||||||
|
|
||||||
|
expect(result.title).toContain("date=сегодня");
|
||||||
|
// description must use dd.MM.yyyy, not "сегодня"
|
||||||
|
expect(result.description).toContain("date=15.05.2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'завтра' when date is today + 1", () => {
|
||||||
|
vi.useFakeTimers({ now: new Date(2026, 4, 15, 12, 0, 0) });
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
type: "flight" as const,
|
||||||
|
carrier: "SU",
|
||||||
|
flightNumber: "0100",
|
||||||
|
date: "20260516",
|
||||||
|
};
|
||||||
|
const result = buildFlightSearchSeo(stubTRu, params, "ru", CANONICAL);
|
||||||
|
|
||||||
|
expect(result.title).toContain("date=завтра");
|
||||||
|
expect(result.description).toContain("date=16.05.2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns dd.MM.yyyy for an arbitrary past date", () => {
|
||||||
|
vi.useFakeTimers({ now: new Date(2026, 4, 15, 12, 0, 0) });
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
type: "flight" as const,
|
||||||
|
carrier: "SU",
|
||||||
|
flightNumber: "0100",
|
||||||
|
date: "20250115",
|
||||||
|
};
|
||||||
|
const result = buildFlightSearchSeo(stubTRu, params, "ru", CANONICAL);
|
||||||
|
|
||||||
|
expect(result.title).toContain("date=15.01.2025");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts yyyy-MM-dd API shape for today", () => {
|
||||||
|
vi.useFakeTimers({ now: new Date(2026, 4, 15, 12, 0, 0) });
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
type: "departure" as const,
|
||||||
|
station: "SVO",
|
||||||
|
date: "2026-05-15",
|
||||||
|
};
|
||||||
|
const result = buildDepartureSearchSeo(stubTRu, params, "ru", CANONICAL);
|
||||||
|
|
||||||
|
expect(result.title).toContain("date=сегодня");
|
||||||
|
expect(result.description).toContain("date=15.05.2026");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// buildOnlineBoardStartSeo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("buildOnlineBoardStartSeo", () => {
|
describe("buildOnlineBoardStartSeo", () => {
|
||||||
it("uses MAIN translation keys for title and description", () => {
|
it("uses MAIN translation keys for title and description", () => {
|
||||||
const result = buildOnlineBoardStartSeo(stubT, "ru", CANONICAL);
|
const result = buildOnlineBoardStartSeo(stubT, "ru", CANONICAL);
|
||||||
@@ -62,6 +147,10 @@ describe("buildOnlineBoardStartSeo", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// buildFlightSearchSeo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("buildFlightSearchSeo", () => {
|
describe("buildFlightSearchSeo", () => {
|
||||||
const params = {
|
const params = {
|
||||||
type: "flight" as const,
|
type: "flight" as const,
|
||||||
@@ -78,6 +167,14 @@ describe("buildFlightSearchSeo", () => {
|
|||||||
expect(result.description).toContain("SEO.BOARD.FLIGHT-SEARCH.DESCRIPTION");
|
expect(result.description).toContain("SEO.BOARD.FLIGHT-SEARCH.DESCRIPTION");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("title date uses formatDateForTitle; description uses dd.MM.yyyy", () => {
|
||||||
|
// 20250115 is not today/tomorrow, so title date = "15.01.2025"
|
||||||
|
const result = buildFlightSearchSeo(stubT, params, "ru", CANONICAL);
|
||||||
|
|
||||||
|
expect(result.title).toContain("date=15.01.2025");
|
||||||
|
expect(result.description).toContain("date=15.01.2025");
|
||||||
|
});
|
||||||
|
|
||||||
it("sets canonical to the flight search URL", () => {
|
it("sets canonical to the flight search URL", () => {
|
||||||
const result = buildFlightSearchSeo(stubT, params, "ru", CANONICAL);
|
const result = buildFlightSearchSeo(stubT, params, "ru", CANONICAL);
|
||||||
|
|
||||||
@@ -93,6 +190,10 @@ describe("buildFlightSearchSeo", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// buildDepartureSearchSeo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("buildDepartureSearchSeo", () => {
|
describe("buildDepartureSearchSeo", () => {
|
||||||
const params = {
|
const params = {
|
||||||
type: "departure" as const,
|
type: "departure" as const,
|
||||||
@@ -119,6 +220,13 @@ describe("buildDepartureSearchSeo", () => {
|
|||||||
expect(result.title).toContain("departureCity=SVO");
|
expect(result.title).toContain("departureCity=SVO");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("title date uses formatDateForTitle; description keeps dd.MM.yyyy", () => {
|
||||||
|
const result = buildDepartureSearchSeo(stubT, params, "ru", CANONICAL);
|
||||||
|
|
||||||
|
expect(result.title).toContain("date=15.01.2025");
|
||||||
|
expect(result.description).toContain("date=15.01.2025");
|
||||||
|
});
|
||||||
|
|
||||||
it("sets canonical to the departure search URL", () => {
|
it("sets canonical to the departure search URL", () => {
|
||||||
const result = buildDepartureSearchSeo(stubT, params, "en", CANONICAL);
|
const result = buildDepartureSearchSeo(stubT, params, "en", CANONICAL);
|
||||||
|
|
||||||
@@ -128,6 +236,10 @@ describe("buildDepartureSearchSeo", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// buildArrivalSearchSeo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("buildArrivalSearchSeo", () => {
|
describe("buildArrivalSearchSeo", () => {
|
||||||
const params = {
|
const params = {
|
||||||
type: "arrival" as const,
|
type: "arrival" as const,
|
||||||
@@ -154,6 +266,13 @@ describe("buildArrivalSearchSeo", () => {
|
|||||||
expect(result.title).toContain("arrivalCity=JFK");
|
expect(result.title).toContain("arrivalCity=JFK");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("title date uses formatDateForTitle; description keeps dd.MM.yyyy", () => {
|
||||||
|
const result = buildArrivalSearchSeo(stubT, params, "ru", CANONICAL);
|
||||||
|
|
||||||
|
expect(result.title).toContain("date=15.01.2025");
|
||||||
|
expect(result.description).toContain("date=15.01.2025");
|
||||||
|
});
|
||||||
|
|
||||||
it("sets canonical to the arrival search URL", () => {
|
it("sets canonical to the arrival search URL", () => {
|
||||||
const result = buildArrivalSearchSeo(stubT, params, "ru", CANONICAL);
|
const result = buildArrivalSearchSeo(stubT, params, "ru", CANONICAL);
|
||||||
|
|
||||||
@@ -163,6 +282,10 @@ describe("buildArrivalSearchSeo", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// buildRouteSearchSeo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("buildRouteSearchSeo", () => {
|
describe("buildRouteSearchSeo", () => {
|
||||||
const params = {
|
const params = {
|
||||||
type: "route" as const,
|
type: "route" as const,
|
||||||
@@ -192,6 +315,13 @@ describe("buildRouteSearchSeo", () => {
|
|||||||
expect(result.title).toContain("arrivalCity=JFK");
|
expect(result.title).toContain("arrivalCity=JFK");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("title date uses formatDateForTitle; description keeps dd.MM.yyyy", () => {
|
||||||
|
const result = buildRouteSearchSeo(stubT, params, "ru", CANONICAL);
|
||||||
|
|
||||||
|
expect(result.title).toContain("date=15.01.2025");
|
||||||
|
expect(result.description).toContain("date=15.01.2025");
|
||||||
|
});
|
||||||
|
|
||||||
it("sets canonical to the route search URL", () => {
|
it("sets canonical to the route search URL", () => {
|
||||||
const result = buildRouteSearchSeo(stubT, params, "en", CANONICAL);
|
const result = buildRouteSearchSeo(stubT, params, "en", CANONICAL);
|
||||||
|
|
||||||
@@ -201,6 +331,10 @@ describe("buildRouteSearchSeo", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// buildFlightDetailsSeo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("buildFlightDetailsSeo", () => {
|
describe("buildFlightDetailsSeo", () => {
|
||||||
const flight: ISimpleFlight = {
|
const flight: ISimpleFlight = {
|
||||||
id: "SU100-20250115",
|
id: "SU100-20250115",
|
||||||
@@ -263,11 +397,48 @@ describe("buildFlightDetailsSeo", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
it("uses FLIGHT-DETAILS translation keys", () => {
|
it("uses FLIGHT-DETAILS.TITLE with flightNumber and routeCities (no date)", () => {
|
||||||
const result = buildFlightDetailsSeo(stubT, flight, "ru", CANONICAL);
|
const result = buildFlightDetailsSeo(stubT, flight, "ru", CANONICAL);
|
||||||
|
|
||||||
expect(result.title).toContain("SEO.BOARD.FLIGHT-DETAILS.TITLE");
|
expect(result.title).toContain("SEO.BOARD.FLIGHT-DETAILS.TITLE");
|
||||||
expect(result.title).toContain("flightNumber=SU 0100");
|
expect(result.title).toContain("flightNumber=SU 0100");
|
||||||
|
expect(result.title).toContain("routeCities=Moscow-New York");
|
||||||
|
// No date interpolation in title
|
||||||
|
expect(result.title).not.toContain("date=");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("description keeps dd.MM.yyyy date", () => {
|
||||||
|
const result = buildFlightDetailsSeo(stubT, flight, "ru", CANONICAL);
|
||||||
|
|
||||||
|
expect(result.description).toContain("SEO.BOARD.FLIGHT-DETAILS.DESCRIPTION");
|
||||||
|
expect(result.description).toContain("flightNumber=SU 0100");
|
||||||
|
expect(result.description).toContain("date=15.01.2025");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses TITLE-NO-ROUTE when cityNames not available (from id only)", () => {
|
||||||
|
const result = buildFlightDetailsSeoFromId(
|
||||||
|
stubT,
|
||||||
|
{ carrier: "SU", flightNumber: "0100", date: "20250115" },
|
||||||
|
"ru",
|
||||||
|
CANONICAL,
|
||||||
|
// no cityNames provided
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.title).toContain("SEO.BOARD.FLIGHT-DETAILS.TITLE-NO-ROUTE");
|
||||||
|
expect(result.title).toContain("flightNumber=SU 0100");
|
||||||
|
expect(result.title).not.toContain("routeCities=");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses explicit cityNames over leg cities when both present", () => {
|
||||||
|
const result = buildFlightDetailsSeo(
|
||||||
|
stubT,
|
||||||
|
flight,
|
||||||
|
"ru",
|
||||||
|
CANONICAL,
|
||||||
|
{ departure: "Москва", arrival: "Нью-Йорк" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.title).toContain("routeCities=Москва-Нью-Йорк");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets canonical to the details URL", () => {
|
it("sets canonical to the details URL", () => {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const SITE_NAME = "Aeroflot";
|
|||||||
/**
|
/**
|
||||||
* Format a 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).
|
* Accepts both 'yyyyMMdd' (URL param shape) and 'yyyy-MM-dd' (API response).
|
||||||
|
* Used for descriptions only — titles use formatDateForTitle.
|
||||||
*/
|
*/
|
||||||
function formatDateForSeo(input: string): string {
|
function formatDateForSeo(input: string): string {
|
||||||
const digits = input.replace(/-/g, "");
|
const digits = input.replace(/-/g, "");
|
||||||
@@ -57,6 +58,33 @@ function formatDateForSeo(input: string): string {
|
|||||||
return `${day}.${month}.${year}`;
|
return `${day}.${month}.${year}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date for title display per TZ §4.1.3 Table 6:
|
||||||
|
* - t("SHARED.TODAY") if date is today (local)
|
||||||
|
* - t("SHARED.TOMORROW") if date is today + 1 day (local)
|
||||||
|
* - "dd.MM.yyyy" otherwise
|
||||||
|
*
|
||||||
|
* Input: "yyyyMMdd" (URL shape) or "yyyy-MM-dd" (API shape).
|
||||||
|
*/
|
||||||
|
function formatDateForTitle(input: string, t: TFunction): string {
|
||||||
|
const digits = input.replace(/-/g, "");
|
||||||
|
if (digits.length !== 8) return input;
|
||||||
|
|
||||||
|
const y = Number(digits.slice(0, 4));
|
||||||
|
const m = Number(digits.slice(4, 6)) - 1;
|
||||||
|
const d = Number(digits.slice(6, 8));
|
||||||
|
const inputDate = new Date(y, m, d);
|
||||||
|
inputDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const deltaDays = Math.round((inputDate.getTime() - today.getTime()) / 86_400_000);
|
||||||
|
if (deltaDays === 0) return t("SHARED.TODAY");
|
||||||
|
if (deltaDays === 1) return t("SHARED.TOMORROW");
|
||||||
|
return `${digits.slice(6, 8)}.${digits.slice(4, 6)}.${digits.slice(0, 4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the canonical URL for a given set of params + locale.
|
* Build the canonical URL for a given set of params + locale.
|
||||||
*/
|
*/
|
||||||
@@ -153,15 +181,16 @@ export function buildFlightSearchSeo(
|
|||||||
canonicalOrigin: string,
|
canonicalOrigin: string,
|
||||||
): SeoHeadProps {
|
): SeoHeadProps {
|
||||||
const flightDisplay = `${params.carrier} ${params.flightNumber}${params.suffix ?? ""}`;
|
const flightDisplay = `${params.carrier} ${params.flightNumber}${params.suffix ?? ""}`;
|
||||||
const dateDisplay = formatDateForSeo(params.date);
|
const dateForTitle = formatDateForTitle(params.date, t);
|
||||||
|
const dateForDesc = formatDateForSeo(params.date);
|
||||||
|
|
||||||
const title = t("SEO.BOARD.FLIGHT-SEARCH.TITLE", {
|
const title = t("SEO.BOARD.FLIGHT-SEARCH.TITLE", {
|
||||||
flightNumber: flightDisplay,
|
flightNumber: flightDisplay,
|
||||||
date: dateDisplay,
|
date: dateForTitle,
|
||||||
});
|
});
|
||||||
const description = t("SEO.BOARD.FLIGHT-SEARCH.DESCRIPTION", {
|
const description = t("SEO.BOARD.FLIGHT-SEARCH.DESCRIPTION", {
|
||||||
flightNumber: flightDisplay,
|
flightNumber: flightDisplay,
|
||||||
date: dateDisplay,
|
date: dateForDesc,
|
||||||
});
|
});
|
||||||
const canonical = buildCanonical(canonicalOrigin, locale, params);
|
const canonical = buildCanonical(canonicalOrigin, locale, params);
|
||||||
const hreflangPath = buildPathWithoutLocale(params);
|
const hreflangPath = buildPathWithoutLocale(params);
|
||||||
@@ -193,15 +222,16 @@ export function buildDepartureSearchSeo(
|
|||||||
cityNames?: CityNames,
|
cityNames?: CityNames,
|
||||||
): SeoHeadProps {
|
): SeoHeadProps {
|
||||||
const departureCity = cityNames?.departure ?? params.station;
|
const departureCity = cityNames?.departure ?? params.station;
|
||||||
const dateDisplay = formatDateForSeo(params.date);
|
const dateForTitle = formatDateForTitle(params.date, t);
|
||||||
|
const dateForDesc = formatDateForSeo(params.date);
|
||||||
|
|
||||||
const title = t("SEO.BOARD.DEPARTURE-SEARCH.TITLE", {
|
const title = t("SEO.BOARD.DEPARTURE-SEARCH.TITLE", {
|
||||||
departureCity,
|
departureCity,
|
||||||
date: dateDisplay,
|
date: dateForTitle,
|
||||||
});
|
});
|
||||||
const description = t("SEO.BOARD.DEPARTURE-SEARCH.DESCRIPTION", {
|
const description = t("SEO.BOARD.DEPARTURE-SEARCH.DESCRIPTION", {
|
||||||
departureCity,
|
departureCity,
|
||||||
date: dateDisplay,
|
date: dateForDesc,
|
||||||
});
|
});
|
||||||
const canonical = buildCanonical(canonicalOrigin, locale, params);
|
const canonical = buildCanonical(canonicalOrigin, locale, params);
|
||||||
const hreflangPath = buildPathWithoutLocale(params);
|
const hreflangPath = buildPathWithoutLocale(params);
|
||||||
@@ -233,15 +263,16 @@ export function buildArrivalSearchSeo(
|
|||||||
cityNames?: CityNames,
|
cityNames?: CityNames,
|
||||||
): SeoHeadProps {
|
): SeoHeadProps {
|
||||||
const arrivalCity = cityNames?.arrival ?? params.station;
|
const arrivalCity = cityNames?.arrival ?? params.station;
|
||||||
const dateDisplay = formatDateForSeo(params.date);
|
const dateForTitle = formatDateForTitle(params.date, t);
|
||||||
|
const dateForDesc = formatDateForSeo(params.date);
|
||||||
|
|
||||||
const title = t("SEO.BOARD.ARRIVAL-SEARCH.TITLE", {
|
const title = t("SEO.BOARD.ARRIVAL-SEARCH.TITLE", {
|
||||||
arrivalCity,
|
arrivalCity,
|
||||||
date: dateDisplay,
|
date: dateForTitle,
|
||||||
});
|
});
|
||||||
const description = t("SEO.BOARD.ARRIVAL-SEARCH.DESCRIPTION", {
|
const description = t("SEO.BOARD.ARRIVAL-SEARCH.DESCRIPTION", {
|
||||||
arrivalCity,
|
arrivalCity,
|
||||||
date: dateDisplay,
|
date: dateForDesc,
|
||||||
});
|
});
|
||||||
const canonical = buildCanonical(canonicalOrigin, locale, params);
|
const canonical = buildCanonical(canonicalOrigin, locale, params);
|
||||||
const hreflangPath = buildPathWithoutLocale(params);
|
const hreflangPath = buildPathWithoutLocale(params);
|
||||||
@@ -274,17 +305,18 @@ export function buildRouteSearchSeo(
|
|||||||
): SeoHeadProps {
|
): SeoHeadProps {
|
||||||
const departureCity = cityNames?.departure ?? params.departure;
|
const departureCity = cityNames?.departure ?? params.departure;
|
||||||
const arrivalCity = cityNames?.arrival ?? params.arrival;
|
const arrivalCity = cityNames?.arrival ?? params.arrival;
|
||||||
const dateDisplay = formatDateForSeo(params.date);
|
const dateForTitle = formatDateForTitle(params.date, t);
|
||||||
|
const dateForDesc = formatDateForSeo(params.date);
|
||||||
|
|
||||||
const title = t("SEO.BOARD.ROUTE-SEARCH.TITLE", {
|
const title = t("SEO.BOARD.ROUTE-SEARCH.TITLE", {
|
||||||
departureCity,
|
departureCity,
|
||||||
arrivalCity,
|
arrivalCity,
|
||||||
date: dateDisplay,
|
date: dateForTitle,
|
||||||
});
|
});
|
||||||
const description = t("SEO.BOARD.ROUTE-SEARCH.DESCRIPTION", {
|
const description = t("SEO.BOARD.ROUTE-SEARCH.DESCRIPTION", {
|
||||||
departureCity,
|
departureCity,
|
||||||
arrivalCity,
|
arrivalCity,
|
||||||
date: dateDisplay,
|
date: dateForDesc,
|
||||||
});
|
});
|
||||||
const canonical = buildCanonical(canonicalOrigin, locale, params);
|
const canonical = buildCanonical(canonicalOrigin, locale, params);
|
||||||
const hreflangPath = buildPathWithoutLocale(params);
|
const hreflangPath = buildPathWithoutLocale(params);
|
||||||
@@ -310,37 +342,64 @@ export function buildRouteSearchSeo(
|
|||||||
* Accepts a plain object so the route page can render SeoHead
|
* Accepts a plain object so the route page can render SeoHead
|
||||||
* synchronously without waiting for the flight data to load — SSR
|
* synchronously without waiting for the flight data to load — SSR
|
||||||
* needs the title + meta in the <head> of the first response.
|
* needs the title + meta in the <head> of the first response.
|
||||||
|
*
|
||||||
|
* @param cityNames - Optional departure/arrival city names. When available
|
||||||
|
* (e.g. after flight data has loaded client-side), supply these so the
|
||||||
|
* title includes the route cities per TZ Table 6 rows 6-8. When not
|
||||||
|
* available at SSR time the title will omit the route segment.
|
||||||
*/
|
*/
|
||||||
export function buildFlightDetailsSeoFromId(
|
export function buildFlightDetailsSeoFromId(
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
flightId: { carrier: string; flightNumber: string; suffix?: string; date: string },
|
flightId: { carrier: string; flightNumber: string; suffix?: string; date: string },
|
||||||
locale: string,
|
locale: string,
|
||||||
canonicalOrigin: string,
|
canonicalOrigin: string,
|
||||||
|
cityNames?: CityNames,
|
||||||
): SeoHeadProps {
|
): SeoHeadProps {
|
||||||
const flight: ISimpleFlight = { flightId } as unknown as ISimpleFlight;
|
const flight: ISimpleFlight = { flightId } as unknown as ISimpleFlight;
|
||||||
return buildFlightDetailsSeo(t, flight, locale, canonicalOrigin);
|
return buildFlightDetailsSeo(t, flight, locale, canonicalOrigin, cityNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SEO props for flight details page.
|
* SEO props for flight details page.
|
||||||
|
* Title per TZ Table 6 rows 6-8: "Информация о рейсе: {flightNumber}, {fromCity}-{toCity}"
|
||||||
|
* No date in the title. Description keeps the full dd.MM.yyyy date.
|
||||||
*/
|
*/
|
||||||
export function buildFlightDetailsSeo(
|
export function buildFlightDetailsSeo(
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
flight: ISimpleFlight,
|
flight: ISimpleFlight,
|
||||||
locale: string,
|
locale: string,
|
||||||
canonicalOrigin: string,
|
canonicalOrigin: string,
|
||||||
|
cityNames?: CityNames,
|
||||||
): SeoHeadProps {
|
): SeoHeadProps {
|
||||||
const { carrier, flightNumber, suffix, date } = flight.flightId;
|
const { carrier, flightNumber, suffix, date } = flight.flightId;
|
||||||
const flightDisplay = `${carrier} ${flightNumber}${suffix ?? ""}`;
|
const flightDisplay = `${carrier} ${flightNumber}${suffix ?? ""}`;
|
||||||
const dateDisplay = formatDateForSeo(date);
|
const dateForDesc = formatDateForSeo(date);
|
||||||
|
|
||||||
|
// Derive route cities: prefer explicit cityNames, then flight leg data.
|
||||||
|
let departureCity = cityNames?.departure;
|
||||||
|
let arrivalCity = cityNames?.arrival;
|
||||||
|
if (!departureCity || !arrivalCity) {
|
||||||
|
if (flight.routeType === "Direct") {
|
||||||
|
departureCity ??= flight.leg.departure.scheduled.city;
|
||||||
|
arrivalCity ??= flight.leg.arrival.scheduled.city;
|
||||||
|
} else if (flight.routeType === "MultiLeg" && flight.legs.length > 0) {
|
||||||
|
const firstLeg = flight.legs[0];
|
||||||
|
const lastLeg = flight.legs[flight.legs.length - 1];
|
||||||
|
if (firstLeg) departureCity ??= firstLeg.departure.scheduled.city;
|
||||||
|
if (lastLeg) arrivalCity ??= lastLeg.arrival.scheduled.city;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeCities =
|
||||||
|
departureCity && arrivalCity ? `${departureCity}-${arrivalCity}` : undefined;
|
||||||
|
|
||||||
|
const title = routeCities
|
||||||
|
? t("SEO.BOARD.FLIGHT-DETAILS.TITLE", { flightNumber: flightDisplay, routeCities })
|
||||||
|
: t("SEO.BOARD.FLIGHT-DETAILS.TITLE-NO-ROUTE", { flightNumber: flightDisplay });
|
||||||
|
|
||||||
const title = t("SEO.BOARD.FLIGHT-DETAILS.TITLE", {
|
|
||||||
flightNumber: flightDisplay,
|
|
||||||
date: dateDisplay,
|
|
||||||
});
|
|
||||||
const description = t("SEO.BOARD.FLIGHT-DETAILS.DESCRIPTION", {
|
const description = t("SEO.BOARD.FLIGHT-DETAILS.DESCRIPTION", {
|
||||||
flightNumber: flightDisplay,
|
flightNumber: flightDisplay,
|
||||||
date: dateDisplay,
|
date: dateForDesc,
|
||||||
});
|
});
|
||||||
|
|
||||||
const detailsParams: OnlineBoardParams = suffix
|
const detailsParams: OnlineBoardParams = suffix
|
||||||
|
|||||||
@@ -201,7 +201,8 @@
|
|||||||
},
|
},
|
||||||
"FLIGHT-DETAILS": {
|
"FLIGHT-DETAILS": {
|
||||||
"DESCRIPTION": "",
|
"DESCRIPTION": "",
|
||||||
"TITLE": ""
|
"TITLE": "",
|
||||||
|
"TITLE-NO-ROUTE": ""
|
||||||
},
|
},
|
||||||
"FLIGHT-SEARCH": {
|
"FLIGHT-SEARCH": {
|
||||||
"DESCRIPTION": "",
|
"DESCRIPTION": "",
|
||||||
|
|||||||
@@ -226,27 +226,28 @@
|
|||||||
"BOARD": {
|
"BOARD": {
|
||||||
"ARRIVAL-SEARCH": {
|
"ARRIVAL-SEARCH": {
|
||||||
"DESCRIPTION": "Up-to-date list of Aeroflot flights arriving on {date}. Online arrivals board for {arrivalCity}.",
|
"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}"
|
"TITLE": "Arrivals: {arrivalCity}, {date}"
|
||||||
},
|
},
|
||||||
"DEPARTURE-SEARCH": {
|
"DEPARTURE-SEARCH": {
|
||||||
"DESCRIPTION": "Up-to-date list of Aeroflot flights departing on {date}. Online departures board for {departureCity}.",
|
"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}"
|
"TITLE": "Departures: {departureCity}, {date}"
|
||||||
},
|
},
|
||||||
"FLIGHT-DETAILS": {
|
"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.",
|
"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"
|
"TITLE": "Flight info: {flightNumber}, {routeCities}",
|
||||||
|
"TITLE-NO-ROUTE": "Flight info: {flightNumber}"
|
||||||
},
|
},
|
||||||
"FLIGHT-SEARCH": {
|
"FLIGHT-SEARCH": {
|
||||||
"DESCRIPTION": "Departure and arrival information for flight {flightNumber} on {date}.",
|
"DESCRIPTION": "Departure and arrival information for flight {flightNumber} on {date}.",
|
||||||
"TITLE": "Flight {flightNumber} – Online arrivals and departures board {date} | Aeroflot"
|
"TITLE": "Flight: {flightNumber}, {date}"
|
||||||
},
|
},
|
||||||
"MAIN": {
|
"MAIN": {
|
||||||
"DESCRIPTION": "Arrivals and departures board for Aeroflot airline. Real-time flight arrival and departure information.",
|
"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"
|
"TITLE": "Online Board"
|
||||||
},
|
},
|
||||||
"ROUTE-SEARCH": {
|
"ROUTE-SEARCH": {
|
||||||
"DESCRIPTION": "Arrivals and departures board for Aeroflot flights on the {departureCity} - {arrivalCity} route. Real-time flight information for {date}.",
|
"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"
|
"TITLE": "Route: {departureCity}-{arrivalCity}, {date}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"SCHEDULE": {
|
"SCHEDULE": {
|
||||||
|
|||||||
@@ -201,7 +201,8 @@
|
|||||||
},
|
},
|
||||||
"FLIGHT-DETAILS": {
|
"FLIGHT-DETAILS": {
|
||||||
"DESCRIPTION": "",
|
"DESCRIPTION": "",
|
||||||
"TITLE": ""
|
"TITLE": "",
|
||||||
|
"TITLE-NO-ROUTE": ""
|
||||||
},
|
},
|
||||||
"FLIGHT-SEARCH": {
|
"FLIGHT-SEARCH": {
|
||||||
"DESCRIPTION": "",
|
"DESCRIPTION": "",
|
||||||
|
|||||||
@@ -201,7 +201,8 @@
|
|||||||
},
|
},
|
||||||
"FLIGHT-DETAILS": {
|
"FLIGHT-DETAILS": {
|
||||||
"DESCRIPTION": "",
|
"DESCRIPTION": "",
|
||||||
"TITLE": ""
|
"TITLE": "",
|
||||||
|
"TITLE-NO-ROUTE": ""
|
||||||
},
|
},
|
||||||
"FLIGHT-SEARCH": {
|
"FLIGHT-SEARCH": {
|
||||||
"DESCRIPTION": "",
|
"DESCRIPTION": "",
|
||||||
|
|||||||
@@ -201,7 +201,8 @@
|
|||||||
},
|
},
|
||||||
"FLIGHT-DETAILS": {
|
"FLIGHT-DETAILS": {
|
||||||
"DESCRIPTION": "",
|
"DESCRIPTION": "",
|
||||||
"TITLE": ""
|
"TITLE": "",
|
||||||
|
"TITLE-NO-ROUTE": ""
|
||||||
},
|
},
|
||||||
"FLIGHT-SEARCH": {
|
"FLIGHT-SEARCH": {
|
||||||
"DESCRIPTION": "",
|
"DESCRIPTION": "",
|
||||||
|
|||||||
@@ -201,7 +201,8 @@
|
|||||||
},
|
},
|
||||||
"FLIGHT-DETAILS": {
|
"FLIGHT-DETAILS": {
|
||||||
"DESCRIPTION": "",
|
"DESCRIPTION": "",
|
||||||
"TITLE": ""
|
"TITLE": "",
|
||||||
|
"TITLE-NO-ROUTE": ""
|
||||||
},
|
},
|
||||||
"FLIGHT-SEARCH": {
|
"FLIGHT-SEARCH": {
|
||||||
"DESCRIPTION": "",
|
"DESCRIPTION": "",
|
||||||
|
|||||||
@@ -201,7 +201,8 @@
|
|||||||
},
|
},
|
||||||
"FLIGHT-DETAILS": {
|
"FLIGHT-DETAILS": {
|
||||||
"DESCRIPTION": "",
|
"DESCRIPTION": "",
|
||||||
"TITLE": ""
|
"TITLE": "",
|
||||||
|
"TITLE-NO-ROUTE": ""
|
||||||
},
|
},
|
||||||
"FLIGHT-SEARCH": {
|
"FLIGHT-SEARCH": {
|
||||||
"DESCRIPTION": "",
|
"DESCRIPTION": "",
|
||||||
|
|||||||
@@ -226,27 +226,28 @@
|
|||||||
"BOARD": {
|
"BOARD": {
|
||||||
"ARRIVAL-SEARCH": {
|
"ARRIVAL-SEARCH": {
|
||||||
"DESCRIPTION": "Актуальный список рейсов авиакомпании Аэрофлот, прибывающих {date}. Онлайн-табло прилетов в {arrivalCity}.",
|
"DESCRIPTION": "Актуальный список рейсов авиакомпании Аэрофлот, прибывающих {date}. Онлайн-табло прилетов в {arrivalCity}.",
|
||||||
"TITLE": "Онлайн-табло прилетов в {arrivalCity} | Прибытие рейсов от Аэрофлот {date}"
|
"TITLE": "Прилет: {arrivalCity}, {date}"
|
||||||
},
|
},
|
||||||
"DEPARTURE-SEARCH": {
|
"DEPARTURE-SEARCH": {
|
||||||
"DESCRIPTION": "Актуальный список рейсов авиакомпании Аэрофлот, отправляющихся {date}. Онлайн-табло вылетов из {departureCity}.",
|
"DESCRIPTION": "Актуальный список рейсов авиакомпании Аэрофлот, отправляющихся {date}. Онлайн-табло вылетов из {departureCity}.",
|
||||||
"TITLE": "Онлайн-табло вылетов из {departureCity} | Отправление рейсов Аэрофлот {date}"
|
"TITLE": "Вылет: {departureCity}, {date}"
|
||||||
},
|
},
|
||||||
"FLIGHT-DETAILS": {
|
"FLIGHT-DETAILS": {
|
||||||
"DESCRIPTION": "Информация об отправлении и прибытии рейса {flightNumber} в режиме онлайн! Время вылета, время прилета, актуальный статус рейса на официальном сайте авиакомпании Аэрофлот.",
|
"DESCRIPTION": "Информация об отправлении и прибытии рейса {flightNumber} в режиме онлайн! Время вылета, время прилета, актуальный статус рейса на официальном сайте авиакомпании Аэрофлот.",
|
||||||
"TITLE": "Статус рейса {flightNumber} {date} | Аэрофлот"
|
"TITLE": "Информация о рейсе: {flightNumber}, {routeCities}",
|
||||||
|
"TITLE-NO-ROUTE": "Информация о рейсе: {flightNumber}"
|
||||||
},
|
},
|
||||||
"FLIGHT-SEARCH": {
|
"FLIGHT-SEARCH": {
|
||||||
"DESCRIPTION": "Информация об отправлении и прибытии рейса {flightNumber} {date}.",
|
"DESCRIPTION": "Информация об отправлении и прибытии рейса {flightNumber} {date}.",
|
||||||
"TITLE": "Рейс {flightNumber} – Онлайн-табло прилета и вылета {date} | Аэрофлот"
|
"TITLE": "Рейс: {flightNumber}, {date}"
|
||||||
},
|
},
|
||||||
"MAIN": {
|
"MAIN": {
|
||||||
"DESCRIPTION": "Табло прибытия и отправления рейсов авиакомпании 'Аэрофлот'. Информация о прилетах и вылетах в режиме онлайн.",
|
"DESCRIPTION": "Табло прибытия и отправления рейсов авиакомпании 'Аэрофлот'. Информация о прилетах и вылетах в режиме онлайн.",
|
||||||
"TITLE": "Онлайн-табло вылетов и прилетов рейсов авиакомпании Аэрофлот | Аэрофлот"
|
"TITLE": "Онлайн-Табло"
|
||||||
},
|
},
|
||||||
"ROUTE-SEARCH": {
|
"ROUTE-SEARCH": {
|
||||||
"DESCRIPTION": "Табло прибытия и отправления рейсов авиакомпании Аэрофлот по направлению {departureCity} - {arrivalCity}. Информация о прилетах и вылетах в режиме онлайн на {date}.",
|
"DESCRIPTION": "Табло прибытия и отправления рейсов авиакомпании Аэрофлот по направлению {departureCity} - {arrivalCity}. Информация о прилетах и вылетах в режиме онлайн на {date}.",
|
||||||
"TITLE": "Прибытие и отправление рейсов {departureCity} - {arrivalCity} {date} | Аэрофлот"
|
"TITLE": "Маршрут: {departureCity}-{arrivalCity}, {date}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"SCHEDULE": {
|
"SCHEDULE": {
|
||||||
|
|||||||
@@ -201,7 +201,8 @@
|
|||||||
},
|
},
|
||||||
"FLIGHT-DETAILS": {
|
"FLIGHT-DETAILS": {
|
||||||
"DESCRIPTION": "",
|
"DESCRIPTION": "",
|
||||||
"TITLE": ""
|
"TITLE": "",
|
||||||
|
"TITLE-NO-ROUTE": ""
|
||||||
},
|
},
|
||||||
"FLIGHT-SEARCH": {
|
"FLIGHT-SEARCH": {
|
||||||
"DESCRIPTION": "",
|
"DESCRIPTION": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user