Fix Aeroflot buy ticket URLs

This commit is contained in:
2026-05-05 22:01:46 +03:00
parent ef8bda8683
commit f08ed8b206
6 changed files with 125 additions and 37 deletions
+13 -11
View File
@@ -11,11 +11,13 @@
* routes=MOW..LED → omitted: routes=MOW.LED
*/
const BASE = "https://www.aeroflot.ru/sb/app/ru-ru#/search";
const FIXED_PARAMS =
"adults=1&cabin=economy&children=0&infants=0&autosearch=Y" +
"&utm_source=aflwebbot&utm_medium=referral" +
"&utm_campaign=ref_3015_general_rf_button.index__all_flight.map";
import { buildAeroflotSbSearchUrl } from "@/shared/booking/aeroflot.js";
const MAP_UTM_PARAMS = {
utm_source: "aflwebbot",
utm_medium: "referral",
utm_campaign: "ref_3015_general_rf_button.index__all_flight.map",
} as const;
/**
* Build the SB search URL for a single one-way leg.
@@ -30,12 +32,12 @@ export function buildBuyTicketUrl(
arrival: string,
date: string | undefined,
): string {
// TZ §4.1.24.6 Table 71: route format is {dep}.{date}.{arr} for one-way
// with a date; {dep}.{arr} (no dot-date segment) when no date is known.
const routeTriple = date
? `${departure}.${date}.${arrival}`
: `${departure}.${arrival}`;
return `${BASE}?${FIXED_PARAMS}&routes=${routeTriple}`;
return buildAeroflotSbSearchUrl({
departure,
arrival,
date,
extraParams: MAP_UTM_PARAMS,
});
}
/**
@@ -1,6 +1,9 @@
import type { FC } from "react";
import { parseISO, format } from "date-fns";
import { useTranslation } from "@/i18n/provider.js";
import {
buildAeroflotSbSearchUrl,
formatAeroflotRouteDate,
} from "@/shared/booking/aeroflot.js";
import type { ISimpleFlight } from "../../types.js";
import "./actions.scss";
@@ -24,9 +27,11 @@ function buildBuyTicketUrl(flight: ISimpleFlight): string {
if (!firstLeg || !lastLeg) return "";
const dep = firstLeg.departure.scheduled.airportCode;
const arr = lastLeg.arrival.scheduled.airportCode;
const depDate = parseISO(firstLeg.departure.times.scheduledDeparture.utc);
const date = format(depDate, "yyyyMMdd");
return `https://www.aeroflot.ru/sb/app/ru-ru#/search?adults=1&cabin=economy&children=0&infants=0&routes=${dep}.${date}.${arr}&autosearch=Y`;
const date = formatAeroflotRouteDate(
firstLeg.departure.times.scheduledDeparture.local ||
firstLeg.departure.times.scheduledDeparture.utc,
);
return buildAeroflotSbSearchUrl({ departure: dep, arrival: arr, date });
}
export const BuyTicketButton: FC<BuyTicketButtonProps> = ({ flight }) => {
@@ -12,7 +12,6 @@ import type { FC } from "react";
import { Fragment, useCallback, useMemo } from "react";
import { Link, useNavigate, useSearchParams } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import { localeToLanguage, normalizeLocaleParam, DEFAULT_LANGUAGE } from "@/i18n/resolver.js";
import { FlightCard } from "@/ui/flights/FlightCard.js";
import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js";
import { IFlyWarning } from "@/ui/flights/IFlyWarning.js";
@@ -20,6 +19,10 @@ import { SeoHead } from "@/ui/seo/SeoHead.js";
import { PageLayout } from "@/ui/layout/PageLayout.js";
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
import { parseDetailsRequestParam } from "@/shared/detailsRequestParam.js";
import {
buildAeroflotSbSearchUrl,
formatAeroflotRouteDate,
} from "@/shared/booking/aeroflot.js";
import { buildScheduleUrl } from "../url.js";
import { useScheduleDetails } from "../hooks/useScheduleDetails.js";
import { useAppSettings } from "@/shared/hooks/useAppSettings.js";
@@ -235,10 +238,8 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
const selectedDate = flightIds[0]?.date ?? "";
// `Купить билет` 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;
// tab. Mirrors Angular's hardcoded ru-ru SB URL. Returns null when we
// can't assemble the query (missing legs).
const buyUrlFor = useCallback(
(flight: ISimpleFlight): string | null => {
const legs = flight.routeType === "Direct" ? [flight.leg] : flight.legs;
@@ -247,14 +248,13 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
if (!firstLeg || !lastLeg) return null;
const dep = firstLeg.departure.scheduled.airportCode;
const arr = lastLeg.arrival.scheduled.airportCode;
const depUtc = firstLeg.departure.times.scheduledDeparture.utc;
const depDate = new Date(depUtc);
const yyyy = depDate.getFullYear().toString();
const mm = (depDate.getMonth() + 1).toString().padStart(2, "0");
const dd = depDate.getDate().toString().padStart(2, "0");
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`;
const date = formatAeroflotRouteDate(
firstLeg.departure.times.scheduledDeparture.local ||
firstLeg.departure.times.scheduledDeparture.utc,
);
return buildAeroflotSbSearchUrl({ departure: dep, arrival: arr, date });
},
[language],
[],
);
const backLink = (
<Link
@@ -32,6 +32,10 @@ import { useScheduleSearch } from "../hooks/useScheduleSearch.js";
import { buildScheduleUrl } from "../url.js";
import { buildFlightUrlParams } from "../../online-board/url.js";
import { buildDetailsRequestParam } from "@/shared/detailsRequestParam.js";
import {
buildAeroflotSbSearchUrl,
formatAeroflotRouteDate,
} from "@/shared/booking/aeroflot.js";
import { buildScheduleFlightListJsonLd } from "../json-ld.js";
import type { ScheduleParams } from "../url.js";
import type { IScheduleSearchRequest, ISimpleFlight } from "../types.js";
@@ -156,9 +160,8 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
[locale, navigate, outbound, inbound],
);
// 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}
// Builds the Aeroflot booking URL for a flight. Angular's
// BuyTicketLogic hardcodes the SB path to ru-ru for every UI locale.
// Returns null when the flight lacks the data we need to assemble it.
const buyUrlFor = useCallback(
(flight: ISimpleFlight): string | null => {
@@ -168,14 +171,13 @@ export const ScheduleSearchPage: FC<ScheduleSearchPageProps> = ({ params }) => {
if (!firstLeg || !lastLeg) return null;
const dep = firstLeg.departure.scheduled.airportCode;
const arr = lastLeg.arrival.scheduled.airportCode;
const depUtc = firstLeg.departure.times.scheduledDeparture.utc;
const depDate = new Date(depUtc);
const yyyy = depDate.getFullYear().toString();
const mm = (depDate.getMonth() + 1).toString().padStart(2, "0");
const dd = depDate.getDate().toString().padStart(2, "0");
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`;
const date = formatAeroflotRouteDate(
firstLeg.departure.times.scheduledDeparture.local ||
firstLeg.departure.times.scheduledDeparture.utc,
);
return buildAeroflotSbSearchUrl({ departure: dep, arrival: arr, date });
},
[language],
[],
);
// Round-trip schedules render only one direction at a time, with a
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
buildAeroflotSbSearchUrl,
formatAeroflotRouteDate,
} from "./aeroflot.js";
describe("formatAeroflotRouteDate", () => {
it("uses the date encoded in an offset-aware local timestamp", () => {
expect(formatAeroflotRouteDate("2026-05-05T23:30:00-04:00")).toBe("20260505");
});
it("accepts compact yyyyMMdd dates", () => {
expect(formatAeroflotRouteDate("20260505")).toBe("20260505");
});
});
describe("buildAeroflotSbSearchUrl", () => {
it("uses Angular's hardcoded ru-ru SB route", () => {
const url = buildAeroflotSbSearchUrl({
departure: "SVO",
arrival: "LED",
date: "20260505",
});
expect(url).toContain("https://www.aeroflot.ru/sb/app/ru-ru#/search?");
expect(url).toContain("routes=SVO.20260505.LED");
expect(url).toContain("autosearch=Y");
});
it("omits the date segment when no date is supplied", () => {
const url = buildAeroflotSbSearchUrl({
departure: "MOW",
arrival: "LED",
date: null,
});
expect(url).toContain("routes=MOW.LED");
});
});
+40
View File
@@ -0,0 +1,40 @@
const AEROFLOT_SB_SEARCH_BASE = "https://www.aeroflot.ru/sb/app/ru-ru#/search";
const DEFAULT_SEARCH_PARAMS = {
adults: "1",
cabin: "economy",
children: "0",
infants: "0",
autosearch: "Y",
} as const;
export function formatAeroflotRouteDate(value: string | undefined): string | null {
if (!value) return null;
const match = /^(\d{4})-?(\d{2})-?(\d{2})/.exec(value);
if (!match) return null;
return `${match[1]}${match[2]}${match[3]}`;
}
export interface AeroflotSbSearchUrlOptions {
departure: string;
arrival: string;
date?: string | null | undefined;
extraParams?: Record<string, string>;
}
export function buildAeroflotSbSearchUrl({
departure,
arrival,
date,
extraParams,
}: AeroflotSbSearchUrlOptions): string {
const routes = date
? `${departure}.${date}.${arrival}`
: `${departure}.${arrival}`;
const params = new URLSearchParams({
...DEFAULT_SEARCH_PARAMS,
routes,
...extraParams,
});
return `${AEROFLOT_SB_SEARCH_BASE}?${params.toString()}`;
}