diff --git a/src/features/flights-map/buyTicketUrl.ts b/src/features/flights-map/buyTicketUrl.ts index 6a2e6798..0add4114 100644 --- a/src/features/flights-map/buyTicketUrl.ts +++ b/src/features/flights-map/buyTicketUrl.ts @@ -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, + }); } /** diff --git a/src/features/online-board/components/BoardDetailsHeader/BuyTicketButton.tsx b/src/features/online-board/components/BoardDetailsHeader/BuyTicketButton.tsx index 55057128..efc8e910 100644 --- a/src/features/online-board/components/BoardDetailsHeader/BuyTicketButton.tsx +++ b/src/features/online-board/components/BoardDetailsHeader/BuyTicketButton.tsx @@ -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 = ({ flight }) => { diff --git a/src/features/schedule/components/ScheduleDetailsPage.tsx b/src/features/schedule/components/ScheduleDetailsPage.tsx index b801e187..580da351 100644 --- a/src/features/schedule/components/ScheduleDetailsPage.tsx +++ b/src/features/schedule/components/ScheduleDetailsPage.tsx @@ -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 = ({ 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 = ({ 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 = ( = ({ 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 = ({ 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 diff --git a/src/shared/booking/aeroflot.test.ts b/src/shared/booking/aeroflot.test.ts new file mode 100644 index 00000000..7c5c6fd6 --- /dev/null +++ b/src/shared/booking/aeroflot.test.ts @@ -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"); + }); +}); diff --git a/src/shared/booking/aeroflot.ts b/src/shared/booking/aeroflot.ts new file mode 100644 index 00000000..cd2ab604 --- /dev/null +++ b/src/shared/booking/aeroflot.ts @@ -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; +} + +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()}`; +}