Render rich Schedule details page + fix broken SEO key

Schedule details page used to show only a one-line FlightCard and
stop. Reuse ScheduleFlightBody so each flight in the chain renders
the same per-leg layout the schedule results page uses (route
summary, leg cards, transit pill, share/Купить/Детали рейса
actions). Add a `Вернуться к Расписанию` back link to the header.

While here, fix the SEO title key — buildScheduleDetailsSeo was
calling SEO.SCHEDULE.DETAILS.TITLE with `flights={...}`, but the
i18n bundle only defines SEO.SCHEDULE.FLIGHT-DETAILS.TITLE with
`flightNumber={...}`. The unresolved key was leaking into the
document title as "SEO.SCHEDULE.DETAILS.TITLE".
This commit is contained in:
2026-04-20 00:30:39 +03:00
parent b21ae2638b
commit e05ef1ca20
4 changed files with 119 additions and 36 deletions
@@ -0,0 +1,71 @@
@use "../../../styles/colors" as colors;
@use "../../../styles/variables" as vars;
@use "../../../styles/fonts" as fonts;
.schedule-details {
display: flex;
flex-direction: column;
gap: vars.$space-l;
&__header-left {
display: flex;
flex-direction: column;
gap: vars.$space-m;
}
// `Вернуться к Расписанию` back link mirrors Angular's
// `details-back` button. White-translucent pill on the dark
// header strip.
&__back {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: rgba(255, 255, 255, 0.92);
color: colors.$blue;
border-radius: 4px;
font-size: 14px;
font-weight: fonts.$font-medium;
text-decoration: none;
transition: background-color 120ms ease;
&:hover { background: #fff; }
}
&__back-arrow {
font-size: 16px;
line-height: 1;
}
&__flight {
display: flex;
flex-direction: column;
background: #fff;
}
&__header {
display: flex;
align-items: baseline;
gap: vars.$space-m;
padding: vars.$space-l vars.$space-xl 0;
}
&__flight-number {
font-size: 22px;
font-weight: fonts.$font-medium;
color: #1c2330;
margin: 0;
}
&__status {
color: #6b7280;
font-size: 14px;
}
&--error,
&--not-found {
padding: vars.$space-xl;
color: colors.$red;
text-align: center;
}
}
@@ -9,6 +9,7 @@
*/
import type { FC } from "react";
import { Link } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import { FlightCard } from "@/ui/flights/FlightCard.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
@@ -19,7 +20,9 @@ import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
import { useScheduleDetails } from "../hooks/useScheduleDetails.js";
import { buildScheduleDetailsSeo } from "../seo.js";
import { buildScheduleFlightJsonLd } from "../json-ld.js";
import type { IScheduleFlightId, IFlightLeg } from "../types.js";
import { ScheduleFlightBody } from "./ScheduleFlightBody.js";
import type { IScheduleFlightId, IFlightLeg, ISimpleFlight } from "../types.js";
import "./ScheduleDetailsPage.scss";
export interface ScheduleDetailsPageProps {
/** Parsed flight identifiers from the catch-all URL */
@@ -74,6 +77,18 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
const pageTabs = <PageTabs viewType="schedule" />;
const scheduleHref = `/${locale}/schedule`;
const backLink = (
<Link
to={scheduleHref}
className="schedule-details__back"
data-testid="schedule-details-back"
>
<span aria-hidden="true" className="schedule-details__back-arrow">
{"\u2190"}
</span>
{t("SHARED.BACK-SCHEDULE") || t("SHARED.BACK")}
</Link>
);
const title = flightIds[0]
? `${t("BOARD.FLIGHT-INFO")}: ${flightIds[0].carrier} ${flightIds[0].flightNumber}`
: t("SCHEDULE.TITLE");
@@ -127,9 +142,22 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
// SEO
const seoProps = buildScheduleDetailsSeo(t, flights, locale, canonicalOrigin, flightIds);
// Single-flight chains render one schedule body. Multi-flight chains
// (connecting itineraries assembled from the URL) render the rich
// body for each, separated by a transit caption — close to Angular's
// `schedule-flight-details-view` for the connecting case.
const renderBody = (flight: typeof flights[number]) => (
<ScheduleFlightBody flight={flight as unknown as ISimpleFlight} />
);
return (
<PageLayout
headerLeft={pageTabs}
headerLeft={
<div className="schedule-details__header-left">
{backLink}
{pageTabs}
</div>
}
title={<h1 className="text--white page-title">{title}</h1>}
breadcrumbs={[{ label: t("SCHEDULE.TITLE"), url: scheduleHref }]}
>
@@ -138,44 +166,28 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
<div className="schedule-details" data-testid="schedule-details">
{flights.map((flight) => {
const jsonLd = buildScheduleFlightJsonLd(flight);
const legs = getLegs(flight);
const flightNumber = `${flight.flightId.carrier} ${flight.flightId.flightNumber}`;
return (
<div key={flight.id} className="schedule-details__flight" data-testid={`flight-${flight.id}`}>
<div
key={flight.id}
className="schedule-details__flight"
data-testid={`flight-${flight.id}`}
>
<JsonLdRenderer data={jsonLd} />
<div className="schedule-details__header">
<h2 className="schedule-details__flight-number">{flightNumber}</h2>
<h2 className="schedule-details__flight-number">
{flightNumber}
</h2>
<span className="schedule-details__status">
{t(`FLIGHT-STATUSES.${flight.status}`)}
</span>
</div>
<FlightCard flight={flight} />
{/* Per-leg aircraft info (multi-leg routes only — for a
direct flight the FlightCard summary already shows the
route and times, duplicating them here is noise). */}
{legs.length > 1 &&
legs.map((leg, i) => (
<div
key={`leg-${leg.index ?? i}`}
className="schedule-details__leg"
data-testid={`leg-${leg.index ?? i}`}
>
<div className="schedule-details__leg-route">
<span>{leg.departure.scheduled.airportCode}</span>
<span>&rarr;</span>
<span>{leg.arrival.scheduled.airportCode}</span>
</div>
{leg.equipment.name && (
<div className="schedule-details__aircraft">
{leg.equipment.name}
</div>
)}
</div>
))}
{/* Collapsed summary row, then the rich per-leg body. */}
<FlightCard flight={flight} direction="schedule" />
{renderBody(flight)}
</div>
);
})}
+3 -3
View File
@@ -196,11 +196,11 @@ describe("buildScheduleDetailsSeo", () => {
},
};
it("uses DETAILS translation keys", () => {
it("uses FLIGHT-DETAILS translation keys", () => {
const result = buildScheduleDetailsSeo(stubT, [flight], "ru", CANONICAL, flightIds);
expect(result.title).toContain("SEO.SCHEDULE.DETAILS.TITLE");
expect(result.title).toContain("flights=SU 0012");
expect(result.title).toContain("SEO.SCHEDULE.FLIGHT-DETAILS.TITLE");
expect(result.title).toContain("flightNumber=SU 0012");
expect(result.title).toContain("date=27.05.2022");
});
+4 -4
View File
@@ -188,12 +188,12 @@ export function buildScheduleDetailsSeo(
? formatDateForSeo(flightIds[0].date)
: "";
const title = t("SEO.SCHEDULE.DETAILS.TITLE", {
flights: flightDisplay,
const title = t("SEO.SCHEDULE.FLIGHT-DETAILS.TITLE", {
flightNumber: flightDisplay,
date: dateDisplay,
});
const description = t("SEO.SCHEDULE.DETAILS.DESCRIPTION", {
flights: flightDisplay,
const description = t("SEO.SCHEDULE.FLIGHT-DETAILS.DESCRIPTION", {
flightNumber: flightDisplay,
date: dateDisplay,
});