'Купить билет' 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:
@@ -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 2h–330d 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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" : ""}`}
|
||||
|
||||
Reference in New Issue
Block a user