From 76f7acb0ddfa58a519ddf533bee87d0268a3897b Mon Sep 17 00:00:00 2001 From: gnezim Date: Sat, 18 Apr 2026 15:36:14 +0300 Subject: [PATCH] Implement inline expandable flight-card details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a row on the board search results page now toggles an inline details panel instead of immediately navigating away. The layout matches Angular's board-flight-header: - Aircraft model ('Sukhoi SuperJet 100') appears below the flight number when expanded. - 'Время' detail row: По расписанию / Фактическое times with UTC offsets for both the departure and the arrival sides. - 'Посадка' detail row: boarding status (через the BOARDING-STATUSES.* keys), start and end times. - 'Детали рейса' button (blue) in the bottom-right navigates to the full details page. - Active rows get a blue left border + light-blue background. - Chevron icon on the right rotates on expand. Wire-up: FlightCard has two new props (expandable, onViewDetails). FlightList automatically passes expandable=true when a click handler is provided. Added SHARED.BOARDING-START / SHARED.BOARDING-END keys across all nine locales for the time captions. --- src/i18n/locales/de/common.json | 4 +- src/i18n/locales/en/common.json | 4 +- src/i18n/locales/es/common.json | 4 +- src/i18n/locales/fr/common.json | 4 +- src/i18n/locales/it/common.json | 4 +- src/i18n/locales/ja/common.json | 4 +- src/i18n/locales/ko/common.json | 4 +- src/i18n/locales/ru/common.json | 4 +- src/i18n/locales/zh/common.json | 4 +- src/ui/flights/FlightCard.scss | 131 +++++++++++-- src/ui/flights/FlightCard.tsx | 315 +++++++++++++++++++++++++------- src/ui/flights/FlightList.tsx | 5 +- 12 files changed, 395 insertions(+), 92 deletions(-) diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 776df6e6..ed74b8bf 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -389,7 +389,9 @@ "A11Y-OPEN-PICKER": "Open city picker", "A11Y-BREADCRUMB": "Breadcrumb", "A11Y-PREV-LEGS": "Previous legs", - "A11Y-NEXT-LEGS": "Next legs" + "A11Y-NEXT-LEGS": "Next legs", + "BOARDING-START": "Start time", + "BOARDING-END": "End time" }, "WARNING": { "IFLY_HIGHLIGHT": "Bitte beachten Sie:", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 4ffd86b1..10d68dc4 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -416,7 +416,9 @@ "A11Y-OPEN-PICKER": "Open city picker", "A11Y-BREADCRUMB": "Breadcrumb", "A11Y-PREV-LEGS": "Previous legs", - "A11Y-NEXT-LEGS": "Next legs" + "A11Y-NEXT-LEGS": "Next legs", + "BOARDING-START": "Start time", + "BOARDING-END": "End time" }, "WARNING": { "IFLY_HIGHLIGHT": "Please note:", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 44aca259..cefd228f 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -389,7 +389,9 @@ "A11Y-OPEN-PICKER": "Open city picker", "A11Y-BREADCRUMB": "Breadcrumb", "A11Y-PREV-LEGS": "Previous legs", - "A11Y-NEXT-LEGS": "Next legs" + "A11Y-NEXT-LEGS": "Next legs", + "BOARDING-START": "Start time", + "BOARDING-END": "End time" }, "WARNING": { "IFLY_HIGHLIGHT": "Nota:", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 8731a437..6826aaf6 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -389,7 +389,9 @@ "A11Y-OPEN-PICKER": "Open city picker", "A11Y-BREADCRUMB": "Breadcrumb", "A11Y-PREV-LEGS": "Previous legs", - "A11Y-NEXT-LEGS": "Next legs" + "A11Y-NEXT-LEGS": "Next legs", + "BOARDING-START": "Start time", + "BOARDING-END": "End time" }, "WARNING": { "IFLY_HIGHLIGHT": "Remarque:", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index c1d99011..2f9ed013 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -389,7 +389,9 @@ "A11Y-OPEN-PICKER": "Open city picker", "A11Y-BREADCRUMB": "Breadcrumb", "A11Y-PREV-LEGS": "Previous legs", - "A11Y-NEXT-LEGS": "Next legs" + "A11Y-NEXT-LEGS": "Next legs", + "BOARDING-START": "Start time", + "BOARDING-END": "End time" }, "WARNING": { "IFLY_HIGHLIGHT": "Attenzione:", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index c33c2a96..2c0600a8 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -389,7 +389,9 @@ "A11Y-OPEN-PICKER": "Open city picker", "A11Y-BREADCRUMB": "Breadcrumb", "A11Y-PREV-LEGS": "Previous legs", - "A11Y-NEXT-LEGS": "Next legs" + "A11Y-NEXT-LEGS": "Next legs", + "BOARDING-START": "Start time", + "BOARDING-END": "End time" }, "WARNING": { "IFLY_HIGHLIGHT": "ご注意:", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 46152c0e..8a1c9680 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -389,7 +389,9 @@ "A11Y-OPEN-PICKER": "Open city picker", "A11Y-BREADCRUMB": "Breadcrumb", "A11Y-PREV-LEGS": "Previous legs", - "A11Y-NEXT-LEGS": "Next legs" + "A11Y-NEXT-LEGS": "Next legs", + "BOARDING-START": "Start time", + "BOARDING-END": "End time" }, "WARNING": { "IFLY_HIGHLIGHT": "참고:", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 60f88e7c..7e939e5d 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -416,7 +416,9 @@ "A11Y-OPEN-PICKER": "Открыть список городов", "A11Y-BREADCRUMB": "Навигационная цепочка", "A11Y-PREV-LEGS": "Предыдущие сегменты", - "A11Y-NEXT-LEGS": "Следующие сегменты" + "A11Y-NEXT-LEGS": "Следующие сегменты", + "BOARDING-START": "Время начала", + "BOARDING-END": "Время окончания" }, "SMOKE": { "HEADING": "Страница проверки" diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index e01ad007..b39e6831 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -389,7 +389,9 @@ "A11Y-OPEN-PICKER": "Open city picker", "A11Y-BREADCRUMB": "Breadcrumb", "A11Y-PREV-LEGS": "Previous legs", - "A11Y-NEXT-LEGS": "Next legs" + "A11Y-NEXT-LEGS": "Next legs", + "BOARDING-START": "Start time", + "BOARDING-END": "End time" }, "WARNING": { "IFLY_HIGHLIGHT": "请注意:", diff --git a/src/ui/flights/FlightCard.scss b/src/ui/flights/FlightCard.scss index d1c50e90..057061ae 100644 --- a/src/ui/flights/FlightCard.scss +++ b/src/ui/flights/FlightCard.scss @@ -4,24 +4,25 @@ @use "../../styles/screen" as screen; .flight-card { - display: grid; - grid-template-columns: 70px 100px 80px 1fr 90px 80px 1fr; - align-items: center; - gap: 12px; - padding: 18px vars.$space-xl; + display: flex; + flex-direction: column; background: transparent; transition: background-color 120ms ease; - min-height: 68px; & + & { border-top: 1px dashed colors.$border; } - &--clickable { + &--expanded { + background: #f3f6fb; + border-left: 3px solid colors.$blue; + } + + &--clickable .flight-card__row { cursor: pointer; &:hover { - background-color: #eef3ff; + background-color: rgba(46, 87, 255, 0.04); } &:focus-visible { @@ -30,12 +31,28 @@ } } + &__row { + display: grid; + grid-template-columns: 70px 100px 80px 1fr 90px 80px 1fr auto; + align-items: center; + gap: 12px; + padding: 18px vars.$space-xl; + min-height: 68px; + } + &__number { font-weight: fonts.$font-medium; color: #222; font-size: 14px; } + &__aircraft { + font-size: 11px; + color: #8a8a8a; + font-weight: normal; + margin-top: 4px; + } + &__operator { display: flex; align-items: center; @@ -70,17 +87,109 @@ justify-content: center; } - @include screen.mobile { - grid-template-columns: 1fr 1fr; + &__chevron { + color: colors.$blue; + font-size: 18px; + transition: transform 150ms ease; + + &--open { + transform: rotate(180deg); + } + } + + &__expanded { + padding: 0 vars.$space-xl vars.$space-xl; + display: flex; + flex-direction: column; gap: vars.$space-m; + background: #f8fafd; + } + + &__detail-row { + display: grid; + grid-template-columns: 140px 1fr 1fr; + gap: vars.$space-xl; + padding: vars.$space-m 0; + border-bottom: 1px dashed #e0e6f0; + align-items: flex-start; + + &:last-child { + border-bottom: none; + } + } + + &__detail-label { + color: #8a8a8a; + font-size: 14px; + } + + &__detail-group { + display: flex; + gap: vars.$space-xl; + flex-wrap: wrap; + + > div { + display: flex; + flex-direction: column; + gap: 2px; + } + } + + &__detail-caption { + font-size: 12px; + color: #8a8a8a; + } + + &__detail-value { + font-weight: 500; + color: #222; + font-size: 14px; + } + + &__detail-status { + color: #2457ff; + } + + &__actions { + display: flex; + justify-content: flex-end; + padding-top: vars.$space-s; + } + + &__details-btn { + background: colors.$blue; + color: #fff; + border: none; + border-radius: 4px; + padding: 10px 24px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 150ms ease; + + &:hover { + background: #1c45cc; + } + } + + @include screen.mobile { + &__row { + grid-template-columns: 1fr 1fr; + gap: vars.$space-m; + } &__operator, - &__status { + &__status, + &__chevron { display: none; } &__station--arrival { text-align: left; } + + &__detail-row { + grid-template-columns: 1fr; + } } } diff --git a/src/ui/flights/FlightCard.tsx b/src/ui/flights/FlightCard.tsx index 0c6f582c..e80638a6 100644 --- a/src/ui/flights/FlightCard.tsx +++ b/src/ui/flights/FlightCard.tsx @@ -1,6 +1,11 @@ -import type { FC, KeyboardEvent } from "react"; +import { useState, type FC, type KeyboardEvent } from "react"; +import { useTranslation } from "@/i18n/provider.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"; @@ -9,7 +14,19 @@ 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; + /** Fired when the user clicks 'Детали рейса' in the expanded panel. */ + onViewDetails?: () => void; } /** Extract the primary leg from a flight (first leg for multi-leg) */ @@ -28,13 +45,31 @@ function getFinalLeg(flight: ISimpleFlight): IFlightLeg { 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; +} + /** * A single flight row in search results. * - * Matches Angular's board-flight-header layout: - * flight#+status | operator-logo | dep-time | dep-city/terminal | status-icon | arr-time | arr-city/terminal + * 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 = ({ flight, onClick }) => { +export const FlightCard: FC = ({ + flight, + onClick, + expandable, + onViewDetails, +}) => { + const { t } = useTranslation(); const departureLeg = getPrimaryLeg(flight); const arrivalLeg = getFinalLeg(flight); @@ -45,82 +80,220 @@ export const FlightCard: FC = ({ flight, onClick }) => { const flightNumber = `${flight.flightId.carrier} ${flight.flightId.flightNumber}`; const carrier = operatingCarrier(flight.operatingBy) ?? flight.flightId.carrier; - const clickable = Boolean(onClick); + const aircraftName = + departureLeg.equipment?.aircraft?.actual?.title ?? + departureLeg.equipment?.aircraft?.scheduled?.title ?? + null; + + const [expanded, setExpanded] = useState(false); + const rowClickable = expandable || Boolean(onClick); + const toggleExpanded = (): void => { + if (expandable) { + setExpanded((v) => !v); + } else if (onClick) { + onClick(); + } + }; + + const depScheduled = timeWithOffset(depTimes.scheduledDeparture.local); + const depActual = depTimes.actualBlockOff?.local + ? timeWithOffset(depTimes.actualBlockOff.local) + : null; + const arrScheduled = timeWithOffset(arrTimes.scheduledArrival.local); + const arrActual = arrTimes.actualBlockOn?.local + ? timeWithOffset(arrTimes.actualBlockOn.local) + : null; + + const boarding = departureLeg.transition?.boarding; + const BOARDING_STATUS_KEY: Record = { + Finished: "BOARDING-STATUSES.Finished", + Expected: "BOARDING-STATUSES.Expected", + InProgress: "BOARDING-STATUSES.InProgress", + Specified: "BOARDING-STATUSES.Specified", + Scheduled: "BOARDING-STATUSES.Scheduled", + }; return (
) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onClick!(); - } - }, - } - : {})} > -
-
{flightNumber}
+
) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleExpanded(); + } + }, + } + : {})} + > +
+
{flightNumber}
+ {expanded && aircraftName && ( +
{aircraftName}
+ )} +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + {expandable && ( + + )}
-
- -
+ {expandable && expanded && ( +
+
+
{t("SHARED.TIME")}
+
+
+ + {t("SHARED.SCHEDULED")} + + {depScheduled} +
+ {depActual && ( +
+ + {t("SHARED.ACTUAL")} + + {depActual} +
+ )} +
+
+
+ + {t("SHARED.SCHEDULED")} + + {arrScheduled} +
+ {arrActual && ( +
+ + {t("SHARED.ACTUAL")} + + {arrActual} +
+ )} +
+
-
- -
+ {boarding && ( +
+
+ {t("DETAILS.BOARDING")} +
+
+
+ + {t("DETAILS.STATUS")} + + + {t(BOARDING_STATUS_KEY[boarding.status] ?? boarding.status)} + +
+ {boarding.start?.local && ( +
+ + {t("SHARED.BOARDING-START")} + + + {formatLocalTime(boarding.start.local)} + +
+ )} + {boarding.end?.local && ( +
+ + {t("SHARED.BOARDING-END")} + + + {formatLocalTime(boarding.end.local)} + +
+ )} +
+
+ )} -
- -
- -
- -
- -
- -
- -
- -
+ {onViewDetails && ( +
+ +
+ )} +
+ )}
); }; diff --git a/src/ui/flights/FlightList.tsx b/src/ui/flights/FlightList.tsx index 294d2b49..8ef68c15 100644 --- a/src/ui/flights/FlightList.tsx +++ b/src/ui/flights/FlightList.tsx @@ -47,7 +47,10 @@ export const FlightList: FC = ({ onFlightClick(flight) } : {})} + expandable={Boolean(onFlightClick)} + {...(onFlightClick + ? { onViewDetails: () => onFlightClick(flight) } + : {})} /> ))}