diff --git a/src/features/flights-map/components/FlightsMapFilter.tsx b/src/features/flights-map/components/FlightsMapFilter.tsx index f818a0b0..5a1611ff 100644 --- a/src/features/flights-map/components/FlightsMapFilter.tsx +++ b/src/features/flights-map/components/FlightsMapFilter.tsx @@ -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 = ({ return (
-

{t("FLIGHTS-MAP.ROUTE")}

- = ({ aria-label={t("SHARED.CITY_CHANGE")} data-testid="fm-exchange-btn" > - ⇆ + = ({
+ handleDateChange(e.value as Date | null)} diff --git a/src/features/flights-map/components/FlightsMapStartPage.scss b/src/features/flights-map/components/FlightsMapStartPage.scss index a43da352..d2194e18 100644 --- a/src/features/flights-map/components/FlightsMapStartPage.scss +++ b/src/features/flights-map/components/FlightsMapStartPage.scss @@ -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; } +} diff --git a/src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.scss b/src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.scss index 6d860bff..b73698b9 100644 --- a/src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.scss +++ b/src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.scss @@ -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; + } } } } diff --git a/src/features/online-board/components/BoardDetailsHeader/LastUpdate.test.tsx b/src/features/online-board/components/BoardDetailsHeader/LastUpdate.test.tsx index d6527618..586646e7 100644 --- a/src/features/online-board/components/BoardDetailsHeader/LastUpdate.test.tsx +++ b/src/features/online-board/components/BoardDetailsHeader/LastUpdate.test.tsx @@ -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(); 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", () => { diff --git a/src/features/online-board/components/BoardDetailsHeader/LastUpdate.tsx b/src/features/online-board/components/BoardDetailsHeader/LastUpdate.tsx index e08b58ec..ea5d1638 100644 --- a/src/features/online-board/components/BoardDetailsHeader/LastUpdate.tsx +++ b/src/features/online-board/components/BoardDetailsHeader/LastUpdate.tsx @@ -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 = ({ flight, locale }) => { const { t } = useTranslation(); - const timestamp = formatUpdated(getUpdated(flight)); + const [loadedAt, setLoadedAt] = useState(() => new Date()); + const seenFlightIdRef = useRef(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 ( diff --git a/src/features/online-board/components/BoardDetailsHeader/SharePanel.tsx b/src/features/online-board/components/BoardDetailsHeader/SharePanel.tsx index 2cdd1be6..5d55b028 100644 --- a/src/features/online-board/components/BoardDetailsHeader/SharePanel.tsx +++ b/src/features/online-board/components/BoardDetailsHeader/SharePanel.tsx @@ -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 = ({ url, locale, onClose }) => { const { t } = useTranslation(); const encoded = encodeURIComponent(url); - const handleCopy = async () => { + const handleCopy = async (e: MouseEvent) => { + e.preventDefault(); try { await navigator.clipboard.writeText(url); onClose(); @@ -23,43 +24,64 @@ export const SharePanel: FC = ({ url, locale, onClose }) => { return (
- - Facebook - - - VK - - - Twitter - - {locale === "zh" && ( - - Weibo - - )} - +
); }; diff --git a/src/features/online-board/components/OnlineBoardFilter.tsx b/src/features/online-board/components/OnlineBoardFilter.tsx index 98d9e3e8..a2a80fcd 100644 --- a/src/features/online-board/components/OnlineBoardFilter.tsx +++ b/src/features/online-board/components/OnlineBoardFilter.tsx @@ -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 = ({ + setFlightDate(e.value as Date | null)} @@ -363,6 +369,11 @@ export const OnlineBoardFilter: FC = ({ + setRouteDate(e.value as Date | null)} diff --git a/src/features/online-board/components/OnlineBoardSearchPage.scss b/src/features/online-board/components/OnlineBoardSearchPage.scss index a17a028c..3bc16c64 100644 --- a/src/features/online-board/components/OnlineBoardSearchPage.scss +++ b/src/features/online-board/components/OnlineBoardSearchPage.scss @@ -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 { diff --git a/src/features/online-board/components/OnlineBoardSearchPage.tsx b/src/features/online-board/components/OnlineBoardSearchPage.tsx index bd958d7e..1aa4501b 100644 --- a/src/features/online-board/components/OnlineBoardSearchPage.tsx +++ b/src/features/online-board/components/OnlineBoardSearchPage.tsx @@ -222,7 +222,8 @@ export const OnlineBoardSearchPage: FC = ({ 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 = ({ )} + {/* Footer note: Angular renders after the + flight-list block on every search results page. */} + {!error && !loading && displayFlights.length > 0 && ( +

+ * {t("BOARD.LOCAL-TIME-NOTE")} +

+ )} + {/* Off-screen hit targets for e2e tests — shares markup contract with previous versions of the page. */} {!loading && displayFlights.length > 0 && ( diff --git a/src/ui/calendar/DayQuickPick.scss b/src/ui/calendar/DayQuickPick.scss new file mode 100644 index 00000000..0da37b29 --- /dev/null +++ b/src/ui/calendar/DayQuickPick.scss @@ -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; + } +} diff --git a/src/ui/calendar/DayQuickPick.tsx b/src/ui/calendar/DayQuickPick.tsx new file mode 100644 index 00000000..7adab4b4 --- /dev/null +++ b/src/ui/calendar/DayQuickPick.tsx @@ -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 = ({ + 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 ( +
+ {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 ( + + ); + })} +
+ ); +}; diff --git a/src/ui/flights/FlightCard.scss b/src/ui/flights/FlightCard.scss index 9cb0ce7f..33508136 100644 --- a/src/ui/flights/FlightCard.scss +++ b/src/ui/flights/FlightCard.scss @@ -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 { diff --git a/src/ui/flights/FlightCard.tsx b/src/ui/flights/FlightCard.tsx index 9237a7b3..d2b4382f 100644 --- a/src/ui/flights/FlightCard.tsx +++ b/src/ui/flights/FlightCard.tsx @@ -262,7 +262,10 @@ export const FlightCard: FC = ({ {t("DETAILS.STATUS")} - + +