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:
@@ -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} → {outbound.arrival}
|
||||
</h2>
|
||||
<DayGroupedFlightList
|
||||
flights={outboundSimple}
|
||||
loading={outboundLoading}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" : ""}`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user