Add board details header + action buttons (B.4) design spec

This commit is contained in:
2026-04-17 01:08:20 +03:00
parent 50e3f1b961
commit 4927dc3717
@@ -0,0 +1,357 @@
# Board Details Header + Action Buttons (B.4)
## Goal
Add the flight status header to the React Online Board details page: airline logo, flight number, codesharing, action buttons (Buy Ticket, Register, Flight Status, Share, Print), flight event indicators (route change, reroute), and a last-update timestamp. Match Angular parity including time-based button visibility logic.
## Scope
Sub-feature **B.4** of the Flight Details parity work. Online Board details page only.
## Current State
The React details page has a minimal header:
```tsx
<div className="flight-details__header">
<h1>{flightNumber}</h1> // Now in PageLayout title from B.2
<span>{displayFlight.status}</span>
</div>
```
Angular has a rich `board-details-header` component with 10+ sub-components, visibility logic, and airline-specific behavior.
## Approach
Full Angular parity port. New components live under `src/features/online-board/components/BoardDetailsHeader/`. Pure visibility logic lives in `visibility/*.ts` as side-effect-free functions, tested in isolation. Airline configuration (URLs, capabilities) goes in `airlines.ts` as a constant map. Time math uses `date-fns` (new dependency).
## New Dependency
- `date-fns` (~25kb tree-shakable) — `parseISO`, `subHours`, `isAfter`, `isBefore`, `isSameDay`
## Architecture
### File structure
```
src/features/online-board/components/BoardDetailsHeader/
├── BoardDetailsHeader.tsx # Orchestrator
├── BoardDetailsHeader.scss
├── BoardDetailsHeader.test.tsx
├── DetailsHeaderBadge.tsx # Flight number + codesharing + operator logo + small status button
├── DetailsHeaderBadge.test.tsx
├── OperatorLogo.tsx # CSS-class-driven airline logo
├── OperatorLogo.scss # Ported from Angular's _logos.scss
├── OperatorLogo.test.tsx
├── FlightActions.tsx # Container for 5 action buttons
├── FlightActions.test.tsx
├── BuyTicketButton.tsx
├── BuyTicketButton.test.tsx
├── RegistrationButton.tsx
├── RegistrationButton.test.tsx
├── FlightStatusButton.tsx
├── FlightStatusButton.test.tsx
├── ShareButton.tsx # Button + panel together
├── SharePanel.tsx
├── ShareButton.test.tsx
├── SharePanel.test.tsx
├── PrintButton.tsx # Stub (empty URL, hidden)
├── FlightEvents.tsx # Route change + reroute icons
├── FlightEvents.test.tsx
├── LastUpdate.tsx # Timestamp + mobile share
├── LastUpdate.test.tsx
├── airlines.ts # Airline URL/capability config
├── actions.scss # Shared button styles
├── icons/
│ ├── change.svg
│ ├── return.svg
│ ├── share.svg
│ └── print.svg
├── airlines-logo/ # 35 airline logo folders copied from Angular
│ └── aeroflot/, rossiya/, aurora/, ...
├── visibility/
│ ├── buyTicketVisibility.ts
│ ├── buyTicketVisibility.test.ts
│ ├── registrationVisibility.ts
│ ├── registrationVisibility.test.ts
│ ├── flightStatusVisibility.ts
│ └── flightStatusVisibility.test.ts
└── index.ts
```
### `useAppSettings` hook extension
```typescript
export interface UseAppSettingsResult {
// Existing
onlineboardSearchFrom: number;
onlineboardSearchTo: number;
scheduleSearchFrom: number;
scheduleSearchTo: number;
// New (B.4)
flightStatusAvailableFromHours: number; // default 24
buyTicketMinHours: number; // default 2
buyTicketMaxHours: number; // default 72
loading: boolean;
error: Error | null;
}
```
Parses `uiOptions.buttons.flightStatus.availableFrom` (e.g., `"24h"`) and `uiOptions.buttons.buyTicket.period.min/max`. Pattern: `/^(\d+)h$/`. Falls back to defaults if parsing fails.
### Visibility logic (pure functions in `visibility/`)
**`canBuyTicket(flight, now, minHours, maxHours)`**
- Returns false if status is "Cancelled" or "InFlight"
- Extracts first leg's scheduled departure UTC
- Returns true only when `now` is between `departure - maxHours` and `departure - minHours`
**`canRegister(flight, airlineConfig)`**
- Gets `flight.operatingBy.carrier`
- Returns false if carrier has no `registrationUrl`
- Returns true only when `firstLeg.transition.registration.status === "InProgress"`
**`canViewFlightStatus(flight, now, availableFromHours, airlinesWithStatus)`**
- Returns false if carrier not in set `{"SU", "HZ", "FV", "DP"}`
- Returns false if `now` is not same-day as departure
- Returns true when `now > departure - availableFromHours`
### Airline config (`airlines.ts`)
```typescript
export interface AirlineConfig {
name: string;
registrationUrl?: string;
hasNativeStatus: boolean;
statusUrl?: string; // only when hasNativeStatus is false
}
export const AIRLINES: Record<string, AirlineConfig> = {
SU: { name: "Aeroflot", registrationUrl: "https://www.aeroflot.ru/sb/ckin/app/ru-ru", hasNativeStatus: true },
FV: { name: "Rossiya", registrationUrl: "https://www.rossiya-airlines.com/flight-with-us/before_flight/the_ways_of_check-in/", hasNativeStatus: true },
HZ: { name: "Aurora", registrationUrl: "https://www.flyaurora.ru", hasNativeStatus: false, statusUrl: "https://www.flyaurora.ru" },
DP: { name: "Pobeda", registrationUrl: "https://www.pobeda.aero", hasNativeStatus: false, statusUrl: "https://www.pobeda.aero" },
AF: { name: "AirFrance", hasNativeStatus: false },
};
export const AIRLINES_WITH_STATUS = new Set(["SU", "HZ", "FV", "DP"]);
```
### Component contracts
**`BoardDetailsHeader`**
```typescript
interface Props {
flight: ISimpleFlight;
locale: string;
}
```
Orchestrates `DetailsHeaderBadge`, `FlightActions`, `FlightEvents`, `LastUpdate` in a 2-column grid.
**`DetailsHeaderBadge`**
```typescript
interface Props {
flight: ISimpleFlight;
large?: boolean;
round?: boolean;
showStatus?: boolean; // whether to render the small Flight Status button
}
```
Renders flight number, codesharing partners below (from `legs.map(l => l.operatingBy)`), `OperatorLogo`, and optionally a small `FlightStatusButton`.
**`OperatorLogo`**
```typescript
interface Props {
flight: ISimpleFlight;
locale: string;
round?: boolean;
large?: boolean;
caption?: boolean;
}
```
Renders an empty `<div>` with classes `company-logo company-logo--{CARRIER} {large? "large" :""} {round? "round" :""} {locale === "ru" ? "ru" : ""}`. The SCSS applies background images.
**`FlightActions`**
```typescript
interface Props {
flight: ISimpleFlight;
locale: string;
viewType: "Onlineboard" | "Schedule";
showStatus?: boolean; // default true
showDetails?: boolean; // default false
showPrint?: boolean; // default false (hidden on details page)
showShare?: boolean; // default true
showRegister?: boolean; // default true
showBuy?: boolean; // default true
}
```
Renders each action button conditional on visibility logic + show prop.
**`FlightEvents`**
```typescript
interface Props {
changeRoute: boolean;
reroute: boolean;
direction?: "row" | "column-mobile";
showDescription?: boolean;
}
```
**`LastUpdate`**
```typescript
interface Props {
flight: ISimpleFlight;
locale: string;
}
```
Renders timestamp (`HH:mm DD.MM.YYYY`) via `date-fns` `format`. Also renders a mobile-only `ShareButton` next to it.
### Button behaviors (click handlers)
**BuyTicket:** opens `https://www.aeroflot.ru/sb/app/{locale}-{locale}#/search?adults=1&cabin=economy&children=0&infants=0&routes={dep}.{yyyymmdd}.{arr}&autosearch=Y` in new tab.
**Registration:** opens `AIRLINES[carrier].registrationUrl` in new tab.
**FlightStatus:** if `hasNativeStatus`, opens the flight's own details URL in new tab; otherwise opens `statusUrl` in new tab.
**Share:** toggles `SharePanel`. Panel renders:
- Facebook: `https://www.facebook.com/sharer/sharer.php?u={url}`
- VK: `http://vk.com/share.php?url={url}`
- Twitter: `http://twitter.com/share?text={text}&url={url}`
- Weibo (zh only): `http://service.weibo.com/share/share.php?url={url}`
- Copy button: `navigator.clipboard.writeText(url)`
`url` is built from the current page's absolute URL.
**Print:** empty URL (matches Angular). The component is always rendered hidden on the details page via `showPrint={false}`.
### Integration into `OnlineBoardDetailsPage`
Replace:
```tsx
<div className="flight-details__header">
<span className="flight-details__overall-status">{displayFlight.status}</span>
</div>
```
with:
```tsx
<BoardDetailsHeader flight={displayFlight} locale={locale} />
```
Move the overall-status indication into the header's badge. Remove the stale `flight-details__header` div from the main content — the header is the entire top section now.
## Styling
### Copied from Angular `_logos.scss`
All 35 `.company-logo--{CODE}` rules with CSS `background-image` URLs. Paths rewritten from `~src/assets/...` to relative (`./airlines-logo/...`). Rspack handles the `.svg`/`.png` imports as file assets.
### Action buttons
Shared `actions.scss`:
```scss
.flight-action-btn {
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
border: none;
font-family: inherit;
&--orange { background: #ff9000; color: #fff; }
&--blue-light { background: #e3f0ff; color: #1a3a5c; }
&--transparent { background: transparent; padding: 8px; }
}
```
### Header layout
```scss
.board-details-header {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 16px;
padding: 24px;
background: #fff;
border-radius: 8px;
&__badge { grid-column: 1; }
&__actions { grid-column: 2; display: flex; gap: 8px; justify-content: flex-end; }
&__events { grid-column: 3; }
&__last-update { grid-column: 1 / -1; }
@media (max-width: 768px) {
grid-template-columns: 1fr;
padding: 16px;
.share-button--desktop { display: none; }
}
}
```
## Testing
### Visibility logic (unit, pure functions)
- `buyTicketVisibility.test.ts` — window boundary tests (just before showFrom, middle, just after showUntil), status exclusions
- `registrationVisibility.test.ts` — airline support matrix, status "InProgress" check
- `flightStatusVisibility.test.ts` — same-day boundary, airline filtering, time window
### Components (unit)
- `OperatorLogo.test.tsx` — applies correct CSS class per carrier code
- `DetailsHeaderBadge.test.tsx` — flight number, codesharing list, small status button visibility
- `BuyTicketButton.test.tsx` / `RegistrationButton.test.tsx` / `FlightStatusButton.test.tsx` — render, click opens expected URL (mock `window.open`)
- `ShareButton.test.tsx` — toggles panel visibility
- `SharePanel.test.tsx` — renders 4 social links, Weibo conditional, copy calls `navigator.clipboard`
- `FlightEvents.test.tsx` — conditional rendering of change/reroute icons
- `LastUpdate.test.tsx` — timestamp format, mobile share visibility
- `FlightActions.test.tsx` — renders only enabled visible buttons
- `BoardDetailsHeader.test.tsx` — integration smoke test
### Hook extension
- `useAppSettings.test.ts` — new tests for `buttons` parsing
### Integration
- `OnlineBoardDetailsPage.test.tsx``<BoardDetailsHeader>` replaces the inline header
## i18n
No new keys needed. Reuses:
- `SHARED.BUY-TICKET`, `SHARED.ONLINE-REGISTRATION`
- `SHARED.DETAILS`, `SHARED.DETAILS-TOOLTIP`, `SHARED.FLIGHT-INFO`
- `SHARED.LAST-UPDATE`, `SHARED.ROUTE-CHANGE`, `SHARED.RETURN`
- `SHARED.AVIACOMPANY`
- `BOARD.SHARE`
(All confirmed present in `src/i18n/locales/en/common.json`.)
## Assets
### Airline logos
Copy all 35 directories from `ClientApp/src/assets/img/airlines-logo/` to `src/features/online-board/components/BoardDetailsHeader/airlines-logo/`.
### Event/action icons
Copy `change.svg`, `return.svg`, `share.svg`, `print.svg` from Angular's icon directory to `./icons/`.
## Files Touched
### New files
- All under `src/features/online-board/components/BoardDetailsHeader/` (as listed in file structure)
### Modified files
- `src/shared/hooks/useAppSettings.ts` — extend with buttons config
- `src/shared/hooks/useAppSettings.test.ts` — add tests for buttons parsing
- `src/features/online-board/components/OnlineBoardDetailsPage.tsx` — replace inline header
- `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` — update mock state
- `package.json` — add `date-fns` dependency
## Out of Scope
- Schedule feature equivalent (`schedule-details-header`)
- Print page implementation (URL stays empty per Angular)
- Registration state re-fetch on button click
- Feature flags for China-specific share filtering beyond locale check
- Analytics wiring on button clicks