Render Angular schedule expanded body in React

Schedule flight cards now expand into the rich Angular layout instead
of the online-board time/transition rows. Mirrors connecting-flight-
body / multi-flight-body: horizontal timeline summary, per-leg card
with section number + flight number + operator + aircraft + dep/arr
times + leg duration + stations, transfer-inline-extended pill
between legs (Пересадка, ground time, transit city), and the actions
row (share, Купить, Детали рейса).

Wired via a renderExpandedBody render prop on FlightCard/FlightList so
ui/flights doesn't need to know about schedule-specific bodies.
This commit is contained in:
2026-04-20 00:01:24 +03:00
parent 8bf672f3fa
commit d6ef3c8433
5 changed files with 747 additions and 4 deletions
@@ -10,12 +10,13 @@
* (e.g. single-day search) — no header noise.
*/
import { type FC, useMemo, useState } from "react";
import { type FC, useCallback, useMemo, useState } from "react";
import { useTranslation } from "@/i18n/provider.js";
import { FlightList } from "@/ui/flights/FlightList.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { useLocale } from "@/i18n/useLocale.js";
import type { ISimpleFlight, IFlightLeg } from "@/features/online-board/types.js";
import { ScheduleFlightBody } from "./ScheduleFlightBody.js";
import "./DayGroupedFlightList.scss";
type SortMode =
@@ -187,6 +188,19 @@ export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
</div>
);
// The schedule expanded body — a per-leg route diagram with transfer
// boxes — replaces the default time/transition rows. Buy/Status
// buttons live inside this body (only place Angular renders them).
const renderScheduleBody = useCallback(
(f: ISimpleFlight) => (
<ScheduleFlightBody
flight={f}
{...(onFlightClick ? { onStatus: () => onFlightClick(f) } : {})}
/>
),
[onFlightClick],
);
if (loading) return <FlightListSkeleton count={5} />;
if (groups.length === 0) {
@@ -202,6 +216,7 @@ export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
flights={flights}
loading={false}
direction="schedule"
renderExpandedBody={renderScheduleBody}
{...(onFlightClick ? { onFlightClick } : {})}
{...(initialCurrentFlightId ? { initialCurrentFlightId } : {})}
/>
@@ -274,6 +289,7 @@ export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
flights={g.flights}
loading={false}
direction="schedule"
renderExpandedBody={renderScheduleBody}
{...(onFlightClick ? { onFlightClick } : {})}
{...(initialCurrentFlightId
? { initialCurrentFlightId }
@@ -0,0 +1,327 @@
@use "../../../styles/colors" as colors;
@use "../../../styles/variables" as vars;
@use "../../../styles/fonts" as fonts;
.schedule-flight-body {
display: flex;
flex-direction: column;
background: #fff;
&__leg {
display: grid;
grid-template-columns:
30px 80px 120px 70px minmax(45px, 240px) 100px 70px minmax(45px, 240px);
align-items: center;
gap: 0 vars.$space-l;
padding: vars.$space-l vars.$space-xl;
font-size: 14px;
color: #1c2330;
}
&__leg-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid colors.$border;
color: colors.$blue;
font-weight: fonts.$font-medium;
font-size: 13px;
}
&__leg-flight-number {
color: #1c2330;
font-weight: fonts.$font-medium;
}
&__leg-operator {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
&__leg-aircraft {
font-size: 11px;
color: #6b7280;
}
&__leg-time {
text-align: left;
&--arrival {
text-align: left;
}
}
&__leg-station {
min-width: 0;
&--arrival .station {
align-items: flex-start;
}
}
&__leg-duration {
align-self: center;
border-top: 1px solid colors.$border;
padding-top: 6px;
margin-top: 6px;
text-align: center;
color: #6b7280;
font-size: 13px;
}
// Connection / intermediate-landing box that sits between two leg
// rows. Mirrors Angular's `transfer-inline-extended` styling: dashed
// side borders, white centred pill with the icon + caption + ground
// time + optional station-change pair.
&__transfer {
display: flex;
justify-content: center;
position: relative;
background: #fff;
border-left: 1px dashed colors.$border;
border-right: 1px dashed colors.$border;
margin-top: -8px;
margin-bottom: -8px;
font-size: 12px;
color: #1c2330;
&::after {
content: "";
position: absolute;
top: 50%;
width: 100%;
border-top: 1px dotted colors.$border;
z-index: 0;
}
}
&__transfer-icon {
display: flex;
align-items: center;
justify-content: center;
padding: 0 vars.$space-m;
border-right: 1px solid colors.$border;
align-self: stretch;
svg {
width: 20px;
height: 7px;
fill: #ff9000;
}
}
// The pill itself (icon + content) sits above the dotted through-line.
&__transfer-icon,
&__transfer-content {
background: #fff;
z-index: 1;
}
&__transfer-content {
display: flex;
align-items: center;
gap: vars.$space-m;
padding: vars.$space-s vars.$space-m;
border: 1px solid colors.$border;
border-left: 0;
border-radius: 0 3px 3px 0;
}
&__transfer-icon {
border: 1px solid colors.$border;
border-right: 1px solid colors.$border;
border-radius: 3px 0 0 3px;
}
&__transfer-caption {
font-weight: fonts.$font-medium;
}
&__transfer-time {
display: inline-flex;
align-items: center;
gap: 6px;
img {
width: 14px;
height: 14px;
}
}
&__transfer-stations {
display: inline-flex;
align-items: center;
gap: 6px;
color: #6b7280;
}
&__actions {
display: flex;
align-items: center;
gap: vars.$space-m;
padding: vars.$space-l vars.$space-xl;
border-top: 1px dashed colors.$border;
background: #fff;
}
&__spacer {
flex: 1;
}
&__share-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 120ms ease;
img { width: 20px; height: 20px; }
&:hover { background-color: rgba(46, 87, 255, 0.08); }
}
&__buy-btn {
background: #ff9000;
color: #fff;
border: none;
border-radius: 4px;
padding: 10px 24px;
font-size: 14px;
font-weight: fonts.$font-medium;
cursor: pointer;
min-width: 150px;
transition: background-color 150ms ease;
&:hover { background: #e68200; }
}
&__status-btn {
background: colors.$blue;
color: #fff;
border: 1px solid colors.$blue;
border-radius: 4px;
padding: 10px 24px;
font-size: 14px;
font-weight: fonts.$font-medium;
cursor: pointer;
min-width: 150px;
transition: background-color 150ms ease;
&:hover { background: #1c45cc; }
}
// ----- horizontal timeline (route summary) -----------------------------
&__timeline {
padding: vars.$space-l vars.$space-xl vars.$space-m;
border-bottom: 1px solid colors.$border;
background: #fff;
color: #1c2330;
}
&__timeline-row {
display: flex;
align-items: flex-start;
gap: vars.$space-l;
font-size: 14px;
}
&__timeline-leg {
display: flex;
align-items: center;
flex: 1 1 0;
gap: vars.$space-s;
min-width: 0;
}
&__timeline-time {
flex-shrink: 0;
}
&__timeline-section {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 0;
min-width: 60px;
color: #6b7280;
font-size: 13px;
position: relative;
}
&__timeline-bar {
flex: 1;
height: 1px;
width: 100%;
border-top: 1px solid colors.$border;
}
&__timeline-section-num {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
padding: 1px 6px;
border: 1px solid colors.$border;
border-radius: 3px;
background: #fff;
color: #1c2330;
font-size: 12px;
font-weight: fonts.$font-medium;
margin-bottom: 2px;
}
&__timeline-section-dur {
margin-top: 2px;
color: #6b7280;
font-size: 12px;
white-space: nowrap;
}
&__timeline-transit {
flex-shrink: 0;
color: #1c2330;
font-weight: fonts.$font-medium;
font-size: 13px;
padding: 0 vars.$space-s;
}
&__timeline-stations {
display: flex;
justify-content: space-between;
gap: vars.$space-l;
margin-top: vars.$space-s;
font-size: 14px;
}
&__timeline-station {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-width: 0;
&:first-child { text-align: left; }
&:last-child { text-align: right; }
&:not(:first-child):not(:last-child) { text-align: center; }
}
&__timeline-station-city {
color: #1c2330;
font-weight: fonts.$font-medium;
}
&__timeline-station-terminal {
color: #6b7280;
font-size: 12px;
}
}
@@ -0,0 +1,375 @@
/**
* Schedule-specific expanded body for a flight card.
*
* Mirrors Angular's `connecting-flight-body` / `multi-flight-body`
* layout: each leg is rendered as a `flight-body-part-header` row
* (section number, flight number, operator logo + aircraft model,
* scheduled departure time, departure station, leg duration, arrival
* time, arrival station). Between legs, a `transfer-inline-extended`
* box shows the connection icon + ground time + departure/arrival
* airports of the transfer.
*
* The synthetic MultiLeg from `extractSimpleFlights` carries
* `_childFlightIds` so connecting flights show two distinct flight
* numbers (`SU 6188` / `SU 6233`) over their respective legs. For
* true MultiLeg flights all leg headers reuse the parent flight ID.
*
* @module
*/
import type { FC } from "react";
import { useTranslation } from "@/i18n/provider.js";
import { useLocale } from "@/i18n/useLocale.js";
import type {
IFlightLeg,
ISimpleFlight,
} from "@/features/online-board/types.js";
import { operatingCarrier } from "@/features/online-board/types.js";
import { TimeGroup } from "@/ui/flights/TimeGroup.js";
import { StationDisplay } from "@/ui/flights/StationDisplay.js";
import { OperatorLogo } from "@/ui/flights/OperatorLogo.js";
import "./ScheduleFlightBody.scss";
export interface ScheduleFlightBodyProps {
flight: ISimpleFlight;
/** Optional click handler for the `Купить` button. */
onBuy?: () => void;
/** Optional click handler for the `Статус рейса` button. */
onStatus?: () => void;
}
interface ChildFlightId {
carrier: string;
flightNumber: string;
suffix?: string;
}
/** Convert "HH:MM:SS" or "PT1H30M" into Angular `DurationPipe` output. */
function formatDuration(value: string | undefined, language: string): string {
if (!value) return "";
const isRu = language.startsWith("ru");
let h = 0;
let m = 0;
const hms = /^(\d+):(\d+):(\d+)$/.exec(value);
if (hms) {
h = parseInt(hms[1]!, 10);
m = parseInt(hms[2]!, 10);
} else {
const iso = /^PT(?:(\d+)H)?(?:(\d+)M)?/.exec(value);
if (iso) {
h = parseInt(iso[1] ?? "0", 10);
m = parseInt(iso[2] ?? "0", 10);
} else {
return value;
}
}
return isRu ? `${h}ч. ${m}мин.` : `${h}h ${m}m`;
}
/** Compute ground-transfer minutes between two consecutive legs. */
function transferDuration(prev: IFlightLeg, next: IFlightLeg): string {
const prevArr = prev.arrival.times.scheduledArrival.local;
const nextDep = next.departure.times.scheduledDeparture.local;
if (!prevArr || !nextDep) return "";
const a = new Date(prevArr).getTime();
const b = new Date(nextDep).getTime();
if (Number.isNaN(a) || Number.isNaN(b) || b <= a) return "";
const minutes = Math.round((b - a) / 60000);
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:00`;
}
export const ScheduleFlightBody: FC<ScheduleFlightBodyProps> = ({
flight,
onBuy,
onStatus,
}) => {
const { t } = useTranslation();
const { language } = useLocale();
const legs: IFlightLeg[] =
flight.routeType === "Direct" ? [flight.leg] : flight.legs;
if (legs.length === 0) return null;
const childFlightIds = (flight as ISimpleFlight & {
_childFlightIds?: ChildFlightId[];
})._childFlightIds;
const isConnecting = Boolean(childFlightIds && childFlightIds.length > 1);
const parentFlightId = flight.flightId;
/** Pick the flight ID to display per leg. */
const flightIdForLeg = (legIndex: number): ChildFlightId => {
if (childFlightIds && childFlightIds.length > 0) {
const id =
childFlightIds[Math.min(legIndex, childFlightIds.length - 1)];
if (id) return id;
}
return parentFlightId;
};
// Angular renders a horizontal `timeline` summary above the per-leg
// cards: dep-time → [section# / leg-duration] → arr-time → ground-
// time → next-dep-time → [section# / next-leg-duration] → final
// arr-time, with a station row underneath. Only worth rendering for
// multi-leg routes.
const showTimeline = legs.length > 1;
return (
<div className="schedule-flight-body" data-testid="schedule-flight-body">
{showTimeline && (
<div
className="schedule-flight-body__timeline"
data-testid="schedule-timeline"
>
<div className="schedule-flight-body__timeline-row">
{legs.map((leg, i) => (
<span key={`tl-leg-${i}`} className="schedule-flight-body__timeline-leg">
<span className="schedule-flight-body__timeline-time">
<TimeGroup
scheduled={leg.departure.times.scheduledDeparture.local}
/>
</span>
<span className="schedule-flight-body__timeline-section">
<span className="schedule-flight-body__timeline-bar" />
<span className="schedule-flight-body__timeline-section-num">
{i + 1}
</span>
<span className="schedule-flight-body__timeline-section-dur">
{formatDuration(leg.flyingTime, language)}
</span>
<span className="schedule-flight-body__timeline-bar" />
</span>
<span className="schedule-flight-body__timeline-time">
<TimeGroup
scheduled={leg.arrival.times.scheduledArrival.local}
/>
</span>
{i < legs.length - 1 && (
<span className="schedule-flight-body__timeline-transit">
{formatDuration(transferDuration(leg, legs[i + 1]!), language)}
</span>
)}
</span>
))}
</div>
<div className="schedule-flight-body__timeline-stations">
{legs.map((leg, i) => (
<span
key={`tl-st-${i}`}
className="schedule-flight-body__timeline-station"
>
<span className="schedule-flight-body__timeline-station-city">
{leg.departure.scheduled.city}
</span>
{leg.departure.terminal && (
<span className="schedule-flight-body__timeline-station-terminal">
{[leg.departure.scheduled.airport, leg.departure.terminal]
.filter(Boolean)
.join(" - ")}
</span>
)}
</span>
))}
<span className="schedule-flight-body__timeline-station">
<span className="schedule-flight-body__timeline-station-city">
{legs[legs.length - 1]!.arrival.scheduled.city}
</span>
{legs[legs.length - 1]!.arrival.terminal && (
<span className="schedule-flight-body__timeline-station-terminal">
{[
legs[legs.length - 1]!.arrival.scheduled.airport,
legs[legs.length - 1]!.arrival.terminal,
]
.filter(Boolean)
.join(" - ")}
</span>
)}
</span>
</div>
</div>
)}
{legs.map((leg, idx) => {
const id = flightIdForLeg(idx);
const carrier = operatingCarrier(leg.operatingBy) ?? id.carrier;
const aircraft =
leg.equipment?.aircraft?.actual?.title ??
leg.equipment?.aircraft?.scheduled?.title ??
null;
const isLast = idx === legs.length - 1;
const next = legs[idx + 1];
const transferType =
next && isConnecting ? "connecting" : next ? "multileg" : null;
const transferKey =
transferType === "connecting"
? "SHARED.FLIGHT-TRANSFER"
: "SHARED.INTERMEDIATE-LANDING-PLURAL-ONE";
const stationChange =
next && leg.arrival.scheduled.airportCode !==
next.departure.scheduled.airportCode;
return (
<div key={`leg-${idx}`}>
<div className="schedule-flight-body__leg">
<div className="schedule-flight-body__leg-number">{idx + 1}</div>
<div className="schedule-flight-body__leg-flight-number">
{`${id.carrier} ${id.flightNumber}${id.suffix ?? ""}`}
</div>
<div className="schedule-flight-body__leg-operator">
<OperatorLogo carrier={carrier} locale={language} />
{aircraft && (
<div className="schedule-flight-body__leg-aircraft">
{aircraft}
</div>
)}
</div>
<div className="schedule-flight-body__leg-time">
<TimeGroup
scheduled={leg.departure.times.scheduledDeparture.local}
dayChange={
leg.departure.times.scheduledDeparture.dayChange?.value
}
/>
</div>
<div className="schedule-flight-body__leg-station">
<StationDisplay
airportCode={leg.departure.scheduled.airportCode}
airportName={leg.departure.scheduled.airport}
cityName={leg.departure.scheduled.city}
{...(leg.departure.terminal
? { terminal: leg.departure.terminal }
: {})}
cityFirst
/>
</div>
<div className="schedule-flight-body__leg-duration">
<span>{formatDuration(leg.flyingTime, language)}</span>
</div>
<div className="schedule-flight-body__leg-time schedule-flight-body__leg-time--arrival">
<TimeGroup
scheduled={leg.arrival.times.scheduledArrival.local}
dayChange={
leg.arrival.times.scheduledArrival.dayChange?.value
}
/>
</div>
<div className="schedule-flight-body__leg-station schedule-flight-body__leg-station--arrival">
<StationDisplay
airportCode={leg.arrival.scheduled.airportCode}
airportName={leg.arrival.scheduled.airport}
cityName={leg.arrival.scheduled.city}
{...(leg.arrival.terminal
? { terminal: leg.arrival.terminal }
: {})}
cityFirst
/>
</div>
</div>
{!isLast && next && transferType && (
<div
className={`schedule-flight-body__transfer schedule-flight-body__transfer--${transferType}`}
data-testid="flight-transfer"
>
<div className="schedule-flight-body__transfer-icon">
<svg aria-hidden="true">
<use
xlinkHref={`/assets/img/sprite.svg#${
transferType === "connecting"
? "flight-transfer"
: "intermediate-landing"
}`}
/>
</svg>
</div>
<div className="schedule-flight-body__transfer-content">
<div className="schedule-flight-body__transfer-caption">
{t(transferKey)}
</div>
<div className="schedule-flight-body__transfer-time">
<img
src="/assets/img/time-orange.svg"
alt=""
aria-hidden="true"
/>
<span>{formatDuration(transferDuration(leg, next), language)}</span>
</div>
<div className="schedule-flight-body__transfer-stations">
{stationChange ? (
<>
<span>{leg.arrival.scheduled.city}, {leg.arrival.scheduled.airport}</span>
<span aria-hidden="true">{"\u2192"}</span>
<span>
{next.departure.scheduled.city}, {next.departure.scheduled.airport}
</span>
</>
) : (
<span>
{leg.arrival.scheduled.city}, {leg.arrival.scheduled.airport}
{leg.arrival.terminal ? ` - ${leg.arrival.terminal}` : ""}
</span>
)}
</div>
</div>
</div>
)}
</div>
);
})}
<div className="schedule-flight-body__actions">
<button
type="button"
className="schedule-flight-body__share-btn"
data-testid="schedule-share-button"
aria-label={t("BOARD.SHARE")}
title={t("BOARD.SHARE")}
onClick={(e) => {
e.stopPropagation();
const url =
typeof window !== "undefined" ? window.location.href : "";
const navShare =
typeof navigator !== "undefined" &&
(navigator as Navigator & {
share?: (data: ShareData) => Promise<void>;
}).share;
if (navShare && url) {
void navShare.call(navigator, { url });
} else if (
url &&
typeof navigator !== "undefined" &&
navigator.clipboard
) {
void navigator.clipboard.writeText(url);
}
}}
>
<img src="/assets/img/share.svg" alt="" aria-hidden="true" />
</button>
<div className="schedule-flight-body__spacer" />
<button
type="button"
className="schedule-flight-body__buy-btn"
data-testid="schedule-buy-button"
onClick={(e) => {
e.stopPropagation();
onBuy?.();
}}
>
{t("SHARED.BUY-TICKET")}
</button>
<button
type="button"
className="schedule-flight-body__status-btn"
data-testid="schedule-status-button"
onClick={(e) => {
e.stopPropagation();
onStatus?.();
}}
>
{t("SHARED.FLIGHT-DETAILS")}
</button>
</div>
</div>
);
};
+18 -2
View File
@@ -1,4 +1,4 @@
import { useState, type FC, type KeyboardEvent } from "react";
import { useState, type FC, type KeyboardEvent, type ReactNode } from "react";
import { Link } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import { useLocale } from "@/i18n/useLocale.js";
@@ -42,6 +42,12 @@ export interface FlightCardProps {
* results layout.
*/
direction?: "departure" | "arrival" | "route" | "flight" | "schedule";
/**
* Optional render override for the expanded body. When provided,
* replaces the default time/transition rows — used by schedule
* pages to render the per-leg route diagram.
*/
renderExpandedBody?: (flight: ISimpleFlight) => ReactNode;
}
/** Extract the primary leg from a flight (first leg for multi-leg) */
@@ -111,6 +117,7 @@ export const FlightCard: FC<FlightCardProps> = ({
initialExpanded,
onViewDetails,
direction = "route",
renderExpandedBody,
}) => {
const { t } = useTranslation();
const { language } = useLocale();
@@ -296,7 +303,16 @@ export const FlightCard: FC<FlightCardProps> = ({
)}
</div>
{expandable && expanded && (
{expandable && expanded && renderExpandedBody && (
<div
className="flight-card__expanded flight-card__expanded--custom"
data-testid="flight-card-expanded"
>
{renderExpandedBody(flight)}
</div>
)}
{expandable && expanded && !renderExpandedBody && (
<div className="flight-card__expanded" data-testid="flight-card-expanded">
<div className="flight-card__detail-row">
<div className="flight-card__detail-label">{t("SHARED.TIME")}</div>
+10 -1
View File
@@ -1,4 +1,4 @@
import { type FC, useEffect, useRef } from "react";
import { type FC, type ReactNode, useEffect, useRef } from "react";
import { useTranslation } from "@/i18n/provider.js";
import type { ISimpleFlight } from "@/features/online-board/types.js";
import { FlightCard } from "./FlightCard.js";
@@ -26,6 +26,13 @@ export interface FlightListProps {
* Angular's `Посадка` / `Высадка` switch on departure vs arrival pages.
*/
direction?: "departure" | "arrival" | "route" | "flight" | "schedule";
/**
* Optional override for the expanded body — receives the flight and
* returns the React node to render inside the expanded panel. Used by
* schedule pages to render the per-leg route diagram instead of the
* default time/transition rows.
*/
renderExpandedBody?: (flight: ISimpleFlight) => ReactNode;
}
/**
@@ -41,6 +48,7 @@ export const FlightList: FC<FlightListProps> = ({
onFlightClick,
initialCurrentFlightId,
direction = "route",
renderExpandedBody,
}) => {
const { t } = useTranslation();
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
@@ -90,6 +98,7 @@ export const FlightList: FC<FlightListProps> = ({
{...(onFlightClick
? { onViewDetails: () => onFlightClick(flight) }
: {})}
{...(renderExpandedBody ? { renderExpandedBody } : {})}
/>
</div>
))}