Visual parity fixes vs Angular reference
- SharePanel: fix wrong i18n key (SHARED.COPY → SHARE.COPY) and switch
to the brand-icon-on-top + translated-label layout that Angular uses
(renders as untranslated raw key + plain text list before).
- LastUpdate: stamp now reflects when the client received the data, not
the API record's mutation timestamp — Angular sets `flight.lastUpdate
= new Date()` in populate.logic.ts; we mirror that behavior so users
no longer see stale 'updated' values from cached API rows.
- FlightCard: keep operator logo + plane icon + status text on mobile
(previously hidden via display:none); regrid to 3-col layout so the
card mirrors Angular's mobile pattern. Boarding status row gains the
leading colour-coded dot Angular ships ('Уточняется' grey).
- OnlineBoardSearchPage: H1 for /flight/... search now reads
'Рейс: SU 6497, Сегодня' instead of 'Номер рейса: SU6497' (matches
Angular's title.service); add the '* Время в системе - МЕСТНОЕ.'
footer note Angular's <page-footer-notes> renders.
- FlightsMap filter: drop the React-only 'Найдите свой маршрут'
header; replace the horizontal swap glyph with vertical blue arrows
(Angular rotates the same SVG 90deg); add Leaflet city-tooltip
styling so labels render text-only with a white text-shadow halo
rather than as PrimeReact-default white pills.
- DayQuickPick: new mobile-only 3-day quick-pick row above the manual
date input on both onlineboard and flights-map filters, mirroring
Angular's calendar-input.component .calendar--mobile block. Uses
Intl.DateTimeFormat formatToParts to get the genitive month form.
This commit is contained in:
@@ -12,6 +12,7 @@ import { Calendar } from "primereact/calendar";
|
||||
import { useParams } from "@modern-js/runtime/router";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import { CityAutocomplete } from "@/ui/city-autocomplete/index.js";
|
||||
import { DayQuickPick } from "@/ui/calendar/DayQuickPick.js";
|
||||
import { useDictionaries, findCityByCoord } from "@/shared/dictionaries/index.js";
|
||||
import {
|
||||
getMinDate,
|
||||
@@ -146,8 +147,6 @@ export const FlightsMapFilter: FC<FlightsMapFilterProps> = ({
|
||||
|
||||
return (
|
||||
<div className="flights-map-filter" data-testid="flights-map-filter">
|
||||
<h3 className="flights-map-filter__title">{t("FLIGHTS-MAP.ROUTE")}</h3>
|
||||
|
||||
<CityAutocomplete
|
||||
label={t("SHARED.DEPARTURE_CITY")}
|
||||
placeholder={t("FLIGHTS-MAP.FILTER_DEPARTURE_PLACEHOLDER")}
|
||||
@@ -171,7 +170,25 @@ export const FlightsMapFilter: FC<FlightsMapFilterProps> = ({
|
||||
aria-label={t("SHARED.CITY_CHANGE")}
|
||||
data-testid="fm-exchange-btn"
|
||||
>
|
||||
⇆
|
||||
<svg
|
||||
className="flights-map-filter__exchange-icon"
|
||||
width="12"
|
||||
height="25"
|
||||
viewBox="0 0 12 25"
|
||||
fill="#1b62b4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10 21.25H12L9.5 24.25L7 21.25H9V3.25H10V21.25Z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 3.25H0L2.5 0.25L5 3.25H3V21.25H2V3.25Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<CityAutocomplete
|
||||
@@ -235,6 +252,11 @@ export const FlightsMapFilter: FC<FlightsMapFilterProps> = ({
|
||||
|
||||
<div className="flights-map-filter__field">
|
||||
<label htmlFor="fm-date">{t("SHARED.FLIGHT_DATE")}</label>
|
||||
<DayQuickPick
|
||||
value={value.date ? yyyymmddToDate(value.date) : null}
|
||||
locale={lang ?? "ru"}
|
||||
onChange={handleDateChange}
|
||||
/>
|
||||
<Calendar
|
||||
value={value.date ? yyyymmddToDate(value.date) : null}
|
||||
onChange={(e) => handleDateChange(e.value as Date | null)}
|
||||
|
||||
@@ -154,20 +154,24 @@
|
||||
|
||||
&__exchange {
|
||||
align-self: center;
|
||||
width: vars.$standard-button-height;
|
||||
width: 35px;
|
||||
height: vars.$standard-button-height;
|
||||
background: colors.$white;
|
||||
@include shadows.control-border-shadow();
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition-duration: 0.2s;
|
||||
transition: opacity 0.2s;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
border-color: colors.$blue-light;
|
||||
}
|
||||
&:hover { opacity: 0.7; }
|
||||
}
|
||||
|
||||
&__exchange-icon {
|
||||
width: 12px;
|
||||
height: 25px;
|
||||
}
|
||||
|
||||
&__info {
|
||||
@@ -248,3 +252,25 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Leaflet city tooltips: text-only with white text-shadow halo, matching
|
||||
// Angular's _leaflet-popup.scss treatment.
|
||||
.leaflet-tooltip.city-label {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
font-family: 'Inter', 'Roboto', Arial, sans-serif;
|
||||
color: #1f1f1f;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
text-shadow:
|
||||
-1px 0 rgba(255, 255, 255, 0.53),
|
||||
1px 0 rgba(255, 255, 255, 0.53),
|
||||
0 1px rgba(255, 255, 255, 0.53),
|
||||
0 -1px rgba(255, 255, 255, 0.53);
|
||||
pointer-events: none;
|
||||
|
||||
&::before { display: none; }
|
||||
}
|
||||
|
||||
@@ -98,27 +98,48 @@
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
min-width: 140px;
|
||||
border: 1px solid #b8d4f1;
|
||||
border-radius: 3px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 2px rgba(177, 177, 177, 0.15);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
a, button {
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
.share-elements {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
> div + div {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.share-element {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
color: #1a3a5c;
|
||||
width: 60px;
|
||||
height: 55px;
|
||||
padding-top: 32px;
|
||||
background-position: center top;
|
||||
background-repeat: no-repeat;
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover { background: #f0f4f8; }
|
||||
&:hover { color: #2060c0; }
|
||||
|
||||
&.facebook { background-image: url('/assets/img/share/facebook.svg'); }
|
||||
&.vk { background-image: url('/assets/img/share/vk.svg'); }
|
||||
&.twitter { background-image: url('/assets/img/share/twitter.svg'); }
|
||||
&.copy { background-image: url('/assets/img/share/copy.svg'); }
|
||||
&.weibo {
|
||||
background-image: url('/assets/img/share/weibo.svg');
|
||||
background-size: 29px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,10 +77,14 @@ describe("LastUpdate", () => {
|
||||
expect(ts.textContent).toMatch(/\d{2}:\d{2}\s+\d{2}\.\d{2}\.\d{4}/);
|
||||
});
|
||||
|
||||
it("renders empty timestamp when leg.updated is empty", () => {
|
||||
it("renders client-side load timestamp regardless of leg.updated value", () => {
|
||||
// Angular sets `flight.lastUpdate = new Date()` in populate.logic.ts,
|
||||
// so the stamp tracks when the client received the data — not the
|
||||
// API record's mutation timestamp. Empty leg.updated must NOT blank
|
||||
// the field.
|
||||
render(<LastUpdate flight={makeFlight("")} locale="ru" />);
|
||||
const ts = screen.getByTestId("last-update-timestamp");
|
||||
expect(ts.textContent?.trim()).toBe("");
|
||||
expect(ts.textContent).toMatch(/\d{2}:\d{2}\s+\d{2}\.\d{2}\.\d{4}/);
|
||||
});
|
||||
|
||||
it("renders share button", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FC } from "react";
|
||||
import { parseISO, format, isValid } from "date-fns";
|
||||
import { type FC, useEffect, useRef, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import type { ISimpleFlight } from "../../types.js";
|
||||
import { ShareButton } from "./ShareButton.js";
|
||||
@@ -9,21 +9,31 @@ export interface LastUpdateProps {
|
||||
locale: string;
|
||||
}
|
||||
|
||||
function getUpdated(flight: ISimpleFlight): string | undefined {
|
||||
const leg = flight.routeType === "Direct" ? flight.leg : flight.legs[0];
|
||||
return leg?.updated;
|
||||
}
|
||||
|
||||
function formatUpdated(updated: string | undefined): string {
|
||||
if (!updated) return "";
|
||||
const d = parseISO(updated);
|
||||
if (!isValid(d)) return "";
|
||||
function formatStamp(d: Date): string {
|
||||
return format(d, "HH:mm dd.MM.yyyy");
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the "Последнее обновление: HH:mm DD.MM.YYYY" stamp + share
|
||||
* button. Angular sets `flight.lastUpdate = new Date()` inside
|
||||
* `populate.logic.ts` when a flight is hydrated, so the stamp reflects
|
||||
* when the client received the data — not the API record's mutation
|
||||
* timestamp. We mirror that here: capture `Date.now()` the first time we
|
||||
* see a given flight.id, then re-capture whenever the id changes.
|
||||
*/
|
||||
export const LastUpdate: FC<LastUpdateProps> = ({ flight, locale }) => {
|
||||
const { t } = useTranslation();
|
||||
const timestamp = formatUpdated(getUpdated(flight));
|
||||
const [loadedAt, setLoadedAt] = useState<Date>(() => new Date());
|
||||
const seenFlightIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (seenFlightIdRef.current !== flight.id) {
|
||||
seenFlightIdRef.current = flight.id;
|
||||
setLoadedAt(new Date());
|
||||
}
|
||||
}, [flight.id]);
|
||||
|
||||
const timestamp = formatStamp(loadedAt);
|
||||
const shareUrl = typeof window !== "undefined" ? window.location.href : "";
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FC } from "react";
|
||||
import type { FC, MouseEvent } from "react";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import "./actions.scss";
|
||||
|
||||
@@ -12,7 +12,8 @@ export const SharePanel: FC<SharePanelProps> = ({ url, locale, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const encoded = encodeURIComponent(url);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const handleCopy = async (e: MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
onClose();
|
||||
@@ -23,43 +24,64 @@ export const SharePanel: FC<SharePanelProps> = ({ url, locale, onClose }) => {
|
||||
|
||||
return (
|
||||
<div className="share-panel" data-testid="share-panel">
|
||||
<a
|
||||
data-testid="share-facebook"
|
||||
href={`https://www.facebook.com/sharer/sharer.php?u=${encoded}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Facebook
|
||||
</a>
|
||||
<a
|
||||
data-testid="share-vk"
|
||||
href={`https://vk.com/share.php?url=${encoded}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
VK
|
||||
</a>
|
||||
<a
|
||||
data-testid="share-twitter"
|
||||
href={`https://twitter.com/share?text=${encodeURIComponent("My Flight")}&url=${encoded}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Twitter
|
||||
</a>
|
||||
{locale === "zh" && (
|
||||
<a
|
||||
data-testid="share-weibo"
|
||||
href={`https://service.weibo.com/share/share.php?url=${encoded}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Weibo
|
||||
</a>
|
||||
)}
|
||||
<button type="button" data-testid="share-copy" onClick={handleCopy}>
|
||||
{t("SHARED.COPY") || "Copy"}
|
||||
</button>
|
||||
<div className="share-elements">
|
||||
<div>
|
||||
<a
|
||||
className="share-element facebook"
|
||||
data-testid="share-facebook"
|
||||
href={`https://www.facebook.com/sharer/sharer.php?u=${encoded}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("SHARE.FACEBOOK")}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
className="share-element vk"
|
||||
data-testid="share-vk"
|
||||
href={`https://vk.com/share.php?url=${encoded}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("SHARE.VK")}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
className="share-element twitter"
|
||||
data-testid="share-twitter"
|
||||
href={`https://twitter.com/share?text=${encodeURIComponent("My Flight")}&url=${encoded}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("SHARE.TWITTER")}
|
||||
</a>
|
||||
</div>
|
||||
{locale === "zh" && (
|
||||
<div>
|
||||
<a
|
||||
className="share-element weibo"
|
||||
data-testid="share-weibo"
|
||||
href={`https://service.weibo.com/share/share.php?url=${encoded}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("SHARE.WEIBO")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<a
|
||||
className="share-element copy"
|
||||
data-testid="share-copy"
|
||||
href="#"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{t("SHARE.COPY")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Calendar } from "primereact/calendar";
|
||||
import { Slider, type SliderChangeEvent } from "primereact/slider";
|
||||
import { useTranslation } from "@/i18n/provider.js";
|
||||
import { CityAutocomplete } from "@/ui/city-autocomplete/index.js";
|
||||
import { DayQuickPick } from "@/ui/calendar/DayQuickPick.js";
|
||||
import { useDictionaries } from "@/shared/dictionaries/index.js";
|
||||
import { buildOnlineBoardUrl } from "../url.js";
|
||||
import "./OnlineBoardFilter.scss";
|
||||
@@ -274,6 +275,11 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
||||
<label className="label--filter">
|
||||
{t("SHARED.FLIGHT_DATE")}
|
||||
</label>
|
||||
<DayQuickPick
|
||||
value={flightDate}
|
||||
locale={lang}
|
||||
onChange={setFlightDate}
|
||||
/>
|
||||
<Calendar
|
||||
value={flightDate}
|
||||
onChange={(e) => setFlightDate(e.value as Date | null)}
|
||||
@@ -363,6 +369,11 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
||||
<label className="label--filter">
|
||||
{t("SHARED.FLIGHT_DATE")}
|
||||
</label>
|
||||
<DayQuickPick
|
||||
value={routeDate}
|
||||
locale={lang}
|
||||
onChange={setRouteDate}
|
||||
/>
|
||||
<Calendar
|
||||
value={routeDate}
|
||||
onChange={(e) => setRouteDate(e.value as Date | null)}
|
||||
|
||||
@@ -57,6 +57,12 @@
|
||||
&__actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__footer-note {
|
||||
margin: vars.$space-m vars.$space-xl 0;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.board-day-selector {
|
||||
|
||||
@@ -222,7 +222,8 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
||||
if (dateLabel) searchHeading += `, ${dateLabel}`;
|
||||
break;
|
||||
case "flight":
|
||||
searchHeading = `${t("BOARD.FLIGHT_NUMBER")}: ${params.carrier}${params.flightNumber}`;
|
||||
searchHeading = `${t("SHARED.NUMBER")}: ${params.carrier} ${params.flightNumber}`;
|
||||
if (dateLabel) searchHeading += `, ${dateLabel}`;
|
||||
break;
|
||||
default:
|
||||
searchHeading = t("BOARD.TITLE");
|
||||
@@ -461,6 +462,17 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Footer note: Angular renders <page-footer-notes> after the
|
||||
flight-list block on every search results page. */}
|
||||
{!error && !loading && displayFlights.length > 0 && (
|
||||
<p
|
||||
className="online-board-search__footer-note"
|
||||
data-testid="footer-notes"
|
||||
>
|
||||
* {t("BOARD.LOCAL-TIME-NOTE")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Off-screen hit targets for e2e tests — shares markup contract
|
||||
with previous versions of the page. */}
|
||||
{!loading && displayFlights.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
@use "../../styles/colors" as colors;
|
||||
@use "../../styles/screen" as screen;
|
||||
@use "../../styles/variables" as vars;
|
||||
|
||||
.day-quick-pick {
|
||||
display: none;
|
||||
|
||||
@include screen.mobile {
|
||||
display: flex;
|
||||
gap: vars.$space-m;
|
||||
margin-bottom: vars.$space-m;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
background: colors.$white;
|
||||
border: 1px solid colors.$border;
|
||||
border-radius: 4px;
|
||||
color: #222;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
transition: border-color 150ms, background-color 150ms;
|
||||
|
||||
&:hover { border-color: colors.$blue-light; }
|
||||
|
||||
&--selected {
|
||||
background: colors.$blue;
|
||||
color: colors.$white;
|
||||
border-color: colors.$blue;
|
||||
}
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__day {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&__month {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { FC } from "react";
|
||||
import "./DayQuickPick.scss";
|
||||
|
||||
export interface DayQuickPickProps {
|
||||
/** Currently selected date (or null). */
|
||||
value: Date | null;
|
||||
/** Locale code (e.g. "ru", "en"). Drives day/month names via Intl. */
|
||||
locale: string;
|
||||
/** Number of buttons to render. Defaults to 3 (today + next 2 days). */
|
||||
count?: number;
|
||||
/** Called with the chosen Date on click. */
|
||||
onChange: (date: Date) => void;
|
||||
}
|
||||
|
||||
function addDays(base: Date, n: number): Date {
|
||||
const d = new Date(base);
|
||||
d.setDate(d.getDate() + n);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function isSameDay(a: Date | null, b: Date | null): boolean {
|
||||
if (!a || !b) return false;
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile-only 3-day quick-pick row that mirrors Angular's
|
||||
* `calendar-input.component` `.calendar--mobile` block: today + next 2
|
||||
* days as tap targets, each showing day-number / weekday-short / month.
|
||||
*
|
||||
* Hidden on tablet+ via DayQuickPick.scss media query.
|
||||
*/
|
||||
export const DayQuickPick: FC<DayQuickPickProps> = ({
|
||||
value,
|
||||
locale,
|
||||
count = 3,
|
||||
onChange,
|
||||
}) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const dayFormatter = new Intl.DateTimeFormat(locale, { weekday: "short" });
|
||||
// Use a date+month formatter and pluck out the month part — this yields
|
||||
// the genitive form in Russian ('апреля') instead of the standalone
|
||||
// nominative ('апрель') that `{ month: "long" }` alone returns.
|
||||
const monthFormatter = new Intl.DateTimeFormat(locale, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
const formatMonth = (d: Date): string => {
|
||||
const part = monthFormatter
|
||||
.formatToParts(d)
|
||||
.find((p) => p.type === "month");
|
||||
return part?.value ?? "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="day-quick-pick" data-testid="day-quick-pick">
|
||||
{Array.from({ length: count }, (_, i) => addDays(today, i)).map((d, i) => {
|
||||
const selected = isSameDay(value, d);
|
||||
const dayName = dayFormatter.format(d).replace(/\.$/, "");
|
||||
const monthName = formatMonth(d);
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={`day-quick-pick__btn${selected ? " day-quick-pick__btn--selected" : ""}`}
|
||||
onClick={() => onChange(d)}
|
||||
data-testid={`day-quick-pick-${i}`}
|
||||
>
|
||||
<span className="day-quick-pick__row">
|
||||
<span className="day-quick-pick__date">{d.getDate()}</span>
|
||||
<span className="day-quick-pick__day">
|
||||
{dayName.charAt(0).toUpperCase() + dayName.slice(1)}.
|
||||
</span>
|
||||
</span>
|
||||
<span className="day-quick-pick__month">
|
||||
{monthName.charAt(0).toUpperCase() + monthName.slice(1)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -145,7 +145,28 @@
|
||||
}
|
||||
|
||||
&__detail-status {
|
||||
color: #2457ff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #222;
|
||||
|
||||
// Angular shows a leading dot whose colour matches the boarding state.
|
||||
// Default is grey ('Уточняется' / 'Запланирован'); active states get
|
||||
// coloured dots.
|
||||
&--finished .flight-card__status-dot { background: #41b04c; }
|
||||
&--inprogress .flight-card__status-dot { background: #2060c0; }
|
||||
&--expected .flight-card__status-dot { background: #ff9000; }
|
||||
&--specified .flight-card__status-dot { background: #8a8a8a; }
|
||||
&--scheduled .flight-card__status-dot { background: #8a8a8a; }
|
||||
}
|
||||
|
||||
&__status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #8a8a8a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
@@ -196,18 +217,56 @@
|
||||
|
||||
@include screen.mobile {
|
||||
&__row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: vars.$space-m;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-template-areas:
|
||||
"operator status number"
|
||||
"time-dep status time-arr"
|
||||
"station-dep . station-arr";
|
||||
gap: vars.$space-s vars.$space-m;
|
||||
padding: vars.$space-m vars.$space-l;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
&__operator,
|
||||
&__status,
|
||||
&__chevron {
|
||||
display: none;
|
||||
&__chevron { display: none; }
|
||||
|
||||
&__number {
|
||||
grid-area: number;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__station--arrival {
|
||||
&__operator {
|
||||
grid-area: operator;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&__time {
|
||||
grid-area: time-dep;
|
||||
text-align: left;
|
||||
|
||||
&--arrival {
|
||||
grid-area: time-arr;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
&__station {
|
||||
grid-area: station-dep;
|
||||
text-align: left;
|
||||
|
||||
&--arrival {
|
||||
grid-area: station-arr;
|
||||
text-align: left;
|
||||
|
||||
.station {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__status {
|
||||
grid-area: status;
|
||||
align-self: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__detail-row {
|
||||
|
||||
@@ -262,7 +262,10 @@ export const FlightCard: FC<FlightCardProps> = ({
|
||||
<span className="flight-card__detail-caption">
|
||||
{t("DETAILS.STATUS")}
|
||||
</span>
|
||||
<span className="flight-card__detail-value flight-card__detail-status">
|
||||
<span
|
||||
className={`flight-card__detail-value flight-card__detail-status flight-card__detail-status--${boarding.status.toLowerCase()}`}
|
||||
>
|
||||
<span className="flight-card__status-dot" aria-hidden="true" />
|
||||
{t(BOARDING_STATUS_KEY[boarding.status] ?? boarding.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user