From d6ef3c843321bb8d41aa62314a88d233e079420e Mon Sep 17 00:00:00 2001 From: gnezim Date: Mon, 20 Apr 2026 00:01:24 +0300 Subject: [PATCH] Render Angular schedule expanded body in React MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/DayGroupedFlightList.tsx | 18 +- .../components/ScheduleFlightBody.scss | 327 +++++++++++++++ .../components/ScheduleFlightBody.tsx | 375 ++++++++++++++++++ src/ui/flights/FlightCard.tsx | 20 +- src/ui/flights/FlightList.tsx | 11 +- 5 files changed, 747 insertions(+), 4 deletions(-) create mode 100644 src/features/schedule/components/ScheduleFlightBody.scss create mode 100644 src/features/schedule/components/ScheduleFlightBody.tsx diff --git a/src/features/schedule/components/DayGroupedFlightList.tsx b/src/features/schedule/components/DayGroupedFlightList.tsx index 79c8034a..d1f9beeb 100644 --- a/src/features/schedule/components/DayGroupedFlightList.tsx +++ b/src/features/schedule/components/DayGroupedFlightList.tsx @@ -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 = ({ ); + // 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) => ( + onFlightClick(f) } : {})} + /> + ), + [onFlightClick], + ); + if (loading) return ; if (groups.length === 0) { @@ -202,6 +216,7 @@ export const DayGroupedFlightList: FC = ({ flights={flights} loading={false} direction="schedule" + renderExpandedBody={renderScheduleBody} {...(onFlightClick ? { onFlightClick } : {})} {...(initialCurrentFlightId ? { initialCurrentFlightId } : {})} /> @@ -274,6 +289,7 @@ export const DayGroupedFlightList: FC = ({ flights={g.flights} loading={false} direction="schedule" + renderExpandedBody={renderScheduleBody} {...(onFlightClick ? { onFlightClick } : {})} {...(initialCurrentFlightId ? { initialCurrentFlightId } diff --git a/src/features/schedule/components/ScheduleFlightBody.scss b/src/features/schedule/components/ScheduleFlightBody.scss new file mode 100644 index 00000000..7406429f --- /dev/null +++ b/src/features/schedule/components/ScheduleFlightBody.scss @@ -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; + } +} diff --git a/src/features/schedule/components/ScheduleFlightBody.tsx b/src/features/schedule/components/ScheduleFlightBody.tsx new file mode 100644 index 00000000..ec496805 --- /dev/null +++ b/src/features/schedule/components/ScheduleFlightBody.tsx @@ -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 = ({ + 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 ( +
+ {showTimeline && ( +
+
+ {legs.map((leg, i) => ( + + + + + + + + {i + 1} + + + {formatDuration(leg.flyingTime, language)} + + + + + + + {i < legs.length - 1 && ( + + {formatDuration(transferDuration(leg, legs[i + 1]!), language)} + + )} + + ))} +
+
+ {legs.map((leg, i) => ( + + + {leg.departure.scheduled.city} + + {leg.departure.terminal && ( + + {[leg.departure.scheduled.airport, leg.departure.terminal] + .filter(Boolean) + .join(" - ")} + + )} + + ))} + + + {legs[legs.length - 1]!.arrival.scheduled.city} + + {legs[legs.length - 1]!.arrival.terminal && ( + + {[ + legs[legs.length - 1]!.arrival.scheduled.airport, + legs[legs.length - 1]!.arrival.terminal, + ] + .filter(Boolean) + .join(" - ")} + + )} + +
+
+ )} + {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 ( +
+
+
{idx + 1}
+
+ {`${id.carrier} ${id.flightNumber}${id.suffix ?? ""}`} +
+
+ + {aircraft && ( +
+ {aircraft} +
+ )} +
+
+ +
+
+ +
+
+ {formatDuration(leg.flyingTime, language)} +
+
+ +
+
+ +
+
+ + {!isLast && next && transferType && ( +
+
+ +
+
+
+ {t(transferKey)} +
+
+ + {formatDuration(transferDuration(leg, next), language)} +
+
+ {stationChange ? ( + <> + {leg.arrival.scheduled.city}, {leg.arrival.scheduled.airport} + + + {next.departure.scheduled.city}, {next.departure.scheduled.airport} + + + ) : ( + + {leg.arrival.scheduled.city}, {leg.arrival.scheduled.airport} + {leg.arrival.terminal ? ` - ${leg.arrival.terminal}` : ""} + + )} +
+
+
+ )} +
+ ); + })} + +
+ +
+ + +
+
+ ); +}; diff --git a/src/ui/flights/FlightCard.tsx b/src/ui/flights/FlightCard.tsx index c1fecdd3..6430ffb3 100644 --- a/src/ui/flights/FlightCard.tsx +++ b/src/ui/flights/FlightCard.tsx @@ -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 = ({ initialExpanded, onViewDetails, direction = "route", + renderExpandedBody, }) => { const { t } = useTranslation(); const { language } = useLocale(); @@ -296,7 +303,16 @@ export const FlightCard: FC = ({ )}
- {expandable && expanded && ( + {expandable && expanded && renderExpandedBody && ( +
+ {renderExpandedBody(flight)} +
+ )} + + {expandable && expanded && !renderExpandedBody && (
{t("SHARED.TIME")}
diff --git a/src/ui/flights/FlightList.tsx b/src/ui/flights/FlightList.tsx index 6b575684..73b82f49 100644 --- a/src/ui/flights/FlightList.tsx +++ b/src/ui/flights/FlightList.tsx @@ -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 = ({ onFlightClick, initialCurrentFlightId, direction = "route", + renderExpandedBody, }) => { const { t } = useTranslation(); const cardRefs = useRef>(new Map()); @@ -90,6 +98,7 @@ export const FlightList: FC = ({ {...(onFlightClick ? { onViewDetails: () => onFlightClick(flight) } : {})} + {...(renderExpandedBody ? { renderExpandedBody } : {})} />
))}