diff --git a/src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.scss b/src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.scss new file mode 100644 index 00000000..60698e3c --- /dev/null +++ b/src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.scss @@ -0,0 +1,101 @@ +.board-details-header { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 16px; + padding: 24px; + background: #fff; + border-radius: 8px; + + &__badge { grid-column: 1; } + &__actions-row { grid-column: 2; display: flex; justify-content: flex-end; } + &__events-row { + grid-column: 1 / -1; + display: flex; + justify-content: space-between; + align-items: center; + } + + @media (max-width: 768px) { + grid-template-columns: 1fr; + padding: 16px; + } +} + +.details-header-badge { + display: flex; + align-items: center; + gap: 12px; + + &__flight-number { display: flex; flex-direction: column; } + &__primary { font-size: 24px; font-weight: 600; color: #1a3a5c; } + &__codesharing { font-size: 12px; color: #666; margin-top: 2px; } +} + +.flight-events { + display: flex; + gap: 12px; + align-items: center; + + &__event { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: 1px solid #ddd; + border-radius: 14px; + height: 28px; + font-size: 12px; + } +} + +.last-update { + display: flex; + align-items: center; + gap: 8px; + + &__description { font-size: 12px; color: #666; } + + .share-button-wrap { + @media (min-width: 769px) { display: none; } + } +} + +.share-button-wrap { + position: relative; + + .share-panel { + position: absolute; + 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; + z-index: 10; + + a, button { + padding: 6px 10px; + font-size: 14px; + color: #1a3a5c; + text-decoration: none; + background: none; + border: none; + text-align: left; + cursor: pointer; + + &:hover { background: #f0f4f8; } + } + } +} + +.flight-actions { + display: flex; + gap: 8px; + align-items: center; +} diff --git a/src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.test.tsx b/src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.test.tsx new file mode 100644 index 00000000..909c7724 --- /dev/null +++ b/src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { BoardDetailsHeader } from "./BoardDetailsHeader.js"; +import type { ISimpleFlight, IFlightLeg, IFlightLegFlags } from "../../types.js"; + +vi.mock("@/i18n/provider.js", () => ({ useTranslation: () => ({ t: (k: string) => k }) })); +vi.mock("@/shared/hooks/useAppSettings.js", () => ({ + useAppSettings: () => ({ + onlineboardSearchFrom: 2, + onlineboardSearchTo: 14, + scheduleSearchFrom: 30, + scheduleSearchTo: 30, + flightStatusAvailableFromHours: 24, + buyTicketMinHours: 2, + buyTicketMaxHours: 72, + loading: false, + error: null, + }), +})); + +function makeLeg(flags: Partial = {}): IFlightLeg { + return { + arrival: { + scheduled: { airport: "", airportCode: "LED", city: "", cityCode: "", countryCode: "" }, + latest: { airport: "", airportCode: "LED", city: "", cityCode: "", countryCode: "" }, + dispatch: "", + gate: "", + terminal: "", + times: { + scheduledArrival: { + dayChange: { value: 0, title: "" }, + local: "", + localTime: "", + tzOffset: 0, + utc: "", + }, + }, + }, + dayChange: 0, + departure: { + scheduled: { airport: "", airportCode: "SVO", city: "", cityCode: "", countryCode: "" }, + latest: { airport: "", airportCode: "SVO", city: "", cityCode: "", countryCode: "" }, + dispatch: "", + gate: "", + terminal: "", + checkingStatus: "Scheduled", + parkingStand: "", + times: { + scheduledDeparture: { + dayChange: { value: 0, title: "" }, + local: "", + localTime: "", + tzOffset: 0, + utc: "2026-04-20T10:00:00Z", + }, + }, + }, + equipment: {}, + flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false, ...flags }, + flyingTime: "1h", + index: 0, + operatingBy: {}, + status: "Scheduled", + updated: "2026-04-20T09:00:00Z", + } as IFlightLeg; +} + +function makeFlight(flags: Partial = {}): ISimpleFlight { + return { + id: "X", + routeType: "Direct", + flyingTime: "1h", + status: "Scheduled", + flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260417" }, + operatingBy: { carrier: "SU", flightNumber: "0022" }, + leg: makeLeg(flags), + } as ISimpleFlight; +} + +describe("BoardDetailsHeader", () => { + it("has data-testid=board-details-header", () => { + render(); + expect(screen.getByTestId("board-details-header")).toBeTruthy(); + }); + + it("renders operator-logo, flight-actions, last-update-timestamp", () => { + render(); + expect(screen.getByTestId("operator-logo")).toBeTruthy(); + expect(screen.getByTestId("flight-actions")).toBeTruthy(); + expect(screen.getByTestId("last-update-timestamp")).toBeTruthy(); + }); + + it("renders change-route event when leg.flags.routeChanged=true", () => { + render(); + expect(screen.getByTestId("flight-event-change-route")).toBeTruthy(); + }); + + it("renders reroute event when leg.flags.returnToAirport=true", () => { + render(); + expect(screen.getByTestId("flight-event-reroute")).toBeTruthy(); + }); + + it("does not render events when both flags are false", () => { + render(); + expect(screen.queryByTestId("flight-event-change-route")).toBeNull(); + expect(screen.queryByTestId("flight-event-reroute")).toBeNull(); + }); +}); diff --git a/src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.tsx b/src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.tsx new file mode 100644 index 00000000..3f619a20 --- /dev/null +++ b/src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.tsx @@ -0,0 +1,41 @@ +import type { FC } from "react"; +import type { ISimpleFlight, IFlightLeg } from "../../types.js"; +import { DetailsHeaderBadge } from "./DetailsHeaderBadge.js"; +import { FlightActions } from "./FlightActions.js"; +import { FlightEvents } from "./FlightEvents.js"; +import { LastUpdate } from "./LastUpdate.js"; +import "./BoardDetailsHeader.scss"; + +export interface BoardDetailsHeaderProps { + flight: ISimpleFlight; + locale: string; +} + +function getLegs(flight: ISimpleFlight): IFlightLeg[] { + if (flight.routeType === "Direct") return [flight.leg]; + return flight.legs; +} + +function anyLegFlag(flight: ISimpleFlight, key: "routeChanged" | "returnToAirport"): boolean { + return getLegs(flight).some((l) => l.flags[key]); +} + +export const BoardDetailsHeader: FC = ({ flight, locale }) => { + const changeRoute = anyLegFlag(flight, "routeChanged"); + const reroute = anyLegFlag(flight, "returnToAirport"); + + return ( +
+
+ +
+
+ +
+
+ + +
+
+ ); +}; diff --git a/src/features/online-board/components/BoardDetailsHeader/index.ts b/src/features/online-board/components/BoardDetailsHeader/index.ts new file mode 100644 index 00000000..c8b283aa --- /dev/null +++ b/src/features/online-board/components/BoardDetailsHeader/index.ts @@ -0,0 +1,2 @@ +export { BoardDetailsHeader } from "./BoardDetailsHeader.js"; +export type { BoardDetailsHeaderProps } from "./BoardDetailsHeader.js";