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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user