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:
2026-04-19 16:14:47 +03:00
parent 314889de2a
commit b63fd8fb6b
13 changed files with 436 additions and 89 deletions
@@ -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"
>
&#8646;
<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 && (
+61
View File
@@ -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;
}
}
+90
View File
@@ -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>
);
};
+67 -8
View File
@@ -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 {
+4 -1
View File
@@ -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>