Render Connecting flights + Angular grid for schedule rows

- Connecting (multi-leg via transit) flights are now folded into a
  synthetic MultiLeg shape with combined flight numbers (SU 6188,
  SU 6233) and per-leg airline logos, matching Angular's
  schedule-list-flight-header.

- Schedule grid now uses Angular's 8-column layout
  (80/120/100/240/100/100/240/16). The middle status icon is
  replaced by a duration column with the blue clock icon and
  '3ч. 48мин.' / '4h 19m' formatting.

- Multi-leg airline logos use the round badge variant (separate
  round.png assets) so two carriers fit side-by-side without overlap.

- Action buttons removed from collapsed rows — Angular only shows
  flight-actions in the expanded body. Added chevron column for
  every schedule card and made schedule cards expandable by default.

- Removed 'Туда: MOW → KUF' subhead from outbound section, matching
  Angular's bare flight list under the column header.
This commit is contained in:
2026-04-19 23:24:06 +03:00
parent bdd3a099bc
commit 4c487ab1b2
6 changed files with 156 additions and 46 deletions
@@ -65,12 +65,50 @@ function formatApiDate(yyyymmdd: string): string {
}
/**
* Extract simple flights from the mixed IFlight[] response for rendering.
* Convert the mixed IFlight[] response into a flat ISimpleFlight[] for
* rendering. Connecting flights are folded into a synthetic MultiLeg
* shape so the existing FlightCard can render them with combined leg
* numbers, both airline logos, and the total flying time — matching
* Angular's `schedule-list-flight-header` for connecting flights.
*/
function extractSimpleFlights(flights: Array<{ routeType: string }>): ISimpleFlight[] {
return flights.filter(
(f): f is ISimpleFlight => f.routeType === "Direct" || f.routeType === "MultiLeg",
);
function extractSimpleFlights(
flights: Array<{ routeType: string }>,
): ISimpleFlight[] {
const out: ISimpleFlight[] = [];
for (const f of flights) {
if (f.routeType === "Direct" || f.routeType === "MultiLeg") {
out.push(f as unknown as ISimpleFlight);
continue;
}
if (f.routeType === "Connecting") {
const conn = f as unknown as {
flights: ISimpleFlight[];
flyingTime: string;
status: import("@/features/online-board/types.js").FlightStatus;
};
const first = conn.flights[0];
if (!first) continue;
const allLegs: import("@/features/online-board/types.js").IFlightLeg[] = [];
for (const child of conn.flights) {
if (child.routeType === "Direct") allLegs.push(child.leg);
else allLegs.push(...child.legs);
}
const synthetic = {
routeType: "MultiLeg",
flightId: first.flightId,
flyingTime: conn.flyingTime,
operatingBy: first.operatingBy,
id: conn.flights.map((c) => c.id).join("+"),
status: conn.status,
legs: allLegs,
// Carry through the original child flight numbers so the header
// can display 'SU 6188, SU 6233'.
_childFlightIds: conn.flights.map((c) => c.flightId),
} as unknown as ISimpleFlight;
out.push(synthetic);
}
}
return out;
}
export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
@@ -214,9 +252,6 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
<section className="frame">
<div className="schedule-search__outbound" data-testid="outbound-results">
<h2>
{t("SCHEDULE.OUTBOUND")}: {outbound.departure} &rarr; {outbound.arrival}
</h2>
<DayGroupedFlightList
flights={outboundSimple}
loading={outboundLoading}
+31
View File
@@ -40,6 +40,14 @@
min-height: 68px;
}
// Schedule mode swaps the central status column for a wider duration
// pill — Angular's grid is 80 / 120 / 100 / minmax(45,240) / 100 /
// 100 / minmax(45,240) / 10.
&--schedule .flight-card__row {
grid-template-columns: 80px 120px 100px minmax(80px, 1fr) 100px 100px minmax(80px, 1fr) 16px;
gap: vars.$space-l;
}
&__number {
font-weight: fonts.$font-medium;
color: #222;
@@ -56,6 +64,7 @@
&__operator {
display: flex;
align-items: center;
gap: 6px;
}
&__time {
@@ -101,6 +110,28 @@
gap: vars.$space-s;
}
&__duration {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 12px;
color: #5b6b80;
white-space: nowrap;
}
&__duration-icon {
width: 18px;
height: 18px;
display: inline-block;
background: url("/assets/img/time-blue.svg") no-repeat center / contain;
}
&__duration-text {
line-height: 1.1;
}
&__expanded {
padding: 0 vars.$space-xl vars.$space-xl;
display: flex;
+60 -26
View File
@@ -68,6 +68,32 @@ function timeWithOffset(iso: string | undefined): string {
return offset ? `${time} ${offset}` : time;
}
/**
* Convert API flyingTime ("HH:MM:SS" or ISO-8601 "PT1H30M") to the
* locale-formatted Angular `DurationPipe` output ("1ч. 30мин." for ru).
*/
function formatFlyingTime(value: string, 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;
}
}
if (isRu) return `${h}ч. ${m}мин.`;
return `${h}h ${m}m`;
}
/**
* A single flight row in search results.
*
@@ -96,7 +122,18 @@ export const FlightCard: FC<FlightCardProps> = ({
const depTimes = depStation.times;
const arrTimes = arrStation.times;
const flightNumber = `${flight.flightId.carrier} ${flight.flightId.flightNumber}`;
// Connecting flights get folded into a synthetic MultiLeg shape with
// an extra `_childFlightIds` array so we can render `SU 6188, SU 6233`
// — Angular's `schedule-list-flight-header` does the same via
// `flightNumber` pipe over `getFlights()`.
const childFlightIds = (flight as ISimpleFlight & {
_childFlightIds?: { carrier: string; flightNumber: string; suffix?: string }[];
})._childFlightIds;
const flightNumber = childFlightIds && childFlightIds.length > 1
? childFlightIds
.map((id) => `${id.carrier} ${id.flightNumber}${id.suffix ?? ""}`)
.join(", ")
: `${flight.flightId.carrier} ${flight.flightId.flightNumber}`;
const carrier = operatingCarrier(flight.operatingBy) ?? flight.flightId.carrier;
const isMultiLeg = flight.routeType === "MultiLeg";
const aircraftName =
@@ -104,6 +141,10 @@ export const FlightCard: FC<FlightCardProps> = ({
departureLeg.equipment?.aircraft?.scheduled?.title ??
null;
// Total duration shown in the middle column on schedule rows. Prefer
// the flight-level flyingTime; fall back to the primary leg.
const flightDuration = flight.flyingTime || departureLeg.flyingTime || "";
const [expanded, setExpanded] = useState(Boolean(initialExpanded));
const rowClickable = expandable || Boolean(onClick);
const toggleExpanded = (): void => {
@@ -180,6 +221,11 @@ export const FlightCard: FC<FlightCardProps> = ({
key={`${operatingCarrier(leg.operatingBy) ?? carrier}-${i}`}
carrier={operatingCarrier(leg.operatingBy) ?? carrier}
locale={language}
// Collapsed multi-leg schedule rows show two small
// round airline badges side-by-side — matches
// Angular's `operator-logo-and-model` with
// `round="!expanded || !direct"`.
round={direction === "schedule" && !expanded}
/>
))
: <OperatorLogo carrier={carrier} locale={language} />}
@@ -206,9 +252,18 @@ export const FlightCard: FC<FlightCardProps> = ({
/>
</div>
<div className="flight-card__status">
<FlightStatus status={flight.status} />
</div>
{direction === "schedule" ? (
<div className="flight-card__duration" data-testid="flight-duration">
<span className="flight-card__duration-icon" aria-hidden="true" />
<span className="flight-card__duration-text">
{formatFlyingTime(flightDuration, language)}
</span>
</div>
) : (
<div className="flight-card__status">
<FlightStatus status={flight.status} />
</div>
)}
<div className="flight-card__time flight-card__time--arrival">
<TimeGroup
@@ -231,28 +286,7 @@ export const FlightCard: FC<FlightCardProps> = ({
/>
</div>
{direction === "schedule" ? (
<div className="flight-card__inline-actions">
<a
className="flight-card__buy-btn"
data-testid="flight-buy-button"
href="https://www.aeroflot.ru/sb/app/ru-ru"
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
{t("SHARED.BUY-TICKET") || "Купить"}
</a>
<Link
className="flight-card__status-btn"
data-testid="flight-status-button"
to={`/${languageToLocale(language)}/onlineboard/${flight.flightId.carrier}${flight.flightId.flightNumber}-${flight.flightId.date.replace(/-/g, "")}`}
onClick={(e) => e.stopPropagation()}
>
{t("SHARED.DETAILS")}
</Link>
</div>
) : expandable && (
{(expandable || direction === "schedule") && (
<div
className={`flight-card__chevron${expanded ? " flight-card__chevron--open" : ""}`}
aria-hidden="true"
+4 -1
View File
@@ -82,7 +82,10 @@ export const FlightList: FC<FlightListProps> = ({
<FlightCard
flight={flight}
direction={direction}
expandable={Boolean(onFlightClick)}
// Schedule cards expand on click even without an onFlightClick
// — Angular's schedule rows are p-accordionTab so expanding is
// intrinsic to the layout.
expandable={Boolean(onFlightClick) || direction === "schedule"}
initialExpanded={flight.id === initialCurrentFlightId}
{...(onFlightClick
? { onViewDetails: () => onFlightClick(flight) }
+6 -4
View File
@@ -8,10 +8,12 @@
flex-shrink: 0;
&--round {
width: 24px;
height: 24px;
border-radius: 50%;
background-size: cover;
// Angular: round badges are 36×36px square, designed as round
// icons (separate `round.png` asset). `contain` keeps the icon
// intact without cropping the wide-logo SVG.
width: 36px;
height: 36px;
background-size: contain;
background-position: center;
}
+12 -7
View File
@@ -9,11 +9,11 @@ import "./OperatorLogo.scss";
*
* Locale-specific variants (`ru` vs `en`) are resolved at render time.
*/
const LOGO_PATHS: Record<string, { en: string; ru?: string }> = {
SU: { en: "/assets/img/airlines-logo/aeroflot/large/en.png", ru: "/assets/img/airlines-logo/aeroflot/large/ru.png" },
F7: { en: "/assets/img/airlines-logo/aeroflot/large/en.png", ru: "/assets/img/airlines-logo/aeroflot/large/ru.png" },
HZ: { en: "/assets/img/airlines-logo/aurora/large/en.svg", ru: "/assets/img/airlines-logo/aurora/large/ru.svg" },
FV: { en: "/assets/img/airlines-logo/rossiya/large/en.svg", ru: "/assets/img/airlines-logo/rossiya/large/ru.svg" },
const LOGO_PATHS: Record<string, { en: string; ru?: string; round?: string }> = {
SU: { en: "/assets/img/airlines-logo/aeroflot/large/en.png", ru: "/assets/img/airlines-logo/aeroflot/large/ru.png", round: "/assets/img/airlines-logo/aeroflot/round.png" },
F7: { en: "/assets/img/airlines-logo/aeroflot/large/en.png", ru: "/assets/img/airlines-logo/aeroflot/large/ru.png", round: "/assets/img/airlines-logo/aeroflot/round.png" },
HZ: { en: "/assets/img/airlines-logo/aurora/large/en.svg", ru: "/assets/img/airlines-logo/aurora/large/ru.svg", round: "/assets/img/airlines-logo/aurora/round.png" },
FV: { en: "/assets/img/airlines-logo/rossiya/large/en.svg", ru: "/assets/img/airlines-logo/rossiya/large/ru.svg", round: "/assets/img/airlines-logo/rossiya/round.png" },
RO: { en: "/assets/img/airlines-logo/tarom/large.png" },
DP: { en: "/assets/img/airlines-logo/pobeda/large.svg" },
OM: { en: "/assets/img/airlines-logo/miat/large.svg" },
@@ -67,9 +67,14 @@ export const OperatorLogo: FC<OperatorLogoProps> = ({ carrier, locale, round, ti
// locale (`"ru-ru"`); only the first two chars matter for picking
// between the carrier's en/ru asset variants.
const lang = (locale ?? "").slice(0, 2).toLowerCase();
const src = lang === "ru" && mapping.ru ? mapping.ru : mapping.en;
let src: string;
if (round && mapping.round) {
src = mapping.round;
} else {
src = lang === "ru" && mapping.ru ? mapping.ru : mapping.en;
}
return { backgroundImage: `url('${src}')` };
}, [carrier, locale]);
}, [carrier, locale, round]);
const className = `operator-logo operator-logo--${carrier}${round ? " operator-logo--round" : ""}`;