Summary header: round-logo badges + remove share/buy from leg body

Connecting itineraries now render details-header-badge with the small
round airline icon (36×36) from Angular's `[round]="isConnecting"`
path and drop the 'Авиакомпания' caption, so the SU 6188 + SU 6341
row sits compactly next to the share/buy/last-update cluster instead
of stretching two wide wordmarks across the summary.

Share + Buy buttons removed from ScheduleFlightBody — Angular's
`flight-schedule-details` wires `[share]=false [buy]=false
[print]=false [details]=false [register]=false` into its inner
flight-actions, so a per-leg action strip was never meant to exist.
The page-level summary header now owns those affordances.

OperatorLogo.scss: override the 180×46 rule inside .details-header-badge
when the logo carries .operator-logo--round so the connecting-summary
badge doesn't force a wide wordmark.

BoardDetailsHeader.scss is imported from DetailsHeaderBadge.tsx so
consumers (schedule details summary) that use the badge without the
full BoardDetailsHeader wrapper still pick up flex/gap/typography.
This commit is contained in:
2026-04-23 17:07:25 +03:00
parent cbced8d4b6
commit fa4656dab1
5 changed files with 50 additions and 218 deletions
@@ -3,11 +3,21 @@ import { useTranslation } from "@/i18n/provider.js";
import { OperatorLogo } from "@/ui/flights/OperatorLogo.js";
import { operatingCarrier, type ISimpleFlight, type IFlightLeg } from "../../types.js";
import { FlightStatusButton } from "./FlightStatusButton.js";
// Badge-composition styles (flex/gap/typography) live in the shared
// BoardDetailsHeader.scss. Import here so consumers that use
// DetailsHeaderBadge without the full BoardDetailsHeader wrapper
// (e.g. the schedule-details summary header) still get the layout.
import "./BoardDetailsHeader.scss";
export interface DetailsHeaderBadgeProps {
flight: ISimpleFlight;
locale: string;
large?: boolean;
/** Angular renders the per-flight badge with a small round airline
* icon (and no "Авиакомпания" caption) when the enclosing details
* page is for a connecting itinerary — the large wide logo only
* suits a single-flight view. Callers pass `round=true` for the
* connecting-summary layout. */
round?: boolean;
showStatus?: boolean;
}
@@ -24,6 +34,7 @@ function getCodeshareLegs(flight: ISimpleFlight): IFlightLeg[] {
export const DetailsHeaderBadge: FC<DetailsHeaderBadgeProps> = ({
flight,
locale,
round = false,
showStatus = false,
}) => {
const { t } = useTranslation();
@@ -32,7 +43,9 @@ export const DetailsHeaderBadge: FC<DetailsHeaderBadgeProps> = ({
const carrier = operatingCarrier(flight.operatingBy) ?? flight.flightId.carrier;
return (
<div className="details-header-badge">
<div
className={`details-header-badge${round ? " details-header-badge--round" : ""}`}
>
<div className="details-header-badge__flight-number">
<div className="details-header-badge__primary">{primaryNumber}</div>
{codeshareLegs.length > 0 && (
@@ -50,10 +63,12 @@ export const DetailsHeaderBadge: FC<DetailsHeaderBadgeProps> = ({
)}
</div>
<div className="details-header-badge__airline">
<div className="details-header-badge__airline-caption">
{t("SHARED.AVIACOMPANY")}
</div>
<OperatorLogo carrier={carrier} locale={locale} title={carrier} />
{!round && (
<div className="details-header-badge__airline-caption">
{t("SHARED.AVIACOMPANY")}
</div>
)}
<OperatorLogo carrier={carrier} locale={locale} title={carrier} round={round} />
</div>
{showStatus && <FlightStatusButton flight={flight} locale={locale} small />}
</div>
@@ -498,7 +498,12 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
<div className="schedule-details__summary-row">
<div className="schedule-details__summary-badges">
{(flights as unknown as ISimpleFlight[]).map((f) => (
<DetailsHeaderBadge key={f.id} flight={f} locale={locale} />
<DetailsHeaderBadge
key={f.id}
flight={f}
locale={locale}
round={miniListCurrentFlight.routeType !== "Direct"}
/>
))}
</div>
<div className="schedule-details__summary-actions">
@@ -213,10 +213,9 @@ describe("ScheduleFlightBody TZ §4.1.14.4", () => {
expect(stations.some((s) => s.textContent === "LED")).toBe(true);
});
it("renders share button always (TZ §4.1.14.4.3)", () => {
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_10D)} />);
expect(screen.getByTestId("schedule-share-button")).toBeTruthy();
});
// Share/Buy/Status no longer render inside the per-leg body — they
// live in the page-level summary header (mirroring Angular's
// `flight-schedule-details [share]=false [buy]=false [print]=false`).
});
describe("Multi-leg flight structure", () => {
@@ -395,87 +394,10 @@ describe("ScheduleFlightBody TZ §4.1.14.4", () => {
});
});
// ────────────────────────────────────────────────────────────────────────────
// TZ §4.1.14.4.4 — Buy button visibility gate
// ────────────────────────────────────────────────────────────────────────────
describe("Buy button TZ §4.1.14.4.4", () => {
const BUY = "https://buy.example/route";
it("shows buy link when departure is 10 days ahead", () => {
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_10D)} buyUrl={BUY} />);
expect(screen.getByTestId("schedule-buy-button")).toBeTruthy();
});
it("hides buy link when departure is > 330 days ahead", () => {
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_340D)} buyUrl={BUY} />);
expect(screen.queryByTestId("schedule-buy-button")).toBeNull();
});
it("hides buy link when departure is less than 2h away", () => {
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_1H)} buyUrl={BUY} />);
expect(screen.queryByTestId("schedule-buy-button")).toBeNull();
});
it("hides buy link when departure is in the past", () => {
render(<ScheduleFlightBody flight={makeDirectFlight(YESTERDAY)} buyUrl={BUY} />);
expect(screen.queryByTestId("schedule-buy-button")).toBeNull();
});
it("hides buy link when buyUrl prop not provided", () => {
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_10D)} />);
expect(screen.queryByTestId("schedule-buy-button")).toBeNull();
});
it("renders buy link as anchor targeting the provided URL", () => {
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_10D)} buyUrl={BUY} />);
const anchor = screen.getByTestId("schedule-buy-button") as HTMLAnchorElement;
expect(anchor.tagName).toBe("A");
expect(anchor.href).toBe(BUY);
expect(anchor.target).toBe("_blank");
});
it("uses first-leg departure UTC for multi-leg buy gate (TZ §4.1.14.4.4)", () => {
render(<ScheduleFlightBody flight={makeMultiLegFlight(FUTURE_10D)} buyUrl={BUY} />);
expect(screen.getByTestId("schedule-buy-button")).toBeTruthy();
});
});
// ────────────────────────────────────────────────────────────────────────────
// TZ §4.1.14.4.5 — Status button (today-only)
// ────────────────────────────────────────────────────────────────────────────
describe("Status button TZ §4.1.14.4.5", () => {
it("shows status button when flight departs today", () => {
const onStatus = vi.fn();
render(<ScheduleFlightBody flight={makeDirectFlight(todayUtc())} onStatus={onStatus} />);
expect(screen.getByTestId("schedule-status-button")).toBeTruthy();
});
it("hides status button when flight departs 10 days from now", () => {
const onStatus = vi.fn();
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_10D)} onStatus={onStatus} />);
expect(screen.queryByTestId("schedule-status-button")).toBeNull();
});
it("hides status button when flight departed yesterday", () => {
const onStatus = vi.fn();
render(<ScheduleFlightBody flight={makeDirectFlight(YESTERDAY)} onStatus={onStatus} />);
expect(screen.queryByTestId("schedule-status-button")).toBeNull();
});
it("hides status button when onStatus prop not provided", () => {
render(<ScheduleFlightBody flight={makeDirectFlight(todayUtc())} />);
expect(screen.queryByTestId("schedule-status-button")).toBeNull();
});
it("calls onStatus handler when status button clicked", () => {
const onStatus = vi.fn();
render(<ScheduleFlightBody flight={makeDirectFlight(todayUtc())} onStatus={onStatus} />);
fireEvent.click(screen.getByTestId("schedule-status-button"));
expect(onStatus).toHaveBeenCalledOnce();
});
});
// Share/Buy/Status button tests were removed: those affordances
// relocated from the per-leg body to the page-level summary header.
// Their visibility is exercised by BoardDetailsHeader/FlightActions
// tests plus the schedule-details summary e2e spec.
// ────────────────────────────────────────────────────────────────────────────
// TZ §4.1.16.7 — Intermediate landing vs transfer duration (UTC fix)
@@ -33,14 +33,6 @@ import "./ScheduleFlightBody.scss";
export interface ScheduleFlightBodyProps {
flight: ISimpleFlight;
/**
* Aeroflot booking URL for the `Купить билет` affordance. When provided
* the body renders a hover-only link (per TIRREDESIGN-6 / TZ §4.1.14.4.4)
* opening in a new tab; omit to hide the link.
*/
buyUrl?: string;
/** Optional click handler for the `Статус рейса` button. */
onStatus?: () => void;
}
interface ChildFlightId {
@@ -95,44 +87,8 @@ function transferDuration(prev: IFlightLeg, next: IFlightLeg): string {
// • Departure UTC is < 330 days from now
// Eligibility is assessed on the FIRST leg of the flight (TZ §4.1.14.4.4:
// "рассчитывается исходя из времени первого сегмента").
const BUY_MAX_DAYS = 330;
const BUY_MIN_HOURS = 2;
function isBuyVisible(firstLegDepUtc: string | undefined): boolean {
if (!firstLegDepUtc) return false;
const depMs = new Date(firstLegDepUtc).getTime();
if (Number.isNaN(depMs)) return false;
const nowMs = Date.now();
const diffMs = depMs - nowMs;
if (diffMs <= 0) return false;
if (diffMs < BUY_MIN_HOURS * 3600 * 1000) return false;
if (diffMs > BUY_MAX_DAYS * 24 * 3600 * 1000) return false;
return true;
}
// ── TZ §4.1.14.4.5 Status-button visibility gate ──────────────────────────
// Visible when the departure calendar day matches today (user's local day).
// Eligibility is assessed on the FIRST leg (TZ: "рассчитывается исходя из
// времени первого сегмента"). We compare using the UTC departure timestamp
// against today's UTC date, which is a safe approximation (avoids needing
// the station's tzOffset for boundary accuracy, consistent with Angular impl).
function isStatusVisible(firstLegDepUtc: string | undefined): boolean {
if (!firstLegDepUtc) return false;
const depMs = new Date(firstLegDepUtc).getTime();
if (Number.isNaN(depMs)) return false;
const dep = new Date(depMs);
const now = new Date();
return (
dep.getUTCFullYear() === now.getUTCFullYear() &&
dep.getUTCMonth() === now.getUTCMonth() &&
dep.getUTCDate() === now.getUTCDate()
);
}
export const ScheduleFlightBody: FC<ScheduleFlightBodyProps> = ({
flight,
buyUrl,
onStatus,
}) => {
const { t } = useTranslation();
const { language } = useLocale();
@@ -141,14 +97,6 @@ export const ScheduleFlightBody: FC<ScheduleFlightBodyProps> = ({
flight.routeType === "Direct" ? [flight.leg] : flight.legs;
if (legs.length === 0) return null;
const firstLeg = legs[0];
const buyVisible =
Boolean(buyUrl) &&
isBuyVisible(firstLeg?.departure.times.scheduledDeparture.utc);
const statusVisible =
Boolean(onStatus) &&
isStatusVisible(firstLeg?.departure.times.scheduledDeparture.utc);
const childFlightIds = (flight as ISimpleFlight & {
_childFlightIds?: ChildFlightId[];
})._childFlightIds;
@@ -419,81 +367,12 @@ export const ScheduleFlightBody: FC<ScheduleFlightBodyProps> = ({
);
})}
<div className="schedule-flight-body__actions">
<button
type="button"
className="schedule-flight-body__share-btn"
data-testid="schedule-share-button"
aria-label={t("BOARD.SHARE")}
title={t("BOARD.SHARE")}
onClick={(e) => {
e.stopPropagation();
const url =
typeof window !== "undefined" ? window.location.href : "";
const navShare =
typeof navigator !== "undefined" &&
(navigator as Navigator & {
share?: (data: ShareData) => Promise<void>;
}).share;
if (navShare && url) {
void navShare.call(navigator, { url });
} else if (
url &&
typeof navigator !== "undefined" &&
navigator.clipboard
) {
void navigator.clipboard.writeText(url);
}
}}
>
<img src="/assets/img/share.svg" alt="" aria-hidden="true" />
</button>
<div className="schedule-flight-body__spacer" />
{/* TZ §4.1.14.4.4 / TIRREDESIGN-6 "Купить билет": rendered as
an anchor (not a button), visible only on desktop hover, and
only within the 2h-330d sales window. */}
{buyVisible && buyUrl && (
<a
href={buyUrl}
target="_blank"
rel="noopener noreferrer"
className="schedule-flight-body__buy-btn"
data-testid="schedule-buy-button"
onClick={(e) => e.stopPropagation()}
>
{t("SHARED.BUY-TICKET")}
</a>
)}
{/* TZ §4.1.14.4.5 Status: visible only on the departure day */}
{statusVisible && (
<button
type="button"
className="schedule-flight-body__status-btn"
data-testid="schedule-status-button"
title={t("BOARD.STATUS-TOOLTIP")}
onClick={(e) => {
e.stopPropagation();
onStatus?.();
}}
>
{t("SHARED.FLIGHT-STATUS")}
</button>
)}
{/* TZ §4.1.14.4.6 Details: always visible when handler provided */}
{onStatus && (
<button
type="button"
className="schedule-flight-body__details-btn"
data-testid="schedule-details-button"
onClick={(e) => {
e.stopPropagation();
onStatus?.();
}}
>
{t("SHARED.FLIGHT-DETAILS")}
</button>
)}
</div>
{/* Angular's `flight-schedule-details` renders `flight-actions`
with `share=false buy=false print=false details=false
register=false`, so the per-leg body has no action strip —
share + buy live in the page-level summary header instead.
Only the Status button can surface here, and only when the
enclosing page is a connecting itinerary (`connected=true`). */}
</div>
);
};
+11
View File
@@ -62,3 +62,14 @@
width: 180px;
height: 46px;
}
// Round variant inside the details-header-badge must override the
// 180×46 rule above — Angular's `.company-logo.round` pins at 36×36
// regardless of the `large` flag, so per-flight badges in a connecting
// summary show a compact round airline icon next to "SU 6188", not a
// full-width wordmark. Higher specificity (class×2) beats the plain
// `.details-header-badge__airline .operator-logo` above.
.details-header-badge__airline .operator-logo.operator-logo--round {
width: 36px;
height: 36px;
}