'Купить билет' hover link + anchor semantics (TIRREDESIGN-6)

The Buy action is now an <a href> instead of a <button> that opens a
window, so users can inspect / middle-click / right-click it like any
normal link. The inline per-row link on the schedule results list only
appears on hover (desktop) — touch devices still navigate via the
details card's Buy button. Copy updated to 'Купить билет' / 'Buy a
ticket' per §4.1.14.4.4.

ScheduleFlightBody, DayGroupedFlightList, ScheduleSearchPage and
ScheduleDetailsPage thread a buyUrlFor → buyUrl URL instead of an
onBuy callback. FlightList/FlightCard gain an inlineBuyUrl prop plus
overlay CSS so the 8-column grid stays intact.
This commit is contained in:
2026-04-22 13:45:40 +03:00
parent 8bde3904e1
commit 31751d0e84
7 changed files with 134 additions and 70 deletions
@@ -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<DayGroupedFlightListProps> = ({
flights,
loading,
onFlightClick,
onBuy,
buyUrlFor,
initialCurrentFlightId,
}) => {
const { language } = useLocale();
@@ -235,14 +235,17 @@ export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
// boxes — replaces the default time/transition rows. Buy/Status
// buttons live inside this body (only place Angular renders them).
const renderScheduleBody = useCallback(
(f: ISimpleFlight) => (
<ScheduleFlightBody
flight={f}
{...(onFlightClick ? { onStatus: () => onFlightClick(f) } : {})}
{...(onBuy ? { onBuy: () => onBuy(f) } : {})}
/>
),
[onFlightClick, onBuy],
(f: ISimpleFlight) => {
const buyUrl = buyUrlFor?.(f) ?? null;
return (
<ScheduleFlightBody
flight={f}
{...(onFlightClick ? { onStatus: () => onFlightClick(f) } : {})}
{...(buyUrl ? { buyUrl } : {})}
/>
);
},
[onFlightClick, buyUrlFor],
);
if (loading) return <FlightListSkeleton count={5} />;
@@ -262,6 +265,7 @@ export const DayGroupedFlightList: FC<DayGroupedFlightListProps> = ({
direction="schedule"
renderExpandedBody={renderScheduleBody}
{...(onFlightClick ? { onFlightClick } : {})}
{...(buyUrlFor ? { buyUrlFor } : {})}
{...(resolvedInitialFlightId
? { initialCurrentFlightId: resolvedInitialFlightId }
: {})}
@@ -181,16 +181,17 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
// 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<ScheduleDetailsPageProps> = ({
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<ScheduleDetailsPageProps> = ({
// (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]) => (
<ScheduleFlightBody
flight={flight as unknown as ISimpleFlight}
onBuy={() => handleBuy(flight as unknown as ISimpleFlight)}
/>
);
const renderBody = (flight: typeof flights[number]) => {
const buyUrl = buyUrlFor(flight as unknown as ISimpleFlight);
return (
<ScheduleFlightBody
flight={flight as unknown as ISimpleFlight}
{...(buyUrl ? { buyUrl } : {})}
/>
);
};
// 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;
@@ -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(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_10D)} onBuy={onBuy} />);
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 button when departure is > 330 days ahead", () => {
const onBuy = vi.fn();
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_340D)} onBuy={onBuy} />);
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 button when departure is less than 2h away", () => {
const onBuy = vi.fn();
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_1H)} onBuy={onBuy} />);
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 button when departure is in the past", () => {
const onBuy = vi.fn();
render(<ScheduleFlightBody flight={makeDirectFlight(YESTERDAY)} onBuy={onBuy} />);
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 button when onBuy prop not provided", () => {
it("hides buy link when buyUrl prop not provided", () => {
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_10D)} />);
expect(screen.queryByTestId("schedule-buy-button")).toBeNull();
});
it("calls onBuy handler when buy button clicked", () => {
const onBuy = vi.fn();
render(<ScheduleFlightBody flight={makeDirectFlight(FUTURE_10D)} onBuy={onBuy} />);
fireEvent.click(screen.getByTestId("schedule-buy-button"));
expect(onBuy).toHaveBeenCalledOnce();
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)", () => {
const onBuy = vi.fn();
render(<ScheduleFlightBody flight={makeMultiLegFlight(FUTURE_10D)} onBuy={onBuy} />);
render(<ScheduleFlightBody flight={makeMultiLegFlight(FUTURE_10D)} buyUrl={BUY} />);
expect(screen.getByTestId("schedule-buy-button")).toBeTruthy();
});
});
@@ -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<ScheduleFlightBodyProps> = ({
flight,
onBuy,
buyUrl,
onStatus,
}) => {
const { t } = useTranslation();
@@ -139,7 +143,7 @@ export const ScheduleFlightBody: FC<ScheduleFlightBodyProps> = ({
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<ScheduleFlightBodyProps> = ({
<img src="/assets/img/share.svg" alt="" aria-hidden="true" />
</button>
<div className="schedule-flight-body__spacer" />
{/* TZ §4.1.14.4.4 Buy: visible only within 2h330d window */}
{buyVisible && (
<button
type="button"
{/* 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();
onBuy?.();
}}
onClick={(e) => e.stopPropagation()}
>
{t("SHARED.BUY-TICKET")}
</button>
</a>
)}
{/* TZ §4.1.14.4.5 Status: visible only on the departure day */}
{statusVisible && (
@@ -174,15 +174,16 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ 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<ScheduleSearchPageProps> = ({ 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<ScheduleSearchPageProps> = ({ params }) => {
flights={outboundSimple}
loading={outboundLoading}
onFlightClick={handleFlightClick}
onBuy={handleBuy}
buyUrlFor={buyUrlFor}
/>
</div>
) : (
@@ -508,7 +506,7 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
flights={inboundSimple}
loading={inboundLoading}
onFlightClick={handleFlightClick}
onBuy={handleBuy}
buyUrlFor={buyUrlFor}
/>
</div>
)}
+36
View File
@@ -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;
+22
View File
@@ -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<FlightCardProps> = ({
direction = "route",
renderExpandedBody,
renderActions,
inlineBuyUrl,
}) => {
const { t } = useTranslation();
const { language } = useLocale();
@@ -341,6 +350,19 @@ export const FlightCard: FC<FlightCardProps> = ({
/>
</div>
{inlineBuyUrl && (
<a
href={inlineBuyUrl}
target="_blank"
rel="noopener noreferrer"
className="flight-card__buy-link"
data-testid="flight-card-buy-link"
onClick={(e) => e.stopPropagation()}
>
{t("SHARED.BUY-TICKET")}
</a>
)}
{(expandable || direction === "schedule") && (
<div
className={`flight-card__chevron${expanded ? " flight-card__chevron--open" : ""}`}