diff --git a/src/features/schedule/components/DayGroupedFlightList.tsx b/src/features/schedule/components/DayGroupedFlightList.tsx index ede571dc..34d7296b 100644 --- a/src/features/schedule/components/DayGroupedFlightList.tsx +++ b/src/features/schedule/components/DayGroupedFlightList.tsx @@ -32,8 +32,8 @@ export interface DayGroupedFlightListProps { flights: ISimpleFlight[]; loading?: boolean; onFlightClick?: (flight: ISimpleFlight) => void; - /** Click handler for the `Купить` button inside each expanded flight body. */ - onBuy?: (flight: ISimpleFlight) => void; + /** Aeroflot booking URL for each flight. Returning null hides the Buy link. */ + buyUrlFor?: (flight: ISimpleFlight) => string | null; initialCurrentFlightId?: string | null; } @@ -127,7 +127,7 @@ export const DayGroupedFlightList: FC = ({ flights, loading, onFlightClick, - onBuy, + buyUrlFor, initialCurrentFlightId, }) => { const { language } = useLocale(); @@ -235,14 +235,17 @@ export const DayGroupedFlightList: FC = ({ // boxes — replaces the default time/transition rows. Buy/Status // buttons live inside this body (only place Angular renders them). const renderScheduleBody = useCallback( - (f: ISimpleFlight) => ( - onFlightClick(f) } : {})} - {...(onBuy ? { onBuy: () => onBuy(f) } : {})} - /> - ), - [onFlightClick, onBuy], + (f: ISimpleFlight) => { + const buyUrl = buyUrlFor?.(f) ?? null; + return ( + onFlightClick(f) } : {})} + {...(buyUrl ? { buyUrl } : {})} + /> + ); + }, + [onFlightClick, buyUrlFor], ); if (loading) return ; @@ -262,6 +265,7 @@ export const DayGroupedFlightList: FC = ({ direction="schedule" renderExpandedBody={renderScheduleBody} {...(onFlightClick ? { onFlightClick } : {})} + {...(buyUrlFor ? { buyUrlFor } : {})} {...(resolvedInitialFlightId ? { initialCurrentFlightId: resolvedInitialFlightId } : {})} diff --git a/src/features/schedule/components/ScheduleDetailsPage.tsx b/src/features/schedule/components/ScheduleDetailsPage.tsx index 201e9c60..0a8c037f 100644 --- a/src/features/schedule/components/ScheduleDetailsPage.tsx +++ b/src/features/schedule/components/ScheduleDetailsPage.tsx @@ -181,16 +181,17 @@ export const ScheduleDetailsPage: FC = ({ // Selected date is always the first flight's date (primary leg). const selectedDate = flightIds[0]?.date ?? ""; - // `Купить` button — opens Aeroflot's booking flow in a new tab. - // Mirrors BoardDetailsHeader's BuyTicketButton / Schedule search page. + // `Купить билет` link — navigates to Aeroflot's booking flow in a new + // tab. Mirrors BoardDetailsHeader's BuyTicketButton / Schedule search + // page. Returns null when we can't assemble the query (missing legs). const language = localeToLanguage(normalizeLocaleParam(locale) ?? "ru-ru") ?? DEFAULT_LANGUAGE; - const handleBuy = useCallback( - (flight: ISimpleFlight) => { + const buyUrlFor = useCallback( + (flight: ISimpleFlight): string | null => { const legs = flight.routeType === "Direct" ? [flight.leg] : flight.legs; const firstLeg = legs[0]; const lastLeg = legs[legs.length - 1]; - if (!firstLeg || !lastLeg) return; + if (!firstLeg || !lastLeg) return null; const dep = firstLeg.departure.scheduled.airportCode; const arr = lastLeg.arrival.scheduled.airportCode; const depUtc = firstLeg.departure.times.scheduledDeparture.utc; @@ -198,10 +199,7 @@ export const ScheduleDetailsPage: FC = ({ const yyyy = depDate.getFullYear().toString(); const mm = (depDate.getMonth() + 1).toString().padStart(2, "0"); const dd = depDate.getDate().toString().padStart(2, "0"); - const url = `https://www.aeroflot.ru/sb/app/${language}-${language}#/search?adults=1&cabin=economy&children=0&infants=0&routes=${dep}.${yyyy}${mm}${dd}.${arr}&autosearch=Y`; - if (typeof window !== "undefined") { - window.open(url, "_blank", "noopener,noreferrer"); - } + return `https://www.aeroflot.ru/sb/app/${language}-${language}#/search?adults=1&cabin=economy&children=0&infants=0&routes=${dep}.${yyyy}${mm}${dd}.${arr}&autosearch=Y`; }, [language], ); @@ -288,12 +286,15 @@ export const ScheduleDetailsPage: FC = ({ // (connecting itineraries assembled from the URL) render the rich // body for each, separated by a transit caption — close to Angular's // `schedule-flight-details-view` for the connecting case. - const renderBody = (flight: typeof flights[number]) => ( - handleBuy(flight as unknown as ISimpleFlight)} - /> - ); + const renderBody = (flight: typeof flights[number]) => { + const buyUrl = buyUrlFor(flight as unknown as ISimpleFlight); + return ( + + ); + }; // TZ §4.1.16.2: mini-list shows sibling flights from the same search context. // For schedule details the sibling context is not available via a separate API; diff --git a/src/features/schedule/components/ScheduleFlightBody.test.tsx b/src/features/schedule/components/ScheduleFlightBody.test.tsx index d7fb697a..c061c083 100644 --- a/src/features/schedule/components/ScheduleFlightBody.test.tsx +++ b/src/features/schedule/components/ScheduleFlightBody.test.tsx @@ -400,45 +400,43 @@ describe("ScheduleFlightBody – TZ §4.1.14.4", () => { // ──────────────────────────────────────────────────────────────────────────── describe("Buy button – TZ §4.1.14.4.4", () => { - it("shows buy button when departure is 10 days ahead", () => { - const onBuy = vi.fn(); - render(); + const BUY = "https://buy.example/route"; + + it("shows buy link when departure is 10 days ahead", () => { + render(); expect(screen.getByTestId("schedule-buy-button")).toBeTruthy(); }); - it("hides buy button when departure is > 330 days ahead", () => { - const onBuy = vi.fn(); - render(); + it("hides buy link when departure is > 330 days ahead", () => { + render(); expect(screen.queryByTestId("schedule-buy-button")).toBeNull(); }); - it("hides buy button when departure is less than 2h away", () => { - const onBuy = vi.fn(); - render(); + it("hides buy link when departure is less than 2h away", () => { + render(); expect(screen.queryByTestId("schedule-buy-button")).toBeNull(); }); - it("hides buy button when departure is in the past", () => { - const onBuy = vi.fn(); - render(); + it("hides buy link when departure is in the past", () => { + render(); expect(screen.queryByTestId("schedule-buy-button")).toBeNull(); }); - it("hides buy button when onBuy prop not provided", () => { + it("hides buy link when buyUrl prop not provided", () => { render(); expect(screen.queryByTestId("schedule-buy-button")).toBeNull(); }); - it("calls onBuy handler when buy button clicked", () => { - const onBuy = vi.fn(); - render(); - fireEvent.click(screen.getByTestId("schedule-buy-button")); - expect(onBuy).toHaveBeenCalledOnce(); + it("renders buy link as anchor targeting the provided URL", () => { + render(); + 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)", () => { - const onBuy = vi.fn(); - render(); + render(); expect(screen.getByTestId("schedule-buy-button")).toBeTruthy(); }); }); diff --git a/src/features/schedule/components/ScheduleFlightBody.tsx b/src/features/schedule/components/ScheduleFlightBody.tsx index afb8c5c7..9f2afbac 100644 --- a/src/features/schedule/components/ScheduleFlightBody.tsx +++ b/src/features/schedule/components/ScheduleFlightBody.tsx @@ -33,8 +33,12 @@ import "./ScheduleFlightBody.scss"; export interface ScheduleFlightBodyProps { flight: ISimpleFlight; - /** Optional click handler for the `Купить` button. */ - onBuy?: () => void; + /** + * 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; } @@ -127,7 +131,7 @@ function isStatusVisible(firstLegDepUtc: string | undefined): boolean { export const ScheduleFlightBody: FC = ({ flight, - onBuy, + buyUrl, onStatus, }) => { const { t } = useTranslation(); @@ -139,7 +143,7 @@ export const ScheduleFlightBody: FC = ({ const firstLeg = legs[0]; const buyVisible = - Boolean(onBuy) && + Boolean(buyUrl) && isBuyVisible(firstLeg?.departure.times.scheduledDeparture.utc); const statusVisible = Boolean(onStatus) && @@ -445,19 +449,20 @@ export const ScheduleFlightBody: FC = ({
- {/* TZ §4.1.14.4.4 – Buy: visible only within 2h–330d window */} - {buyVisible && ( - + )} {/* TZ §4.1.14.4.5 – Status: visible only on the departure day */} {statusVisible && ( diff --git a/src/features/schedule/components/ScheduleSearchPage.tsx b/src/features/schedule/components/ScheduleSearchPage.tsx index f838dd30..78c65441 100644 --- a/src/features/schedule/components/ScheduleSearchPage.tsx +++ b/src/features/schedule/components/ScheduleSearchPage.tsx @@ -174,15 +174,16 @@ export const ScheduleSearchPage: FC = ({ params }) => { [locale, navigate, outbound, inbound], ); - // `Купить` button — opens Aeroflot's booking flow in a new tab, same - // URL shape as BoardDetailsHeader's BuyTicketButton: + // Builds the Aeroflot booking URL for a flight — shares shape with + // BoardDetailsHeader's BuyTicketButton: // https://www.aeroflot.ru/sb/app/{lang}-{lang}#/search?…&routes={dep}.{yyyyMMdd}.{arr} - const handleBuy = useCallback( - (flight: ISimpleFlight) => { + // Returns null when the flight lacks the data we need to assemble it. + const buyUrlFor = useCallback( + (flight: ISimpleFlight): string | null => { const legs = flight.routeType === "Direct" ? [flight.leg] : flight.legs; const firstLeg = legs[0]; const lastLeg = legs[legs.length - 1]; - if (!firstLeg || !lastLeg) return; + if (!firstLeg || !lastLeg) return null; const dep = firstLeg.departure.scheduled.airportCode; const arr = lastLeg.arrival.scheduled.airportCode; const depUtc = firstLeg.departure.times.scheduledDeparture.utc; @@ -190,10 +191,7 @@ export const ScheduleSearchPage: FC = ({ params }) => { const yyyy = depDate.getFullYear().toString(); const mm = (depDate.getMonth() + 1).toString().padStart(2, "0"); const dd = depDate.getDate().toString().padStart(2, "0"); - const url = `https://www.aeroflot.ru/sb/app/${language}-${language}#/search?adults=1&cabin=economy&children=0&infants=0&routes=${dep}.${yyyy}${mm}${dd}.${arr}&autosearch=Y`; - if (typeof window !== "undefined") { - window.open(url, "_blank", "noopener,noreferrer"); - } + return `https://www.aeroflot.ru/sb/app/${language}-${language}#/search?adults=1&cabin=economy&children=0&infants=0&routes=${dep}.${yyyy}${mm}${dd}.${arr}&autosearch=Y`; }, [language], ); @@ -499,7 +497,7 @@ export const ScheduleSearchPage: FC = ({ params }) => { flights={outboundSimple} loading={outboundLoading} onFlightClick={handleFlightClick} - onBuy={handleBuy} + buyUrlFor={buyUrlFor} />
) : ( @@ -508,7 +506,7 @@ export const ScheduleSearchPage: FC = ({ params }) => { flights={inboundSimple} loading={inboundLoading} onFlightClick={handleFlightClick} - onBuy={handleBuy} + buyUrlFor={buyUrlFor} /> )} diff --git a/src/ui/flights/FlightCard.scss b/src/ui/flights/FlightCard.scss index da2b540a..8dbcefa7 100644 --- a/src/ui/flights/FlightCard.scss +++ b/src/ui/flights/FlightCard.scss @@ -43,6 +43,7 @@ // plus `padding: $space-l $space-xl` (15/20px) — matches Angular's // vertical rhythm on each card. &__row { + position: relative; display: grid; grid-template-columns: 60px 120px 100px 1fr minmax(85px, 145px) 120px 1fr 10px; @@ -286,6 +287,41 @@ &:hover { background: colors.$orange--hover; } } + // TIRREDESIGN-6 / TZ §4.1.14.4.4: inline per-row "Купить билет" link + // that appears on hover near the arrival station. Overlays the row + // so the 8-column grid stays intact. Desktop only — touch devices get + // no hover affordance (Buy ticket remains available from details). + &__buy-link { + display: none; + position: absolute; + right: vars.$space-xl; + top: 50%; + transform: translateY(-50%); + padding: 6px 14px; + font-size: fonts.$font-size-s; + font-weight: fonts.$font-medium; + color: colors.$white; + background: colors.$orange; + border-radius: vars.$border-radius; + text-decoration: none; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease, background-color 0.2s ease; + z-index: 2; + + &:hover { background: colors.$orange--hover; } + } + + @media (hover: hover) { + &__buy-link { display: inline-flex; align-items: center; } + &__row:hover .flight-card__buy-link, + &__buy-link:focus-visible { + opacity: 1; + pointer-events: auto; + } + } + &__details-btn { background: colors.$blue; color: colors.$white; diff --git a/src/ui/flights/FlightCard.tsx b/src/ui/flights/FlightCard.tsx index a94d18d4..16eb5428 100644 --- a/src/ui/flights/FlightCard.tsx +++ b/src/ui/flights/FlightCard.tsx @@ -60,6 +60,14 @@ export interface FlightCardProps { * buttons Angular shows on the search results expansion. */ renderActions?: (flight: ISimpleFlight) => ReactNode; + /** + * Aeroflot booking URL for the per-row "Купить билет" hover link + * (TIRREDESIGN-6 / TZ §4.1.14.4.4). Rendered inside the collapsed row + * only on Schedule layouts; absent in the row flow on other directions. + * CSS shows the link on hover on desktop and hides it entirely on + * mobile / tablet. + */ + inlineBuyUrl?: string; } /** Extract the primary leg from a flight (first leg for multi-leg) */ @@ -131,6 +139,7 @@ export const FlightCard: FC = ({ direction = "route", renderExpandedBody, renderActions, + inlineBuyUrl, }) => { const { t } = useTranslation(); const { language } = useLocale(); @@ -341,6 +350,19 @@ export const FlightCard: FC = ({ /> + {inlineBuyUrl && ( + e.stopPropagation()} + > + {t("SHARED.BUY-TICKET")} + + )} + {(expandable || direction === "schedule") && (