plan/react-rewrite #1
@@ -0,0 +1,86 @@
|
||||
# Phase 2F — SEO + JSON-LD for Online Board
|
||||
|
||||
> **Parent plan:** `2026-04-14-phase-2-online-board-master.md` (sub-plan 2F)
|
||||
> **Depends on:** 2E (route pages), 1F-seo (`SeoHead`, `buildHreflangSet`, `JsonLdRenderer`), 1C (i18n)
|
||||
|
||||
## Goal
|
||||
|
||||
Wire SEO metadata (title, description, canonical, hreflang, OG, Twitter Card) and JSON-LD structured data (`Flight`, `ItemList`) into all 6 Online Board route pages. SEO builders are pure functions; JSON-LD builders produce `schema-dts` typed objects.
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. `src/features/online-board/seo.ts` — 6 builder functions returning `SeoHeadProps`
|
||||
2. `src/features/online-board/json-ld.ts` — 2 JSON-LD builder functions
|
||||
3. Updated route pages wiring `<SeoHead>` and `<JsonLdRenderer>`
|
||||
4. Populated EN locale SEO keys (RU already has values)
|
||||
|
||||
## Tasks
|
||||
|
||||
### T1 — Populate EN locale SEO keys
|
||||
|
||||
The EN locale file has empty SEO.BOARD keys. Add English translations matching the Russian pattern.
|
||||
|
||||
- File: `src/i18n/locales/en/common.json`
|
||||
- Keys: `SEO.BOARD.MAIN`, `SEO.BOARD.FLIGHT-SEARCH`, `SEO.BOARD.DEPARTURE-SEARCH`, `SEO.BOARD.ARRIVAL-SEARCH`, `SEO.BOARD.ROUTE-SEARCH`, `SEO.BOARD.FLIGHT-DETAILS`
|
||||
|
||||
### T2 — TDD: SEO builder functions (`seo.ts`)
|
||||
|
||||
Write tests first in `src/features/online-board/seo.test.ts`, then implement in `src/features/online-board/seo.ts`.
|
||||
|
||||
**Functions:**
|
||||
- `buildOnlineBoardStartSeo(locale, canonicalOrigin)` — start page SEO
|
||||
- `buildFlightSearchSeo(params, locale, canonicalOrigin, cityNames?)` — flight number search
|
||||
- `buildDepartureSearchSeo(params, locale, canonicalOrigin, cityNames?)` — departure search
|
||||
- `buildArrivalSearchSeo(params, locale, canonicalOrigin, cityNames?)` — arrival search
|
||||
- `buildRouteSearchSeo(params, locale, canonicalOrigin, cityNames?)` — route search
|
||||
- `buildFlightDetailsSeo(flight, locale, canonicalOrigin)` — flight details page
|
||||
|
||||
Each returns `SeoHeadProps` with title, description, canonical, hreflang, og, twitter.
|
||||
|
||||
**Test strategy:** Unit tests with mocked i18n `t()` function verifying:
|
||||
- Correct translation keys passed to `t()`
|
||||
- Correct interpolation variables
|
||||
- Canonical URL structure
|
||||
- Hreflang set included
|
||||
- OG tags populated
|
||||
- Twitter card set
|
||||
|
||||
### T3 — TDD: JSON-LD builder functions (`json-ld.ts`)
|
||||
|
||||
Write tests first in `src/features/online-board/json-ld.test.ts`, then implement in `src/features/online-board/json-ld.ts`.
|
||||
|
||||
**Functions:**
|
||||
- `buildFlightJsonLd(flight: ISimpleFlight)` — returns `schema-dts` `Flight` thing
|
||||
- `buildFlightListJsonLd(flights: ISimpleFlight[], searchDescription: string)` — returns `ItemList`
|
||||
|
||||
**Test strategy:** Unit tests verifying:
|
||||
- Correct `@type` set
|
||||
- Flight number, departure/arrival airports mapped
|
||||
- Departure/arrival times mapped
|
||||
- `ItemList` wraps flights as `ListItem` elements
|
||||
- Type-checks against `schema-dts` types
|
||||
|
||||
### T4 — Wire SEO into route pages
|
||||
|
||||
Update the 6 route page files to import and use `<SeoHead>` with appropriate builder + `<JsonLdRenderer>`. No TDD for this step (visual verification).
|
||||
|
||||
- `src/routes/[lang]/onlineboard/page.tsx` — start page
|
||||
- `src/routes/[lang]/onlineboard/flight/[params]/page.tsx` — flight search
|
||||
- `src/routes/[lang]/onlineboard/departure/[params]/page.tsx` — departure search
|
||||
- `src/routes/[lang]/onlineboard/arrival/[params]/page.tsx` — arrival search
|
||||
- `src/routes/[lang]/onlineboard/route/[params]/page.tsx` — route search
|
||||
- `src/routes/[lang]/onlineboard/[params]/page.tsx` — flight details
|
||||
|
||||
### T5 — Export from barrel
|
||||
|
||||
Add SEO + JSON-LD exports to `src/features/online-board/index.ts`.
|
||||
|
||||
### T6 — Verify
|
||||
|
||||
Run `pnpm typecheck && pnpm lint && pnpm test && pnpm build:standalone`.
|
||||
|
||||
## Commit plan
|
||||
|
||||
1. **Commit 1:** EN locale SEO keys + SEO builder tests + implementation (`seo.ts`)
|
||||
2. **Commit 2:** JSON-LD builder tests + implementation (`json-ld.ts`)
|
||||
3. **Commit 3:** Wire SEO into route pages + barrel exports + verify
|
||||
@@ -0,0 +1,292 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildOnlineBoardStartSeo,
|
||||
buildFlightSearchSeo,
|
||||
buildDepartureSearchSeo,
|
||||
buildArrivalSearchSeo,
|
||||
buildRouteSearchSeo,
|
||||
buildFlightDetailsSeo,
|
||||
} from "./seo.js";
|
||||
import type { ISimpleFlight } from "./types.js";
|
||||
|
||||
/** Stub t() that returns the key + interpolation vars for assertion. */
|
||||
function stubT(key: string, opts?: Record<string, unknown>): string {
|
||||
if (opts && Object.keys(opts).length > 0) {
|
||||
const vars = Object.entries(opts)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(",");
|
||||
return `${key}|${vars}`;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
const CANONICAL = "https://www.aeroflot.ru";
|
||||
|
||||
describe("buildOnlineBoardStartSeo", () => {
|
||||
it("uses MAIN translation keys for title and description", () => {
|
||||
const result = buildOnlineBoardStartSeo(stubT, "ru", CANONICAL);
|
||||
|
||||
expect(result.title).toBe("SEO.BOARD.MAIN.TITLE");
|
||||
expect(result.description).toBe("SEO.BOARD.MAIN.DESCRIPTION");
|
||||
});
|
||||
|
||||
it("sets canonical to /{locale}/onlineboard", () => {
|
||||
const result = buildOnlineBoardStartSeo(stubT, "en", CANONICAL);
|
||||
|
||||
expect(result.canonical).toBe("https://www.aeroflot.ru/en/onlineboard");
|
||||
});
|
||||
|
||||
it("includes hreflang with 10 entries (9 langs + x-default)", () => {
|
||||
const result = buildOnlineBoardStartSeo(stubT, "ru", CANONICAL);
|
||||
|
||||
expect(result.hreflang).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("sets og tags", () => {
|
||||
const result = buildOnlineBoardStartSeo(stubT, "ru", CANONICAL);
|
||||
|
||||
expect(result.og.title).toBe(result.title);
|
||||
expect(result.og.description).toBe(result.description);
|
||||
expect(result.og.url).toBe(result.canonical);
|
||||
expect(result.og.type).toBe("website");
|
||||
expect(result.og.locale).toBe("ru");
|
||||
expect(result.og.siteName).toBe("Aeroflot");
|
||||
expect(result.og.image).toContain("aeroflot");
|
||||
});
|
||||
|
||||
it("sets twitter card", () => {
|
||||
const result = buildOnlineBoardStartSeo(stubT, "ru", CANONICAL);
|
||||
|
||||
expect(result.twitter).toBeDefined();
|
||||
expect(result.twitter!.card).toBe("summary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFlightSearchSeo", () => {
|
||||
const params = {
|
||||
type: "flight" as const,
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
date: "20250115",
|
||||
};
|
||||
|
||||
it("uses FLIGHT-SEARCH translation keys with interpolation", () => {
|
||||
const result = buildFlightSearchSeo(stubT, params, "ru", CANONICAL);
|
||||
|
||||
expect(result.title).toContain("SEO.BOARD.FLIGHT-SEARCH.TITLE");
|
||||
expect(result.title).toContain("flightNumber=SU 0100");
|
||||
expect(result.description).toContain("SEO.BOARD.FLIGHT-SEARCH.DESCRIPTION");
|
||||
});
|
||||
|
||||
it("sets canonical to the flight search URL", () => {
|
||||
const result = buildFlightSearchSeo(stubT, params, "ru", CANONICAL);
|
||||
|
||||
expect(result.canonical).toBe(
|
||||
"https://www.aeroflot.ru/ru/onlineboard/flight/SU0100-20250115",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes hreflang entries", () => {
|
||||
const result = buildFlightSearchSeo(stubT, params, "ru", CANONICAL);
|
||||
|
||||
expect(result.hreflang).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDepartureSearchSeo", () => {
|
||||
const params = {
|
||||
type: "departure" as const,
|
||||
station: "SVO",
|
||||
date: "20250115",
|
||||
};
|
||||
|
||||
it("uses DEPARTURE-SEARCH translation keys with city name", () => {
|
||||
const result = buildDepartureSearchSeo(
|
||||
stubT,
|
||||
params,
|
||||
"ru",
|
||||
CANONICAL,
|
||||
{ departure: "Moscow" },
|
||||
);
|
||||
|
||||
expect(result.title).toContain("SEO.BOARD.DEPARTURE-SEARCH.TITLE");
|
||||
expect(result.title).toContain("departureCity=Moscow");
|
||||
});
|
||||
|
||||
it("falls back to station code when no city name provided", () => {
|
||||
const result = buildDepartureSearchSeo(stubT, params, "ru", CANONICAL);
|
||||
|
||||
expect(result.title).toContain("departureCity=SVO");
|
||||
});
|
||||
|
||||
it("sets canonical to the departure search URL", () => {
|
||||
const result = buildDepartureSearchSeo(stubT, params, "en", CANONICAL);
|
||||
|
||||
expect(result.canonical).toBe(
|
||||
"https://www.aeroflot.ru/en/onlineboard/departure/SVO-20250115",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildArrivalSearchSeo", () => {
|
||||
const params = {
|
||||
type: "arrival" as const,
|
||||
station: "JFK",
|
||||
date: "20250115",
|
||||
};
|
||||
|
||||
it("uses ARRIVAL-SEARCH translation keys with city name", () => {
|
||||
const result = buildArrivalSearchSeo(
|
||||
stubT,
|
||||
params,
|
||||
"ru",
|
||||
CANONICAL,
|
||||
{ arrival: "New York" },
|
||||
);
|
||||
|
||||
expect(result.title).toContain("SEO.BOARD.ARRIVAL-SEARCH.TITLE");
|
||||
expect(result.title).toContain("arrivalCity=New York");
|
||||
});
|
||||
|
||||
it("falls back to station code when no city name provided", () => {
|
||||
const result = buildArrivalSearchSeo(stubT, params, "ru", CANONICAL);
|
||||
|
||||
expect(result.title).toContain("arrivalCity=JFK");
|
||||
});
|
||||
|
||||
it("sets canonical to the arrival search URL", () => {
|
||||
const result = buildArrivalSearchSeo(stubT, params, "ru", CANONICAL);
|
||||
|
||||
expect(result.canonical).toBe(
|
||||
"https://www.aeroflot.ru/ru/onlineboard/arrival/JFK-20250115",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRouteSearchSeo", () => {
|
||||
const params = {
|
||||
type: "route" as const,
|
||||
departure: "SVO",
|
||||
arrival: "JFK",
|
||||
date: "20250115",
|
||||
};
|
||||
|
||||
it("uses ROUTE-SEARCH translation keys with both city names", () => {
|
||||
const result = buildRouteSearchSeo(
|
||||
stubT,
|
||||
params,
|
||||
"ru",
|
||||
CANONICAL,
|
||||
{ departure: "Moscow", arrival: "New York" },
|
||||
);
|
||||
|
||||
expect(result.title).toContain("SEO.BOARD.ROUTE-SEARCH.TITLE");
|
||||
expect(result.title).toContain("departureCity=Moscow");
|
||||
expect(result.title).toContain("arrivalCity=New York");
|
||||
});
|
||||
|
||||
it("falls back to IATA codes when no city names provided", () => {
|
||||
const result = buildRouteSearchSeo(stubT, params, "ru", CANONICAL);
|
||||
|
||||
expect(result.title).toContain("departureCity=SVO");
|
||||
expect(result.title).toContain("arrivalCity=JFK");
|
||||
});
|
||||
|
||||
it("sets canonical to the route search URL", () => {
|
||||
const result = buildRouteSearchSeo(stubT, params, "en", CANONICAL);
|
||||
|
||||
expect(result.canonical).toBe(
|
||||
"https://www.aeroflot.ru/en/onlineboard/route/SVO-JFK-20250115",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFlightDetailsSeo", () => {
|
||||
const flight: ISimpleFlight = {
|
||||
id: "SU100-20250115",
|
||||
routeType: "Direct",
|
||||
flightId: {
|
||||
carrier: "SU",
|
||||
flightNumber: "0100",
|
||||
suffix: "",
|
||||
date: "20250115",
|
||||
},
|
||||
flyingTime: "10h 30m",
|
||||
operatingBy: {},
|
||||
status: "Scheduled",
|
||||
leg: {
|
||||
index: 0,
|
||||
status: "Scheduled",
|
||||
flyingTime: "10h 30m",
|
||||
updated: "2025-01-15T10:00:00Z",
|
||||
dayChange: 0,
|
||||
equipment: { name: "Boeing 777-300ER", code: "773" },
|
||||
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
|
||||
operatingBy: {},
|
||||
departure: {
|
||||
scheduled: {
|
||||
airport: "Sheremetyevo International Airport",
|
||||
airportCode: "SVO",
|
||||
city: "Moscow",
|
||||
cityCode: "MOW",
|
||||
countryCode: "RU",
|
||||
},
|
||||
checkingStatus: "closed",
|
||||
times: {
|
||||
scheduledDeparture: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: "2025-01-15T10:00:00",
|
||||
localTime: "10:00",
|
||||
tzOffset: 3,
|
||||
utc: "2025-01-15T07:00:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
arrival: {
|
||||
scheduled: {
|
||||
airport: "John F. Kennedy International Airport",
|
||||
airportCode: "JFK",
|
||||
city: "New York",
|
||||
cityCode: "NYC",
|
||||
countryCode: "US",
|
||||
},
|
||||
times: {
|
||||
scheduledArrival: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: "2025-01-15T14:30:00",
|
||||
localTime: "14:30",
|
||||
tzOffset: -5,
|
||||
utc: "2025-01-15T19:30:00Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("uses FLIGHT-DETAILS translation keys", () => {
|
||||
const result = buildFlightDetailsSeo(stubT, flight, "ru", CANONICAL);
|
||||
|
||||
expect(result.title).toContain("SEO.BOARD.FLIGHT-DETAILS.TITLE");
|
||||
expect(result.title).toContain("flightNumber=SU 0100");
|
||||
});
|
||||
|
||||
it("sets canonical to the details URL", () => {
|
||||
const result = buildFlightDetailsSeo(stubT, flight, "ru", CANONICAL);
|
||||
|
||||
expect(result.canonical).toBe(
|
||||
"https://www.aeroflot.ru/ru/onlineboard/SU0100-20250115",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes hreflang entries", () => {
|
||||
const result = buildFlightDetailsSeo(stubT, flight, "ru", CANONICAL);
|
||||
|
||||
expect(result.hreflang).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("sets og.type to article for details pages", () => {
|
||||
const result = buildFlightDetailsSeo(stubT, flight, "ru", CANONICAL);
|
||||
|
||||
expect(result.og.type).toBe("article");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* SEO builder functions for Online Board route pages.
|
||||
*
|
||||
* Each function is PURE — all data arrives via parameters, no hooks or
|
||||
* side effects. Returns a SeoHeadProps object ready for <SeoHead>.
|
||||
*
|
||||
* Translation keys follow the Angular pattern from meta-tags.service.ts:
|
||||
* SEO.BOARD.{MAIN,FLIGHT-SEARCH,DEPARTURE-SEARCH,ARRIVAL-SEARCH,ROUTE-SEARCH,FLIGHT-DETAILS}.{TITLE,DESCRIPTION}
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SeoHeadProps } from "@/ui/seo/SeoHead.js";
|
||||
import { buildHreflangSet } from "@/shared/seo/hreflang.js";
|
||||
import { buildOnlineBoardUrl } from "./url.js";
|
||||
import type { OnlineBoardParams } from "./url.js";
|
||||
import type { ISimpleFlight, IFlightLeg } from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Translation function signature (compatible with i18next t()) */
|
||||
export type TFunction = (key: string, opts?: Record<string, unknown>) => string;
|
||||
|
||||
/** Optional city names for station/route searches */
|
||||
export interface CityNames {
|
||||
departure?: string;
|
||||
arrival?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OG_IMAGE = "https://www.aeroflot.ru/static/images/aeroflot-og-default.png";
|
||||
const SITE_NAME = "Aeroflot";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format a yyyyMMdd date string to dd.MM.yyyy for display in SEO strings.
|
||||
*/
|
||||
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);
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical URL for a given set of params + locale.
|
||||
*/
|
||||
function buildCanonical(
|
||||
canonicalOrigin: string,
|
||||
locale: string,
|
||||
params: OnlineBoardParams,
|
||||
): string {
|
||||
const path = buildOnlineBoardUrl(params);
|
||||
return `${canonicalOrigin}/${locale}/${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the path without locale prefix for hreflang generation.
|
||||
*/
|
||||
function buildPathWithoutLocale(params: OnlineBoardParams): string {
|
||||
const path = buildOnlineBoardUrl(params);
|
||||
return `/${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the common OG + Twitter fields from title, description, canonical, locale.
|
||||
*/
|
||||
function buildCommonSeoProps(args: {
|
||||
title: string;
|
||||
description: string;
|
||||
canonical: string;
|
||||
hreflangPath: string;
|
||||
canonicalOrigin: string;
|
||||
locale: string;
|
||||
ogType: "website" | "article";
|
||||
}): Pick<SeoHeadProps, "og" | "twitter" | "hreflang"> {
|
||||
return {
|
||||
hreflang: buildHreflangSet({
|
||||
canonicalOrigin: args.canonicalOrigin,
|
||||
pathWithoutLocale: args.hreflangPath,
|
||||
}),
|
||||
og: {
|
||||
title: args.title,
|
||||
description: args.description,
|
||||
url: args.canonical,
|
||||
image: OG_IMAGE,
|
||||
type: args.ogType,
|
||||
locale: args.locale,
|
||||
siteName: SITE_NAME,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first leg from a flight (handles both Direct and MultiLeg).
|
||||
*/
|
||||
function getFirstLeg(flight: ISimpleFlight): IFlightLeg | undefined {
|
||||
if (flight.routeType === "Direct") {
|
||||
return flight.leg;
|
||||
}
|
||||
return flight.legs[0];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public SEO builder functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* SEO props for the Online Board start page.
|
||||
*/
|
||||
export function buildOnlineBoardStartSeo(
|
||||
t: TFunction,
|
||||
locale: string,
|
||||
canonicalOrigin: string,
|
||||
): SeoHeadProps {
|
||||
const params: OnlineBoardParams = { type: "start" };
|
||||
const title = t("SEO.BOARD.MAIN.TITLE");
|
||||
const description = t("SEO.BOARD.MAIN.DESCRIPTION");
|
||||
const canonical = buildCanonical(canonicalOrigin, locale, params);
|
||||
const hreflangPath = buildPathWithoutLocale(params);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
...buildCommonSeoProps({
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
hreflangPath,
|
||||
canonicalOrigin,
|
||||
locale,
|
||||
ogType: "website",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO props for flight number search results page.
|
||||
*/
|
||||
export function buildFlightSearchSeo(
|
||||
t: TFunction,
|
||||
params: { type: "flight"; carrier: string; flightNumber: string; suffix?: string; date: string },
|
||||
locale: string,
|
||||
canonicalOrigin: string,
|
||||
): SeoHeadProps {
|
||||
const flightDisplay = `${params.carrier} ${params.flightNumber}${params.suffix ?? ""}`;
|
||||
const dateDisplay = formatDateForSeo(params.date);
|
||||
|
||||
const title = t("SEO.BOARD.FLIGHT-SEARCH.TITLE", {
|
||||
flightNumber: flightDisplay,
|
||||
date: dateDisplay,
|
||||
});
|
||||
const description = t("SEO.BOARD.FLIGHT-SEARCH.DESCRIPTION", {
|
||||
flightNumber: flightDisplay,
|
||||
date: dateDisplay,
|
||||
});
|
||||
const canonical = buildCanonical(canonicalOrigin, locale, params);
|
||||
const hreflangPath = buildPathWithoutLocale(params);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
...buildCommonSeoProps({
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
hreflangPath,
|
||||
canonicalOrigin,
|
||||
locale,
|
||||
ogType: "website",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO props for departure search results page.
|
||||
*/
|
||||
export function buildDepartureSearchSeo(
|
||||
t: TFunction,
|
||||
params: { type: "departure"; station: string; date: string; timeFrom?: string; timeTo?: string },
|
||||
locale: string,
|
||||
canonicalOrigin: string,
|
||||
cityNames?: CityNames,
|
||||
): SeoHeadProps {
|
||||
const departureCity = cityNames?.departure ?? params.station;
|
||||
const dateDisplay = formatDateForSeo(params.date);
|
||||
|
||||
const title = t("SEO.BOARD.DEPARTURE-SEARCH.TITLE", {
|
||||
departureCity,
|
||||
date: dateDisplay,
|
||||
});
|
||||
const description = t("SEO.BOARD.DEPARTURE-SEARCH.DESCRIPTION", {
|
||||
departureCity,
|
||||
date: dateDisplay,
|
||||
});
|
||||
const canonical = buildCanonical(canonicalOrigin, locale, params);
|
||||
const hreflangPath = buildPathWithoutLocale(params);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
...buildCommonSeoProps({
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
hreflangPath,
|
||||
canonicalOrigin,
|
||||
locale,
|
||||
ogType: "website",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO props for arrival search results page.
|
||||
*/
|
||||
export function buildArrivalSearchSeo(
|
||||
t: TFunction,
|
||||
params: { type: "arrival"; station: string; date: string; timeFrom?: string; timeTo?: string },
|
||||
locale: string,
|
||||
canonicalOrigin: string,
|
||||
cityNames?: CityNames,
|
||||
): SeoHeadProps {
|
||||
const arrivalCity = cityNames?.arrival ?? params.station;
|
||||
const dateDisplay = formatDateForSeo(params.date);
|
||||
|
||||
const title = t("SEO.BOARD.ARRIVAL-SEARCH.TITLE", {
|
||||
arrivalCity,
|
||||
date: dateDisplay,
|
||||
});
|
||||
const description = t("SEO.BOARD.ARRIVAL-SEARCH.DESCRIPTION", {
|
||||
arrivalCity,
|
||||
date: dateDisplay,
|
||||
});
|
||||
const canonical = buildCanonical(canonicalOrigin, locale, params);
|
||||
const hreflangPath = buildPathWithoutLocale(params);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
...buildCommonSeoProps({
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
hreflangPath,
|
||||
canonicalOrigin,
|
||||
locale,
|
||||
ogType: "website",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO props for route search results page.
|
||||
*/
|
||||
export function buildRouteSearchSeo(
|
||||
t: TFunction,
|
||||
params: { type: "route"; departure: string; arrival: string; date: string; timeFrom?: string; timeTo?: string },
|
||||
locale: string,
|
||||
canonicalOrigin: string,
|
||||
cityNames?: CityNames,
|
||||
): SeoHeadProps {
|
||||
const departureCity = cityNames?.departure ?? params.departure;
|
||||
const arrivalCity = cityNames?.arrival ?? params.arrival;
|
||||
const dateDisplay = formatDateForSeo(params.date);
|
||||
|
||||
const title = t("SEO.BOARD.ROUTE-SEARCH.TITLE", {
|
||||
departureCity,
|
||||
arrivalCity,
|
||||
date: dateDisplay,
|
||||
});
|
||||
const description = t("SEO.BOARD.ROUTE-SEARCH.DESCRIPTION", {
|
||||
departureCity,
|
||||
arrivalCity,
|
||||
date: dateDisplay,
|
||||
});
|
||||
const canonical = buildCanonical(canonicalOrigin, locale, params);
|
||||
const hreflangPath = buildPathWithoutLocale(params);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
...buildCommonSeoProps({
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
hreflangPath,
|
||||
canonicalOrigin,
|
||||
locale,
|
||||
ogType: "website",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO props for flight details page.
|
||||
*/
|
||||
export function buildFlightDetailsSeo(
|
||||
t: TFunction,
|
||||
flight: ISimpleFlight,
|
||||
locale: string,
|
||||
canonicalOrigin: string,
|
||||
): SeoHeadProps {
|
||||
const { carrier, flightNumber, suffix, date } = flight.flightId;
|
||||
const flightDisplay = `${carrier} ${flightNumber}${suffix ?? ""}`;
|
||||
const dateDisplay = formatDateForSeo(date);
|
||||
|
||||
const title = t("SEO.BOARD.FLIGHT-DETAILS.TITLE", {
|
||||
flightNumber: flightDisplay,
|
||||
date: dateDisplay,
|
||||
});
|
||||
const description = t("SEO.BOARD.FLIGHT-DETAILS.DESCRIPTION", {
|
||||
flightNumber: flightDisplay,
|
||||
date: dateDisplay,
|
||||
});
|
||||
|
||||
const detailsParams: OnlineBoardParams = suffix
|
||||
? { type: "details", carrier, flightNumber, suffix, date }
|
||||
: { type: "details", carrier, flightNumber, date };
|
||||
const canonical = buildCanonical(canonicalOrigin, locale, detailsParams);
|
||||
const hreflangPath = buildPathWithoutLocale(detailsParams);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
...buildCommonSeoProps({
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
hreflangPath,
|
||||
canonicalOrigin,
|
||||
locale,
|
||||
ogType: "article",
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -182,28 +182,28 @@
|
||||
"SEO": {
|
||||
"BOARD": {
|
||||
"ARRIVAL-SEARCH": {
|
||||
"DESCRIPTION": "",
|
||||
"TITLE": ""
|
||||
"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": "",
|
||||
"TITLE": ""
|
||||
"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": "",
|
||||
"TITLE": ""
|
||||
"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": "",
|
||||
"TITLE": ""
|
||||
"DESCRIPTION": "Departure and arrival information for flight {{ flightNumber }} on {{ date }}.",
|
||||
"TITLE": "Flight {{ flightNumber }} – Online arrivals and departures board {{ date }} | Aeroflot"
|
||||
},
|
||||
"MAIN": {
|
||||
"DESCRIPTION": "",
|
||||
"TITLE": ""
|
||||
"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": "",
|
||||
"TITLE": ""
|
||||
"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": {
|
||||
|
||||
Reference in New Issue
Block a user