plan/react-rewrite #1

Merged
gnezim merged 138 commits from plan/react-rewrite into main 2026-04-15 12:21:16 +03:00
4 changed files with 744 additions and 12 deletions
Showing only changes of commit c68d53dfa6 - Show all commits
@@ -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
+292
View File
@@ -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");
});
});
+354
View File
@@ -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",
}),
};
}
+12 -12
View File
@@ -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": {