Fix Aeroflot buy ticket URLs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user