baseline: carry WIP schedule/UI changes from main
Pulls in 13 modified + 4 new source files that were uncommitted on main when this branch forked. Without them, ScheduleStartPage.test.tsx fails 4 tests against the committed main state, which would mask real regressions during the CI/CD pipeline rollout. Source files only — no test infra or pipeline code. The user's main checkout still owns these changes; this commit will dedupe naturally once the branches reconcile.
This commit is contained in:
@@ -3,11 +3,22 @@
|
||||
@use "../../../styles/colors" as colors;
|
||||
@use "../../../styles/shadows" as shadows;
|
||||
|
||||
// Schedule-parity sidebar: the OnlineBoard filter's city/airport selector
|
||||
// now uses the same visual language as `ScheduleFilter`:
|
||||
// - plain white `section.frame` (no $blue-extra-light tint),
|
||||
// - flat accordion headers without PrimeNG chrome (no shadows / borders /
|
||||
// pill radii), slightly muted label color, chevron on the right,
|
||||
// - shared `.filter-content`, `.label--filter`, `.input--filter`,
|
||||
// `.calendar-input-wrapper`, `.search-button` rules matching Schedule's.
|
||||
|
||||
.online-board-filter {
|
||||
section.frame {
|
||||
background-color: colors.$blue-extra-light;
|
||||
background-color: colors.$white;
|
||||
}
|
||||
|
||||
// Accordion tab list — kept so the user can toggle between the
|
||||
// "Flight number" and "Route" search modes. Visually it's now just
|
||||
// a clickable row, not a pill, so it reads like a subtle divider.
|
||||
.p-accordion {
|
||||
.p-accordion-tab {
|
||||
.p-accordion-header {
|
||||
@@ -15,15 +26,17 @@
|
||||
margin: 0;
|
||||
|
||||
a {
|
||||
background-color: transparent;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: colors.$blue;
|
||||
border-radius: 0;
|
||||
padding: 0 vars.$space-l 0 vars.$space-xl;
|
||||
height: vars.$button-height;
|
||||
padding: vars.$space-m vars.$space-xl;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: fonts.$font-bold;
|
||||
font-size: fonts.$font-size-m;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
@@ -35,46 +48,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The currently-active tab header reads slightly muted (matches
|
||||
// Schedule's plain form label) and drops any pill/shadow chrome.
|
||||
&.p-highlight {
|
||||
a {
|
||||
background-color: colors.$white;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: colors.$text-color;
|
||||
color: colors.$gray;
|
||||
font-weight: fonts.$font-medium;
|
||||
}
|
||||
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.p-accordion-content {
|
||||
// Schedule uses a flat white panel — no shadow / bottom border.
|
||||
box-shadow: none;
|
||||
border-bottom: none;
|
||||
padding: 0 vars.$space-xl vars.$space-xl;
|
||||
background: colors.$white;
|
||||
}
|
||||
|
||||
&:first-child .p-accordion-header a {
|
||||
border-radius: vars.$border-radius vars.$border-radius 0 0;
|
||||
}
|
||||
|
||||
// Thin hairline between tabs, matching Schedule's subtle section
|
||||
// divider above `Популярные разделы`.
|
||||
&:not(:last-child) .p-accordion-header {
|
||||
border-bottom: 1px solid colors.$border;
|
||||
@include shadows.box-shadow-small;
|
||||
padding: vars.$space-s vars.$space-xl vars.$space-xl vars.$space-xl;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
.p-accordion-header {
|
||||
a {
|
||||
border-radius: vars.$border-radius vars.$border-radius 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
.p-accordion-header {
|
||||
border-bottom: 1px solid colors.$border;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
.p-accordion-content {
|
||||
border-radius: 0 0 vars.$border-radius vars.$border-radius;
|
||||
border: none;
|
||||
}
|
||||
&:last-child .p-accordion-content {
|
||||
border-radius: 0 0 vars.$border-radius vars.$border-radius;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors Angular `.label--filter` — 12px regular $gray with
|
||||
// $label-margin-bottom under the label. Matches ScheduleFilter.
|
||||
.label--filter {
|
||||
display: block;
|
||||
margin-right: vars.$space-xl;
|
||||
@@ -155,49 +168,29 @@
|
||||
// `styles/_icons.scss`.
|
||||
|
||||
.wrapper--time-selector {
|
||||
margin-top: vars.$space-xl;
|
||||
// Schedule uses the compact (inline label + value) layout everywhere,
|
||||
// so drop the legacy top margin that OnlineBoard inherited.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: vars.$space-s2;
|
||||
|
||||
.time-selector__label-value {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.time-selector__label {
|
||||
font-size: fonts.$font-size-s;
|
||||
color: colors.$gray;
|
||||
margin-bottom: vars.$space-s;
|
||||
}
|
||||
|
||||
.time-selector {
|
||||
padding: 0 vars.$space-s;
|
||||
color: colors.$light-gray;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.time-selector__value {
|
||||
font-size: fonts.$font-size-s;
|
||||
color: colors.$gray;
|
||||
margin-top: vars.$space-s;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&.compact-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
.time-selector__label-value {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.time-selector__label {
|
||||
color: colors.$text-color;
|
||||
font-size: fonts.$font-size-s;
|
||||
font-weight: fonts.$font-bold;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.time-selector__value {
|
||||
color: colors.$light-gray;
|
||||
font-size: fonts.$font-size-s;
|
||||
margin-top: 0;
|
||||
text-align: left;
|
||||
}
|
||||
color: colors.$text-color;
|
||||
font-weight: fonts.$font-medium;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,10 +238,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.calendar {
|
||||
// margin-top removed: vertical rhythm now driven by .filter-content gap.
|
||||
}
|
||||
|
||||
.calendar-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -284,27 +273,29 @@
|
||||
}
|
||||
|
||||
.filter-content {
|
||||
// Vertical rhythm between filter rows. Angular's accordion content
|
||||
// separates fields by ~$space-l (15px); the previous default
|
||||
// packed inputs about ~6 px tighter and surfaced as a measurable
|
||||
// pixel-diff against Angular on the start page.
|
||||
// Vertical rhythm between filter rows — same as Schedule
|
||||
// ($space-l / 15px between fields).
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: vars.$space-l;
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
margin-top: 0;
|
||||
margin-top: vars.$space-l;
|
||||
}
|
||||
|
||||
// Mirrors Angular `.search-button.color.blue-light` and Schedule's
|
||||
// submit button: 48px tall pill with $blue-light background.
|
||||
.search-button {
|
||||
margin-top: vars.$space-xl;
|
||||
width: 100%;
|
||||
height: vars.$standard-button-height;
|
||||
background-color: colors.$blue-light;
|
||||
color: colors.$white;
|
||||
border: none;
|
||||
border-radius: vars.$border-radius;
|
||||
padding: 0 vars.$space-l;
|
||||
font-size: fonts.$font-size-m;
|
||||
font-weight: fonts.$font-bold;
|
||||
cursor: pointer;
|
||||
transition-duration: 0.2s;
|
||||
|
||||
@@ -337,7 +328,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
// PrimeReact AutoComplete dropdown button — match Angular's subtle chevron
|
||||
// PrimeReact AutoComplete dropdown button — subtle chevron, matches
|
||||
// Schedule.
|
||||
.p-autocomplete-dropdown {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
|
||||
@@ -5,7 +5,22 @@
|
||||
.day-grouped-flight-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
// Angular's `schedule-days .frame` lays day blocks flush — no gap; a
|
||||
// single 1.3px hairline divider between siblings is drawn from the
|
||||
// group's `::before` (see &__group + &__group below).
|
||||
gap: 0;
|
||||
|
||||
// When the column-headers row immediately follows the week-tabs inside
|
||||
// the sticky card (the Angular-parity layout), cancel the WeekTabs
|
||||
// bottom margin so the two sit flush together.
|
||||
.week-tabs + &__column-headers,
|
||||
.week-tabs + * + &__column-headers {
|
||||
margin-top: 0;
|
||||
}
|
||||
.week-tabs:has(+ &__column-headers),
|
||||
.week-tabs:has(+ * + &__column-headers) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&__column-headers {
|
||||
display: grid;
|
||||
@@ -14,13 +29,19 @@
|
||||
grid-template-columns:
|
||||
80px 120px 100px minmax(45px, 240px) 100px 100px minmax(45px, 240px) 10px;
|
||||
gap: 16px;
|
||||
padding: 14px 24px;
|
||||
padding: 10px 24px;
|
||||
color: colors.$light-gray;
|
||||
font-size: 11px;
|
||||
font-weight: fonts.$font-medium;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 1px solid colors.$border;
|
||||
// Put every column label on the same top baseline so "ВЫЛЕТ *" /
|
||||
// "ПРИЛЕТ *" with their sort arrows don't push the row taller
|
||||
// than "РЕЙС" / "ВРЕМЯ В ПУТИ". Each cell is top-aligned; the sort
|
||||
// stack is absolute-positioned relative to the cell so it doesn't
|
||||
// expand the row.
|
||||
align-items: start;
|
||||
|
||||
// The first two header labels span the first two grid columns.
|
||||
> span:nth-child(1) { grid-column: 1; }
|
||||
@@ -37,17 +58,30 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__col-asterisk {
|
||||
margin-left: 2px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
&__sort-group {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
line-height: 0;
|
||||
// Shrink the two 6px triangles so they fit within one text line
|
||||
// height without inflating the header row.
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
&__sort {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
// Global `button { min-height: 35px }` in styles/_buttons.scss would
|
||||
// otherwise inflate each 6px triangle to 35px and double the column
|
||||
// header row height.
|
||||
min-height: 0;
|
||||
height: 6px;
|
||||
cursor: pointer;
|
||||
color: colors.$border-blue;
|
||||
line-height: 0;
|
||||
@@ -60,23 +94,36 @@
|
||||
&--active { color: colors.$blue; }
|
||||
}
|
||||
|
||||
// Angular's `schedule-days .frame` renders each day flat — no per-group
|
||||
// border or rounded corners. A 1.3px top hairline divides siblings,
|
||||
// inset 20px on both sides (see `flight-border-top` mixin in
|
||||
// schedule-search-result.scss). The divider is drawn via a `::before`
|
||||
// on every group except the first.
|
||||
&__group {
|
||||
border: 1px solid colors.$border;
|
||||
border-radius: vars.$border-radius;
|
||||
overflow: hidden;
|
||||
background: colors.$white;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Angular's `schedule-search-result-day` wraps the whole row in
|
||||
// `padding: $space-xl` (20px). Match it so the day group header has
|
||||
// the same visual weight as the Angular reference.
|
||||
&__group + &__group::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: vars.$space-xl;
|
||||
right: vars.$space-xl;
|
||||
height: 1.3px;
|
||||
background: colors.$border;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// Angular's `schedule-search-result-day` stacks the weekday above the
|
||||
// date (small-gray "Вторник" on top, bold "21 Апреля" below). The
|
||||
// chevron stays vertically centered against the stacked title.
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: vars.$space-m2;
|
||||
padding: vars.$space-xl;
|
||||
background: colors.$blue-extra-light;
|
||||
border-bottom: 1px solid colors.$border;
|
||||
background: colors.$white;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
@@ -90,6 +137,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Empty-day header should not change background on hover (mirrors
|
||||
// Angular's disabled-looking row with cursor:default + opacity 0.5).
|
||||
&__group--empty &__header:hover {
|
||||
background: colors.$white;
|
||||
}
|
||||
|
||||
&__header-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
&__weekday {
|
||||
color: colors.$light-gray;
|
||||
font-size: 13px;
|
||||
@@ -101,19 +162,29 @@
|
||||
color: colors.$blue-dark;
|
||||
font-size: fonts.$font-size-xl;
|
||||
font-weight: fonts.$font-medium;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
// The SVG path is an UP-pointing chevron (apex at top). Angular's
|
||||
// `arrow-down-icon` uses the same path and applies `rotate(180deg)`
|
||||
// by default (down, "click to expand") and `rotate(0deg)` when
|
||||
// `[rotated]=true` i.e. expanded (up, "click to collapse").
|
||||
&__chevron {
|
||||
margin-left: auto;
|
||||
color: colors.$blue;
|
||||
transition: transform 0.2s ease;
|
||||
transform: rotate(0deg);
|
||||
|
||||
&--collapsed {
|
||||
transform: rotate(-90deg);
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__group--collapsed &__header {
|
||||
border-bottom: none;
|
||||
// Empty days (no flights for that date) render faded + no chevron,
|
||||
// mirroring Angular's `[style.opacity]="scheduleItem.flights.length ? '1' : '0.5'"`.
|
||||
&__group--empty &__header {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Styles mirror Angular's `schedule-search-result-header.scss`:
|
||||
* - Flex row, padding 0 20px, h-spacing 10px between cells.
|
||||
* - Each cell is 56px tall (big-button-height), 12px uppercase gray text.
|
||||
* - Sort buttons 12×12 px, 30% opacity faded, border on active.
|
||||
* - Asterisk note (`*`) absolutely positioned top-right of the label.
|
||||
*/
|
||||
|
||||
@use "../../../styles/colors" as colors;
|
||||
@use "../../../styles/variables" as vars;
|
||||
@use "../../../styles/fonts" as fonts;
|
||||
|
||||
.schedule-col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 vars.$space-xl;
|
||||
background: colors.$white;
|
||||
|
||||
// 10px horizontal gap between cells (h-spacing $space-m in Angular).
|
||||
> div + div {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
// When placed inside the sticky card directly after the week-tabs,
|
||||
// the week-tabs bottom margin / card padding shouldn't pad the cell.
|
||||
// Kill any margin-top so it sits flush.
|
||||
margin-top: 0;
|
||||
|
||||
&__flight {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
&__company {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
&__departure,
|
||||
&__arrival {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__time {
|
||||
width: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 12px;
|
||||
font-family: fonts.$font-family;
|
||||
font-weight: fonts.$font-regular;
|
||||
color: colors.$gray;
|
||||
text-transform: uppercase;
|
||||
line-height: normal;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
|
||||
&--note {
|
||||
position: relative;
|
||||
padding-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&__note {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 0;
|
||||
font-size: 10px;
|
||||
color: colors.$gray;
|
||||
}
|
||||
|
||||
&__sort-container {
|
||||
margin-left: 5px;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__sort {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
min-height: 0; // override global `button { min-height: 35px }`
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: colors.$white;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
color: colors.$gray;
|
||||
opacity: 0.3;
|
||||
line-height: 0;
|
||||
transition: opacity 0.15s, border-color 0.15s;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
border-color: colors.$border;
|
||||
}
|
||||
|
||||
&--active {
|
||||
opacity: 0.7;
|
||||
border-color: #002776;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Sortable column header row for the schedule route-results page.
|
||||
*
|
||||
* Structure mirrors Angular's `schedule-search-result-header` — a flex
|
||||
* row where each column cell contains a `.sort-label` (optionally with
|
||||
* an absolutely-positioned asterisk note) and a `.sort-container` with
|
||||
* stacked up/down sort buttons. Widths: РЕЙС 80px, АВИАКОМПАНИЯ 120px,
|
||||
* ВЫЛЕТ flex:1, ВРЕМЯ 80px, ПРИЛЕТ flex:1.
|
||||
*
|
||||
* Sort state is owned by the parent page (ScheduleSearchPage), which
|
||||
* also passes it to `DayGroupedFlightList` so the two stay in sync.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import "./ScheduleColumnHeaders.scss";
|
||||
|
||||
export type ScheduleSortMode =
|
||||
| "none"
|
||||
| "departureUp"
|
||||
| "departureDown"
|
||||
| "timeUp"
|
||||
| "timeDown"
|
||||
| "arrivalUp"
|
||||
| "arrivalDown";
|
||||
|
||||
export interface ScheduleColumnHeadersProps {
|
||||
sortMode: ScheduleSortMode;
|
||||
onSortChange: (mode: ScheduleSortMode) => void;
|
||||
}
|
||||
|
||||
export const ScheduleColumnHeaders: FC<ScheduleColumnHeadersProps> = ({
|
||||
sortMode,
|
||||
onSortChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggle = (mode: ScheduleSortMode): void => {
|
||||
onSortChange(sortMode === mode ? "none" : mode);
|
||||
};
|
||||
|
||||
const sortBtn = (mode: ScheduleSortMode, dir: "up" | "down") => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(mode)}
|
||||
className={`schedule-col-header__sort schedule-col-header__sort--${dir}${
|
||||
sortMode === mode ? " schedule-col-header__sort--active" : ""
|
||||
}`}
|
||||
aria-label={`${dir === "up" ? "↑" : "↓"} ${mode}`}
|
||||
data-testid={`schedule-sort-${mode}`}
|
||||
>
|
||||
<svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true">
|
||||
{dir === "up" ? (
|
||||
<path d="M5 2L9 8H1Z" fill="currentColor" />
|
||||
) : (
|
||||
<path d="M5 8L1 2H9Z" fill="currentColor" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="schedule-col-header"
|
||||
data-testid="schedule-column-headers"
|
||||
>
|
||||
<div className="schedule-col-header__flight">
|
||||
<div className="schedule-col-header__label">
|
||||
{t("SCHEDULE.COL-FLIGHT") || "РЕЙС"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="schedule-col-header__company">
|
||||
<div className="schedule-col-header__label">
|
||||
{t("SCHEDULE.COL-AIRLINE") || "АВИАКОМПАНИЯ, БОРТ"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="schedule-col-header__departure">
|
||||
<div className="schedule-col-header__label schedule-col-header__label--note">
|
||||
{t("SCHEDULE.COL-DEPARTURE") || "ВЫЛЕТ"}
|
||||
<span className="schedule-col-header__note" aria-hidden="true">*</span>
|
||||
</div>
|
||||
<div className="schedule-col-header__sort-container">
|
||||
{sortBtn("departureUp", "up")}
|
||||
{sortBtn("departureDown", "down")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="schedule-col-header__time">
|
||||
<div className="schedule-col-header__label">
|
||||
{t("SCHEDULE.COL-DURATION") || "ВРЕМЯ В ПУТИ"}
|
||||
</div>
|
||||
<div className="schedule-col-header__sort-container">
|
||||
{sortBtn("timeUp", "up")}
|
||||
{sortBtn("timeDown", "down")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="schedule-col-header__arrival">
|
||||
<div className="schedule-col-header__label schedule-col-header__label--note">
|
||||
{t("SCHEDULE.COL-ARRIVAL") || "ПРИЛЕТ"}
|
||||
<span className="schedule-col-header__note" aria-hidden="true">*</span>
|
||||
</div>
|
||||
<div className="schedule-col-header__sort-container">
|
||||
{sortBtn("arrivalUp", "up")}
|
||||
{sortBtn("arrivalDown", "down")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -269,8 +269,35 @@
|
||||
|
||||
&__timeline-time {
|
||||
flex-shrink: 0;
|
||||
|
||||
// Shrink the TimeGroup time labels inside the route timeline and
|
||||
// each leg row. The default (30px / light) is reserved for the
|
||||
// collapsed summary row; inside the expanded body times read about
|
||||
// half that — roughly matching Angular's `time-group size="small"`
|
||||
// (16px). Apply to the sub-leg time columns as well.
|
||||
.time-group__scheduled,
|
||||
.time-group__actual {
|
||||
font-size: 15px;
|
||||
font-weight: fonts.$font-medium;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
&__leg-time {
|
||||
.time-group__scheduled,
|
||||
.time-group__actual {
|
||||
font-size: 15px;
|
||||
font-weight: fonts.$font-medium;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
// The `section` is the space between two timestamps in the route
|
||||
// timeline. A single continuous 1px line runs horizontally across its
|
||||
// center (via `::before`). The segment label ("1ч. 25мин.") sits
|
||||
// ABOVE the line, and the section-number badge ("[1]") sits ON the
|
||||
// line — its white background covers the line so the connector looks
|
||||
// continuous (matching Angular's `connecting-flight-body` route bar).
|
||||
&__timeline-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -280,16 +307,34 @@
|
||||
color: colors.$light-gray;
|
||||
font-size: 13px;
|
||||
position: relative;
|
||||
padding: 0 vars.$space-s;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-top: 1px solid colors.$border;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// The old structural bars are replaced by the section `::before`; keep
|
||||
// the elements in the DOM (so TSX doesn't need to change) but hide them.
|
||||
&__timeline-bar {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
border-top: 1px solid colors.$border;
|
||||
display: none;
|
||||
}
|
||||
|
||||
// [1]/[2] sits centered on the line. Absolute-position it at 50% so
|
||||
// the number box vertically aligns with the connector, with its white
|
||||
// background hiding the line behind the box.
|
||||
&__timeline-section-num {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -301,11 +346,16 @@
|
||||
color: colors.$text-color;
|
||||
font-size: fonts.$font-size-s;
|
||||
font-weight: fonts.$font-medium;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
// "1ч. 25мин." label sits BELOW the line — reserve top space equal to
|
||||
// the number-badge height (~22px) + a gap so the label clears the line.
|
||||
&__timeline-section-dur {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 22px;
|
||||
padding: 0 4px;
|
||||
background: colors.$white;
|
||||
color: colors.$light-gray;
|
||||
font-size: fonts.$font-size-s;
|
||||
white-space: nowrap;
|
||||
@@ -338,9 +388,15 @@
|
||||
&:not(:first-child):not(:last-child) { text-align: center; }
|
||||
}
|
||||
|
||||
// Angular renders the route-timeline city names at 22px / 300 (light),
|
||||
// measured on the live `connecting-flight-body`. Bumps them well above
|
||||
// the surrounding 14px body copy so the three-stop diagram reads like
|
||||
// a headline.
|
||||
&__timeline-station-city {
|
||||
color: colors.$text-color;
|
||||
font-weight: fonts.$font-medium;
|
||||
font-size: fonts.$font-size-xl2;
|
||||
font-weight: fonts.$font-light;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__timeline-station-terminal {
|
||||
|
||||
@@ -76,10 +76,13 @@ vi.mock("@/shared/hooks/useCitySearch.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/ui/city-autocomplete/index.js", () => ({
|
||||
// Controlled mock — reflects `value` prop changes so tests can assert
|
||||
// post-update form state, not just mount-time prefill.
|
||||
CityAutocomplete: (props: Record<string, unknown>) => (
|
||||
<input
|
||||
data-testid={`${(props["testIdPrefix"] as string) ?? "city-autocomplete"}-input`}
|
||||
defaultValue={(props["value"] as string) ?? ""}
|
||||
value={(props["value"] as string) ?? ""}
|
||||
readOnly
|
||||
/>
|
||||
),
|
||||
SwapCityButton: (props: { onClick: () => void; testId?: string }) => (
|
||||
@@ -188,35 +191,38 @@ describe("ScheduleStartPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("4.1.5-S1: one-way Route click prefills current ISO week dates (from clamped to today-1) + no return", () => {
|
||||
it("4.1.5-S1: one-way Route click populates form with current ISO week dates (from clamped to today-1) + no return", () => {
|
||||
// 2026-05-15 (Fri) → raw Mon 2026-05-11, raw Sun 2026-05-17
|
||||
// `from` is clamped to today−1 = 2026-05-14 so the route guard does
|
||||
// not redirect the search back to the start page.
|
||||
// Same-page Schedule click updates form state directly (navigate to
|
||||
// the same route would no-op), so we assert visible form state and
|
||||
// submit the form to verify the dates landed in component state.
|
||||
render(<ScheduleStartPage />);
|
||||
fireEvent.click(screen.getByTestId("popular-click-route"));
|
||||
const stored = JSON.parse(sessionStore.getRaw("afl-prefill:schedule")!);
|
||||
expect(stored.departure).toBe("SVO");
|
||||
expect(stored.arrival).toBe("LED");
|
||||
expect(stored.withReturn).toBe(false);
|
||||
expect(stored.dateFrom).toBe("20260514"); // clamped to today−1 (raw Mon was 2026-05-11)
|
||||
expect(stored.dateTo).toBe("20260517"); // Sun
|
||||
expect(stored.returnDateFrom).toBeUndefined();
|
||||
expect(stored.returnDateTo).toBeUndefined();
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule");
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
expect((screen.getByTestId("schedule-departure-input") as HTMLInputElement).value).toBe("SVO");
|
||||
expect((screen.getByTestId("schedule-arrival-input") as HTMLInputElement).value).toBe("LED");
|
||||
expect((screen.getByTestId("round-trip-toggle") as HTMLInputElement).checked).toBe(false);
|
||||
expect(screen.queryByTestId("return-date-range-input")).toBeNull();
|
||||
|
||||
// Submit drives the dates from state into the URL — proves they were set.
|
||||
fireEvent.submit(screen.getByTestId("schedule-search-form"));
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule/route/SVO-LED-20260514-20260517");
|
||||
});
|
||||
|
||||
it("4.1.5-S2: round-trip RouteWithBack click prefills current + next week dates (outbound from clamped)", () => {
|
||||
it("4.1.5-S2: round-trip RouteWithBack click populates form with current + next week dates (outbound from clamped)", () => {
|
||||
// current week raw: 20260511-20260517 (clamped from: 20260514-20260517)
|
||||
// next week: 20260518-20260524 (unclamped — future)
|
||||
render(<ScheduleStartPage />);
|
||||
fireEvent.click(screen.getByTestId("popular-click-roundtrip"));
|
||||
const stored = JSON.parse(sessionStore.getRaw("afl-prefill:schedule")!);
|
||||
expect(stored.withReturn).toBe(true);
|
||||
expect(stored.dateFrom).toBe("20260514"); // clamped
|
||||
expect(stored.dateTo).toBe("20260517");
|
||||
expect(stored.returnDateFrom).toBe("20260518"); // next Mon
|
||||
expect(stored.returnDateTo).toBe("20260524"); // next Sun
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule");
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
expect((screen.getByTestId("round-trip-toggle") as HTMLInputElement).checked).toBe(true);
|
||||
|
||||
fireEvent.submit(screen.getByTestId("schedule-search-form"));
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
"/ru-ru/schedule/route/SVO-LED-20260514-20260517/LED-SVO-20260518-20260524",
|
||||
);
|
||||
});
|
||||
|
||||
it("4.1.5-S3: prefill dates hydrate into form calendar state (no search on mount)", () => {
|
||||
@@ -250,13 +256,17 @@ describe("ScheduleStartPage", () => {
|
||||
expect((roundTripCheckbox as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
|
||||
it("writes prefill + navigates to onlineboard on Onlineboard-type popular click", () => {
|
||||
it("Onlineboard-type Departure popular click stays on Schedule and sets departure only", () => {
|
||||
// Deviation from Angular: Angular always navigates Arrival/Departure
|
||||
// popular clicks to /onlineboard. We instead populate the relevant
|
||||
// Schedule field in-place so users planning a route don't lose context.
|
||||
render(<ScheduleStartPage />);
|
||||
fireEvent.click(screen.getByTestId("popular-click-onlineboard"));
|
||||
expect(sessionStore.getRaw("afl-prefill:online-board")).toBe(
|
||||
JSON.stringify({ tab: "route", departure: "LED" }),
|
||||
);
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/onlineboard");
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
expect((screen.getByTestId("schedule-departure-input") as HTMLInputElement).value).toBe("LED");
|
||||
expect((screen.getByTestId("schedule-arrival-input") as HTMLInputElement).value).toBe("");
|
||||
// Onlineboard-type clicks must not write to either prefill slot.
|
||||
expect(sessionStore.getRaw("afl-prefill:online-board")).toBeNull();
|
||||
});
|
||||
|
||||
it("initializes form from sessionStorage prefill (legacy shape — withReturn only)", () => {
|
||||
@@ -288,14 +298,13 @@ describe("4.1.9-R: Current-Week label substitution", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("4.1.9-R: start page renders with current-week dates pre-populated in session store on Route click", () => {
|
||||
it("4.1.9-R: start page populates date range with current week on Route click", () => {
|
||||
render(<ScheduleStartPage />);
|
||||
fireEvent.click(screen.getByTestId("popular-click-route"));
|
||||
const stored = JSON.parse(sessionStore.getRaw("afl-prefill:schedule")!);
|
||||
// Current week Sun for 2026-05-15 is 2026-05-17; `from` is clamped to
|
||||
// today−1 = 2026-05-14 so the range is inside Schedule's −1/+330 window.
|
||||
expect(stored.dateFrom).toBe("20260514");
|
||||
expect(stored.dateTo).toBe("20260517");
|
||||
fireEvent.submit(screen.getByTestId("schedule-search-form"));
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/ru-ru/schedule/route/SVO-LED-20260514-20260517");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,61 +2,104 @@
|
||||
@use "../../../styles/variables" as vars;
|
||||
@use "../../../styles/fonts" as fonts;
|
||||
|
||||
// Mirrors Angular's `date-tabs` + `tab-button` (see ClientApp/src/app/
|
||||
// toolkit/date-tabs/*). Each tab is a flat rectangle on $blue-extra-light
|
||||
// with a 1px border-right between siblings and a 1px border-bottom along
|
||||
// the row; the active tab is white with no bottom border so it visually
|
||||
// "merges" into the content below. Carousel-style chevron arrows sit at
|
||||
// each end (top-rounded outer corner, $blue-extra-light fill).
|
||||
.week-tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: vars.$border-radius;
|
||||
padding: 4px;
|
||||
margin-bottom: vars.$space-m2;
|
||||
|
||||
// When week-tabs sits directly above the column-header row inside the
|
||||
// sticky card (Angular parity layout), cancel the bottom margin so the
|
||||
// two rows sit flush together.
|
||||
&:has(+ .schedule-col-header),
|
||||
&:has(+ .schedule-direction-switch + .schedule-col-header) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&__nav {
|
||||
background: transparent;
|
||||
flex: 0 0 50px;
|
||||
width: 50px;
|
||||
max-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: colors.$blue-extra-light;
|
||||
border: none;
|
||||
color: colors.$light-gray;
|
||||
font-size: fonts.$font-size-xl;
|
||||
width: 28px;
|
||||
border-bottom: 1px solid colors.$border;
|
||||
color: colors.$blue;
|
||||
cursor: pointer;
|
||||
border-radius: vars.$border-radius;
|
||||
// Override the global `button { min-height: 35px }` so we can hit
|
||||
// the Angular 48px row height precisely.
|
||||
min-height: 0;
|
||||
height: 48px;
|
||||
line-height: 0;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
svg { display: block; }
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: colors.$blue-dark;
|
||||
background: colors.$blue-icon;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--prev {
|
||||
border-top-left-radius: vars.$border-radius;
|
||||
}
|
||||
|
||||
&--next {
|
||||
border-top-right-radius: vars.$border-radius;
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__tab {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: vars.$space-s2 vars.$space-m2;
|
||||
background: transparent;
|
||||
padding: 0 vars.$space-m2;
|
||||
height: 48px;
|
||||
max-height: 48px;
|
||||
// Override the global `button { min-height: 35px }`.
|
||||
min-height: 0;
|
||||
background: colors.$blue-extra-light;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
border-right: 1px solid colors.$border;
|
||||
border-bottom: 1px solid colors.$border;
|
||||
border-radius: 0;
|
||||
font-size: 12px;
|
||||
font-weight: fonts.$font-medium;
|
||||
color: colors.$blue;
|
||||
cursor: pointer;
|
||||
border-radius: vars.$border-radius;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover { background: colors.$blue-extra-light; }
|
||||
&:hover:not(:disabled):not(&--active) {
|
||||
background: colors.$blue-icon;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: colors.$white;
|
||||
// Hide the bottom border so the active tab visually merges into
|
||||
// the column-header / table below it (Angular parity).
|
||||
border-bottom-color: colors.$white;
|
||||
color: colors.$blue-dark;
|
||||
font-weight: fonts.$font-bold;
|
||||
box-shadow: inset 0 -2px 0 colors.$blue;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,9 @@ export const WeekTabs: FC<WeekTabsProps> = ({ selectedMonday, onNavigate }) => {
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
aria-label={t("SHARED.A11Y-PREV-PAGE")}
|
||||
>
|
||||
‹
|
||||
<svg viewBox="0 0 8 12" width="8" height="12" aria-hidden="true">
|
||||
<path d="M7 1L2 6L7 11" stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="week-tabs__list">
|
||||
{activeSlice.map((w) => {
|
||||
@@ -174,7 +176,9 @@ export const WeekTabs: FC<WeekTabsProps> = ({ selectedMonday, onNavigate }) => {
|
||||
onClick={() => setPage((p) => Math.min(activeTotalPages - 1, p + 1))}
|
||||
aria-label={t("SHARED.A11Y-NEXT-PAGE")}
|
||||
>
|
||||
›
|
||||
<svg viewBox="0 0 8 12" width="8" height="12" aria-hidden="true">
|
||||
<path d="M1 1L6 6L1 11" stroke="currentColor" strokeWidth="1.6" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -15,18 +15,28 @@ describe("4.1.9-R: formatScheduleDateRangeWithCurrentWeek", () => {
|
||||
).toBe("Текущая неделя");
|
||||
});
|
||||
|
||||
it("returns dd.MM.yyyy-dd.MM.yyyy for other ranges", () => {
|
||||
it("returns dd.MM.yyyy-dd.MM.yyyy for ranges that don't contain today", () => {
|
||||
const t = (k: string) => k;
|
||||
expect(
|
||||
formatScheduleDateRangeWithCurrentWeek(new Date(2026, 4, 18), new Date(2026, 4, 24), t),
|
||||
).toBe("18.05.2026-24.05.2026");
|
||||
});
|
||||
|
||||
it("returns dd.MM.yyyy-dd.MM.yyyy for partial current week", () => {
|
||||
const t = (k: string) => k;
|
||||
it("returns 'Текущая неделя' for partial current week containing today (matches Angular)", () => {
|
||||
// today = 2026-05-15 (Fri); range 2026-05-13 .. 2026-05-17 contains today.
|
||||
// Angular's CalendarInputWeekComponent uses `from <= today <= to`.
|
||||
const t = (k: string) => (k === "SCHEDULE.CURRENT-WEEK" ? "Текущая неделя" : k);
|
||||
expect(
|
||||
formatScheduleDateRangeWithCurrentWeek(new Date(2026, 4, 13), new Date(2026, 4, 17), t),
|
||||
).toBe("13.05.2026-17.05.2026");
|
||||
).toBe("Текущая неделя");
|
||||
});
|
||||
|
||||
it("returns 'Текущая неделя' for clamped popular-click range (today-1 .. Sun)", () => {
|
||||
// Popular-click on Schedule clamps `from` to today−1 = 2026-05-14.
|
||||
const t = (k: string) => (k === "SCHEDULE.CURRENT-WEEK" ? "Текущая неделя" : k);
|
||||
expect(
|
||||
formatScheduleDateRangeWithCurrentWeek(new Date(2026, 4, 14), new Date(2026, 4, 17), t),
|
||||
).toBe("Текущая неделя");
|
||||
});
|
||||
|
||||
it("returns empty string for null inputs", () => {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
/**
|
||||
* Schedule range-calendar label substitution per TZ §4.1.9 Table 14.
|
||||
* Current week Mon-Sun → "Текущая неделя", otherwise dd.MM.yyyy-dd.MM.yyyy.
|
||||
* Any range containing today → "Текущая неделя", otherwise dd.MM.yyyy-dd.MM.yyyy.
|
||||
*
|
||||
* Matches Angular `CalendarInputWeekComponent.getDateString()`: substitutes
|
||||
* the label whenever `from <= today <= to`, not only on an exact Mon-Sun
|
||||
* match. This covers the popular-click case where the start page clamps
|
||||
* the outbound `from` to today−1 to stay inside Schedule's [-1, +330] window.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -12,14 +17,6 @@ function toYmd(d: Date): string {
|
||||
return `${day}.${month}.${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
function mondayOfWeek(base: Date): Date {
|
||||
const d = new Date(base);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
const offset = (d.getDay() + 6) % 7;
|
||||
d.setDate(d.getDate() - offset);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function formatScheduleDateRangeWithCurrentWeek(
|
||||
dateFrom: Date | null | undefined,
|
||||
dateTo: Date | null | undefined,
|
||||
@@ -28,14 +25,11 @@ export function formatScheduleDateRangeWithCurrentWeek(
|
||||
if (!dateFrom || !dateTo) return "";
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const thisMon = mondayOfWeek(today);
|
||||
const thisSun = new Date(thisMon);
|
||||
thisSun.setDate(thisSun.getDate() + 6);
|
||||
const from = new Date(dateFrom);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
const to = new Date(dateTo);
|
||||
to.setHours(0, 0, 0, 0);
|
||||
if (from.getTime() === thisMon.getTime() && to.getTime() === thisSun.getTime()) {
|
||||
if (from.getTime() <= today.getTime() && today.getTime() <= to.getTime()) {
|
||||
return t("SCHEDULE.CURRENT-WEEK");
|
||||
}
|
||||
return `${toYmd(from)}-${toYmd(to)}`;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Convert the mixed `IFlight[]` schedule-search 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.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { FlightStatus, IFlightLeg } from "@/features/online-board/types.js";
|
||||
import type { ISimpleFlight } from "./types.js";
|
||||
|
||||
export 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: FlightStatus;
|
||||
};
|
||||
const first = conn.flights[0];
|
||||
if (!first) continue;
|
||||
const allLegs: 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;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Airport IATA → official site URL map. Mirrors Angular's
|
||||
* `ClientApp/src/app/shared/services/airports-data.service.ts`.
|
||||
* Used by the station-display terminal link.
|
||||
*/
|
||||
export const airportUrls: Readonly<Record<string, string>> = {
|
||||
SVO: "https://www.svo.aero/ru/main",
|
||||
VKO: "http://www.vnukovo.ru/",
|
||||
DME: "https://www.dme.ru/",
|
||||
ZIA: "http://www.zia.aero/",
|
||||
};
|
||||
|
||||
export function airportUrl(airportCode: string | undefined | null): string | undefined {
|
||||
if (!airportCode) return undefined;
|
||||
return airportUrls[airportCode];
|
||||
}
|
||||
@@ -30,17 +30,27 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// Angular `city-autocomplete__input` measured height = 46px with
|
||||
// 16px font-size (`Расписание рейсов` sidebar on the live site).
|
||||
// Previously this was 38px / default font-size which looked noticeably
|
||||
// shorter than Angular's pill.
|
||||
$city-input-h: 46px;
|
||||
|
||||
&__input {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: $city-input-h;
|
||||
box-shadow: 0 0 0 1px colors.$border-input;
|
||||
border-radius: vars.$border-radius;
|
||||
|
||||
.p-autocomplete {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// Reset the inner PrimeReact input's native border — the outer
|
||||
@@ -49,6 +59,9 @@
|
||||
input.p-inputtext {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
height: 100%;
|
||||
font-size: fonts.$font-size-l; // 16px, matches Angular
|
||||
padding: 0 vars.$space-l;
|
||||
}
|
||||
|
||||
// Also drop PrimeReact's blue focus shadow on the inner input
|
||||
@@ -61,7 +74,7 @@
|
||||
.button-clear {
|
||||
display: none;
|
||||
width: 32px;
|
||||
height: 38px;
|
||||
height: $city-input-h;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
@@ -91,7 +104,7 @@
|
||||
&__search-button {
|
||||
width: 38px !important;
|
||||
min-width: 38px;
|
||||
height: 38px;
|
||||
height: $city-input-h;
|
||||
border-radius: 0 vars.$border-radius vars.$border-radius 0 !important;
|
||||
border: none !important;
|
||||
border-left: 1px solid white !important;
|
||||
|
||||
@@ -62,12 +62,48 @@
|
||||
grid-template-columns:
|
||||
80px 120px 100px minmax(45px, 240px) 100px 100px minmax(45px, 240px) 10px;
|
||||
gap: 0 vars.$space-l;
|
||||
padding: vars.$space-xl;
|
||||
}
|
||||
|
||||
// Schedule row typography — values taken from the live Angular page
|
||||
// (computed styles on `list-scheduled-flight schedule-list-flight-header`,
|
||||
// measured 2026-04-23):
|
||||
// flight number 18px / 400 (regular)
|
||||
// time 30px / 300 (light)
|
||||
// station city 14px / 400 (regular)
|
||||
// station term 12px / 400 (regular, underlined)
|
||||
// duration text 12px / 400 (regular)
|
||||
&--schedule .flight-card__number {
|
||||
font-size: fonts.$font-size-xl;
|
||||
font-weight: fonts.$font-regular;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
&--schedule .flight-card__time .time-group__scheduled,
|
||||
&--schedule .flight-card__time .time-group__actual {
|
||||
font-size: fonts.$font-size-xxl;
|
||||
font-weight: fonts.$font-light;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
&--schedule .flight-card__station .station__city--bold {
|
||||
font-size: fonts.$font-size-m;
|
||||
font-weight: fonts.$font-regular;
|
||||
}
|
||||
|
||||
&--schedule .flight-card__station .station__terminal {
|
||||
font-size: fonts.$font-size-s;
|
||||
}
|
||||
|
||||
&--schedule .flight-card__duration {
|
||||
font-size: fonts.$font-size-s;
|
||||
}
|
||||
|
||||
&__number {
|
||||
font-weight: fonts.$font-medium;
|
||||
color: colors.$text-color;
|
||||
font-size: fonts.$font-size-m;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__aircraft {
|
||||
@@ -120,6 +156,61 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Angular hides the row-expand chevron unless the row is hovered or
|
||||
// already expanded (see schedule-list-flight-header.scss
|
||||
// `.arrow-icon { display: none } :host:hover .arrow-icon { display: initial }`).
|
||||
&--schedule .flight-card__chevron {
|
||||
visibility: hidden;
|
||||
}
|
||||
&--schedule .flight-card__row:hover .flight-card__chevron,
|
||||
&--schedule.flight-card--expanded .flight-card__chevron {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
// Angular renders a compact `transfer-inline` bar below the collapsed
|
||||
// schedule row for connecting flights. The bar is offset left by the
|
||||
// number + operator columns (`margin-left: 80px + 120px + 2 * $space-xl`
|
||||
// per schedule-list-flight-header.scss) and sits in a thin pill with
|
||||
// the dumbbell transfer icon tinted orange.
|
||||
&__transfer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: vars.$space-s;
|
||||
// 80 (number) + 120 (logos) + 20 (left pad) + 20 (gap) = 240px
|
||||
margin: 0 vars.$space-xl vars.$space-m 240px;
|
||||
padding: 6px vars.$space-m;
|
||||
background: colors.$white;
|
||||
border: 1px solid colors.$border;
|
||||
border-radius: vars.$border-radius;
|
||||
font-size: fonts.$font-size-s;
|
||||
color: colors.$text-color;
|
||||
width: fit-content;
|
||||
max-width: calc(100% - 240px - #{vars.$space-xl});
|
||||
}
|
||||
|
||||
&__transfer-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #f78c2f; // Aeroflot orange transfer-dot tint
|
||||
}
|
||||
|
||||
&__transfer-label {
|
||||
font-weight: fonts.$font-regular;
|
||||
color: colors.$text-color;
|
||||
}
|
||||
|
||||
&__transfer-dash {
|
||||
color: colors.$light-gray;
|
||||
}
|
||||
|
||||
&__transfer-stations {
|
||||
color: colors.$text-color;
|
||||
}
|
||||
|
||||
&__transfer-airport {
|
||||
color: colors.$blue;
|
||||
}
|
||||
|
||||
&__inline-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -355,7 +355,19 @@ export const FlightCard: FC<FlightCardProps> = ({
|
||||
: {})}
|
||||
>
|
||||
<div className="flight-card__number" data-testid="flight-carrier-number">
|
||||
<div>{flightNumber}</div>
|
||||
{/* Angular's `schedule-list-flight-header` stacks each leg's
|
||||
flight number on its own line (e.g. "SU 6951," / "SU 6345")
|
||||
in the schedule row. Outside schedule mode we keep the
|
||||
existing single-line presentation. */}
|
||||
{direction === "schedule" && childFlightIds && childFlightIds.length > 1 ? (
|
||||
childFlightIds.map((id, i) => (
|
||||
<div key={`${id.carrier}-${id.flightNumber}-${i}`}>
|
||||
{id.carrier} {id.flightNumber}{id.suffix ?? ""}{i < childFlightIds.length - 1 ? "," : ""}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div>{flightNumber}</div>
|
||||
)}
|
||||
{expanded && flight.routeType === "Direct" && aircraftName && (
|
||||
<div className="flight-card__aircraft">{aircraftName}</div>
|
||||
)}
|
||||
@@ -480,6 +492,56 @@ export const FlightCard: FC<FlightCardProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Angular `schedule-list-flight-header` renders a compact transfer
|
||||
bar below the row when the flight is collapsed and connecting
|
||||
(`*ngIf="!flight.expanded && flight.boardings >= 1"`). */}
|
||||
{direction === "schedule" && !expanded && flight.routeType !== "Direct" &&
|
||||
flight.legs.length > 1 && (
|
||||
<div
|
||||
className="flight-card__transfer"
|
||||
data-testid="flight-card-transfer"
|
||||
>
|
||||
<span className="flight-card__transfer-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 20 8" width="20" height="8">
|
||||
<circle cx="3" cy="4" r="3" fill="currentColor" />
|
||||
<path d="M6 4h8" stroke="currentColor" strokeWidth="1.5" />
|
||||
<circle cx="17" cy="4" r="3" fill="currentColor" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="flight-card__transfer-label">
|
||||
{t(
|
||||
flight.legs.length > 2
|
||||
? "SHARED.INTERMEDIATE-LANDING-PLURAL-OTHER"
|
||||
: "SHARED.FLIGHT-TRANSFER-PLURAL-ONE",
|
||||
)}
|
||||
</span>
|
||||
<span className="flight-card__transfer-dash"> — </span>
|
||||
<span className="flight-card__transfer-stations">
|
||||
{flight.legs.slice(0, -1).map((l, i) => {
|
||||
const s = l.arrival.scheduled;
|
||||
const terminal = l.arrival.terminal;
|
||||
const airportWithTerminal = terminal
|
||||
? `${s.airport} - ${terminal}`
|
||||
: s.airport;
|
||||
return (
|
||||
<span key={`tr-${i}`}>
|
||||
{i > 0 ? ", " : ""}
|
||||
{s.city}
|
||||
{s.airport ? (
|
||||
<>
|
||||
{", "}
|
||||
<span className="flight-card__transfer-airport">
|
||||
{airportWithTerminal}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expandable && expanded && renderExpandedBody && (
|
||||
<div
|
||||
className="flight-card__expanded flight-card__expanded--custom"
|
||||
|
||||
@@ -26,6 +26,19 @@
|
||||
color: colors.$light-gray;
|
||||
text-decoration: underline;
|
||||
line-height: 16px;
|
||||
|
||||
// The `--link` variant renders an <a> to the airport's site (SVO,
|
||||
// VKO, …). Match Angular's terminal-link blue hover state; the
|
||||
// dotted underline keeps it visually distinct from a full-blue
|
||||
// CTA without losing its "clickable" affordance.
|
||||
&--link {
|
||||
color: colors.$blue;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: colors.$blue--hover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--city-first {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FC } from "react";
|
||||
import type { FC, MouseEvent } from "react";
|
||||
import { useCityName } from "@/shared/hooks/useDictionaries.js";
|
||||
import { airportUrl } from "@/shared/airportUrls.js";
|
||||
import "./StationDisplay.scss";
|
||||
|
||||
export interface StationDisplayProps {
|
||||
@@ -32,14 +33,49 @@ export const StationDisplay: FC<StationDisplayProps> = ({
|
||||
}) => {
|
||||
const resolvedCity = cityName ?? useCityName(airportCode);
|
||||
const terminalLine = [airportName, terminal].filter(Boolean).join(" — ");
|
||||
const url = airportUrl(airportCode);
|
||||
|
||||
// Clicking the airport link should NOT toggle the parent flight row.
|
||||
const stopBubble = (e: MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// Airport tooltip mirrors Angular's `terminal-link` pTooltip — the
|
||||
// full "Airport name — Terminal N" (e.g. "Шереметьево — B"). The
|
||||
// city tooltip just shows the city name itself, matching station's
|
||||
// `[tooltip]="city"` ellipsis helper.
|
||||
const airportTooltip = terminalLine || airportName || undefined;
|
||||
const cityTooltip = resolvedCity;
|
||||
|
||||
const terminalEl = terminalLine ? (
|
||||
url ? (
|
||||
<a
|
||||
className="station__terminal station__terminal--link"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={stopBubble}
|
||||
title={airportTooltip}
|
||||
>
|
||||
{terminalLine}
|
||||
</a>
|
||||
) : (
|
||||
<span className="station__terminal" title={airportTooltip}>
|
||||
{terminalLine}
|
||||
</span>
|
||||
)
|
||||
) : null;
|
||||
|
||||
if (cityFirst) {
|
||||
return (
|
||||
<div className="station station--city-first">
|
||||
<span className="station__city station__city--bold">{resolvedCity}</span>
|
||||
{terminalLine ? (
|
||||
<span className="station__terminal">{terminalLine}</span>
|
||||
) : null}
|
||||
<span
|
||||
className="station__city station__city--bold"
|
||||
title={cityTooltip}
|
||||
>
|
||||
{resolvedCity}
|
||||
</span>
|
||||
{terminalEl}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,7 +86,9 @@ export const StationDisplay: FC<StationDisplayProps> = ({
|
||||
{airportName ? (
|
||||
<span className="station__name">{airportName}</span>
|
||||
) : null}
|
||||
<span className="station__city">{resolvedCity}</span>
|
||||
<span className="station__city" title={cityTooltip}>
|
||||
{resolvedCity}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user