9ff034d19f
Angular's board search results expansion shows [Купить] [Онлайн регистрация] [Детали рейса]. React only rendered Details. Added a `renderActions` prop on FlightCard/FlightList so the feature layer can inject extra buttons without the ui layer importing from features. OnlineBoardSearchPage wires it to FlightActions with showShare=false (the row already has a dedicated share icon). Visibility rules fall through to canBuyTicket / canRegister (same as BoardDetailsHeader), so cancelled/past flights still hide the Buy button and carriers without a registrationUrl still hide the Online Registration button — matching Angular's per-flight gating. Integration test mocks useAppSettings to avoid requiring the real ApiClientProvider in flight-search.test.tsx.
450 lines
17 KiB
TypeScript
450 lines
17 KiB
TypeScript
import { useState, type FC, type KeyboardEvent, type ReactNode } from "react";
|
|
import { useTranslation } from "@/i18n/provider.js";
|
|
import { useLocale } from "@/i18n/useLocale.js";
|
|
import type { ISimpleFlight, IFlightLeg } from "@/features/online-board/types.js";
|
|
import { operatingCarrier } from "@/features/online-board/types.js";
|
|
import {
|
|
formatLocalTime,
|
|
formatUtcOffset,
|
|
} from "@/shared/utils/datetime/index.js";
|
|
import { StationDisplay } from "./StationDisplay.js";
|
|
import { TimeGroup } from "./TimeGroup.js";
|
|
import { FlightStatus } from "./FlightStatus.js";
|
|
import { OperatorLogo } from "./OperatorLogo.js";
|
|
import "./FlightCard.scss";
|
|
|
|
export interface FlightCardProps {
|
|
flight: ISimpleFlight;
|
|
/**
|
|
* Legacy single-click navigation handler. Kept for callers that don't
|
|
* need inline expansion (e.g. details-page summary row).
|
|
*/
|
|
onClick?: () => void;
|
|
/**
|
|
* When true, a single row click toggles an inline detail panel (time
|
|
* row, boarding row, 'Детали рейса' button) and the bottom button
|
|
* fires onViewDetails. Matches Angular's board-flight-header behaviour.
|
|
*/
|
|
expandable?: boolean;
|
|
/**
|
|
* Opens the inline panel on mount — used by the 'closest flight'
|
|
* auto-selection Angular ships on search results.
|
|
*/
|
|
initialExpanded?: boolean;
|
|
/** Fired when the user clicks 'Детали рейса' in the expanded panel. */
|
|
onViewDetails?: () => void;
|
|
/**
|
|
* Search direction. `arrival` swaps the boarding row to deboarding
|
|
* (label `Высадка` instead of `Посадка`); `schedule` keeps the
|
|
* aircraft model visible in the collapsed row per Angular's schedule
|
|
* 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;
|
|
/**
|
|
* Extra action buttons rendered before the default share + details
|
|
* buttons in the expanded actions row. Used by the online-board
|
|
* search page to surface the same Купить / Онлайн регистрация
|
|
* buttons Angular shows on the search results expansion.
|
|
*/
|
|
renderActions?: (flight: ISimpleFlight) => ReactNode;
|
|
}
|
|
|
|
/** Extract the primary leg from a flight (first leg for multi-leg) */
|
|
function getPrimaryLeg(flight: ISimpleFlight): IFlightLeg {
|
|
if (flight.routeType === "Direct") return flight.leg;
|
|
const first = flight.legs[0];
|
|
if (!first) throw new Error("Multi-leg flight has no legs");
|
|
return first;
|
|
}
|
|
|
|
/** Extract the final leg (last leg for multi-leg, same as primary for direct) */
|
|
function getFinalLeg(flight: ISimpleFlight): IFlightLeg {
|
|
if (flight.routeType === "Direct") return flight.leg;
|
|
const last = flight.legs[flight.legs.length - 1];
|
|
if (!last) throw new Error("Multi-leg flight has no legs");
|
|
return last;
|
|
}
|
|
|
|
/** Pretty "HH:mm UTC±HH:mm" string from an offset-aware ISO timestamp. */
|
|
function timeWithOffset(iso: string | undefined): string {
|
|
if (!iso) return "";
|
|
const time = formatLocalTime(iso);
|
|
const offset = formatUtcOffset(iso);
|
|
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] ?? "0", 10);
|
|
m = parseInt(hms[2] ?? "0", 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.
|
|
*
|
|
* Header row matches Angular's board-flight-header layout:
|
|
* flight#+aircraft | operator-logo | dep-time | dep-city/terminal | status-icon | arr-time | arr-city/terminal | chevron
|
|
*
|
|
* When `expandable`, a row click toggles an inline details panel with
|
|
* scheduled/expected time rows for departure + arrival, a boarding
|
|
* status row, and a 'Детали рейса' navigation button.
|
|
*/
|
|
export const FlightCard: FC<FlightCardProps> = ({
|
|
flight,
|
|
onClick,
|
|
expandable,
|
|
initialExpanded,
|
|
onViewDetails,
|
|
direction = "route",
|
|
renderExpandedBody,
|
|
renderActions,
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const { language } = useLocale();
|
|
const departureLeg = getPrimaryLeg(flight);
|
|
const arrivalLeg = getFinalLeg(flight);
|
|
|
|
const depStation = departureLeg.departure;
|
|
const arrStation = arrivalLeg.arrival;
|
|
const depTimes = depStation.times;
|
|
const arrTimes = arrStation.times;
|
|
|
|
// 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 =
|
|
departureLeg.equipment?.aircraft?.actual?.title ??
|
|
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 => {
|
|
if (expandable) {
|
|
setExpanded((v) => !v);
|
|
} else if (onClick) {
|
|
onClick();
|
|
}
|
|
};
|
|
|
|
const depScheduled = timeWithOffset(depTimes.scheduledDeparture.local);
|
|
// Angular shows the latest available dep time as actual>estimated.
|
|
// The caption changes with it: 'Фактическое' for actual, 'Ожидаемое'
|
|
// for estimated-only.
|
|
const depLatestTimes = depTimes.actualBlockOff ?? depTimes.estimatedBlockOff;
|
|
const depLatest = depLatestTimes ? timeWithOffset(depLatestTimes.local) : null;
|
|
const depLatestCaptionKey = depTimes.actualBlockOff
|
|
? "SHARED.ACTUAL"
|
|
: "SHARED.EXPECTED";
|
|
const arrScheduled = timeWithOffset(arrTimes.scheduledArrival.local);
|
|
const arrLatestTimes = arrTimes.actualBlockOn ?? arrTimes.estimatedBlockOn;
|
|
const arrLatest = arrLatestTimes ? timeWithOffset(arrLatestTimes.local) : null;
|
|
const arrLatestCaptionKey = arrTimes.actualBlockOn
|
|
? "SHARED.ACTUAL"
|
|
: "SHARED.EXPECTED";
|
|
|
|
// Arrival pages show the deboarding (Высадка) transition; departure /
|
|
// route / flight-number views show boarding (Посадка). Matches Angular.
|
|
const isArrival = direction === "arrival";
|
|
const transition = isArrival
|
|
? arrivalLeg.transition?.deboarding
|
|
: departureLeg.transition?.boarding;
|
|
const transitionLabelKey = isArrival ? "DETAILS.DEBOARDING" : "DETAILS.BOARDING";
|
|
const BOARDING_STATUS_KEY: Record<string, string> = {
|
|
Finished: "BOARDING-STATUSES.Finished",
|
|
Expected: "BOARDING-STATUSES.Expected",
|
|
InProgress: "BOARDING-STATUSES.InProgress",
|
|
Specified: "BOARDING-STATUSES.Specified",
|
|
Scheduled: "BOARDING-STATUSES.Scheduled",
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`flight-card${rowClickable ? " flight-card--clickable" : ""}${expanded ? " flight-card--expanded" : ""}`}
|
|
data-flight-id={flight.id}
|
|
>
|
|
<div
|
|
className="flight-card__row"
|
|
{...(rowClickable
|
|
? {
|
|
role: "button",
|
|
tabIndex: 0,
|
|
onClick: toggleExpanded,
|
|
onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
toggleExpanded();
|
|
}
|
|
},
|
|
}
|
|
: {})}
|
|
>
|
|
<div className="flight-card__number" data-testid="flight-carrier-number">
|
|
<div>{flightNumber}</div>
|
|
{(expanded || direction === "schedule") && aircraftName && (
|
|
<div className="flight-card__aircraft">{aircraftName}</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flight-card__operator">
|
|
{isMultiLeg
|
|
? flight.legs.map((leg, i) => (
|
|
<OperatorLogo
|
|
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} />}
|
|
</div>
|
|
|
|
<div className="flight-card__time">
|
|
<TimeGroup
|
|
scheduled={depTimes.scheduledDeparture.local}
|
|
actual={depTimes.actualBlockOff?.local}
|
|
dayChange={
|
|
depTimes.actualBlockOff?.dayChange.value ??
|
|
depTimes.scheduledDeparture.dayChange?.value
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flight-card__station">
|
|
<StationDisplay
|
|
airportCode={depStation.scheduled.airportCode}
|
|
airportName={depStation.scheduled.airport}
|
|
cityName={depStation.scheduled.city}
|
|
{...(depStation.terminal ? { terminal: depStation.terminal } : {})}
|
|
cityFirst
|
|
/>
|
|
</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
|
|
scheduled={arrTimes.scheduledArrival.local}
|
|
actual={arrTimes.actualBlockOn?.local}
|
|
dayChange={
|
|
arrTimes.actualBlockOn?.dayChange.value ??
|
|
arrTimes.scheduledArrival.dayChange?.value
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flight-card__station flight-card__station--arrival">
|
|
<StationDisplay
|
|
airportCode={arrStation.scheduled.airportCode}
|
|
airportName={arrStation.scheduled.airport}
|
|
cityName={arrStation.scheduled.city}
|
|
{...(arrStation.terminal ? { terminal: arrStation.terminal } : {})}
|
|
cityFirst
|
|
/>
|
|
</div>
|
|
|
|
{(expandable || direction === "schedule") && (
|
|
<div
|
|
className={`flight-card__chevron${expanded ? " flight-card__chevron--open" : ""}`}
|
|
aria-hidden="true"
|
|
>
|
|
{"\u25BE"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{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>
|
|
<div className="flight-card__detail-group">
|
|
<div>
|
|
<span className="flight-card__detail-caption">
|
|
{t("SHARED.SCHEDULED")}
|
|
</span>
|
|
<span className="flight-card__detail-value">{depScheduled}</span>
|
|
</div>
|
|
{depLatest && (
|
|
<div>
|
|
<span className="flight-card__detail-caption">
|
|
{t(depLatestCaptionKey)}
|
|
</span>
|
|
<span className="flight-card__detail-value">{depLatest}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flight-card__detail-group">
|
|
<div>
|
|
<span className="flight-card__detail-caption">
|
|
{t("SHARED.SCHEDULED")}
|
|
</span>
|
|
<span className="flight-card__detail-value">{arrScheduled}</span>
|
|
</div>
|
|
{arrLatest && (
|
|
<div>
|
|
<span className="flight-card__detail-caption">
|
|
{t(arrLatestCaptionKey)}
|
|
</span>
|
|
<span className="flight-card__detail-value">{arrLatest}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{transition && (
|
|
<div className="flight-card__detail-row">
|
|
<div className="flight-card__detail-label">
|
|
{t(transitionLabelKey)}
|
|
</div>
|
|
<div className="flight-card__detail-group">
|
|
<div>
|
|
<span className="flight-card__detail-caption">
|
|
{t("DETAILS.STATUS")}
|
|
</span>
|
|
<span
|
|
className={`flight-card__detail-value flight-card__detail-status flight-card__detail-status--${transition.status.toLowerCase()}`}
|
|
>
|
|
<span className="flight-card__status-dot" aria-hidden="true" />
|
|
{t(BOARDING_STATUS_KEY[transition.status] ?? transition.status)}
|
|
</span>
|
|
</div>
|
|
{transition.start?.local && (
|
|
<div>
|
|
<span className="flight-card__detail-caption">
|
|
{t("SHARED.BOARDING-START")}
|
|
</span>
|
|
<span className="flight-card__detail-value">
|
|
{formatLocalTime(transition.start.local)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{transition.end?.local && (
|
|
<div>
|
|
<span className="flight-card__detail-caption">
|
|
{t("SHARED.BOARDING-END")}
|
|
</span>
|
|
<span className="flight-card__detail-value">
|
|
{formatLocalTime(transition.end.local)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{onViewDetails && (
|
|
<div className="flight-card__actions">
|
|
<button
|
|
type="button"
|
|
className="flight-card__share-btn"
|
|
data-testid="flight-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>
|
|
{/* Extra actions (Купить / Онлайн регистрация). Angular
|
|
renders Buy + Registration + Details here on every
|
|
expanded board row; the caller wires up FlightActions
|
|
via renderActions so the visibility rules live with
|
|
the feature instead of leaking into the ui layer. */}
|
|
{renderActions?.(flight)}
|
|
<button
|
|
type="button"
|
|
className="flight-card__details-btn"
|
|
data-testid="flight-details-button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onViewDetails();
|
|
}}
|
|
>
|
|
{t("BOARD.DETAILS-TITLE")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|