Files
flights_web/src/ui/flights/FlightCard.tsx
T
gnezim 9ff034d19f OnlineBoard search: render Купить/Онлайн регистрация in expanded row
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.
2026-04-20 18:27:31 +03:00

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>
);
};