diff --git a/src/features/online-board/components/BoardDetailsHeader/FlightActions.test.tsx b/src/features/online-board/components/BoardDetailsHeader/FlightActions.test.tsx index e0fce849..48958f51 100644 --- a/src/features/online-board/components/BoardDetailsHeader/FlightActions.test.tsx +++ b/src/features/online-board/components/BoardDetailsHeader/FlightActions.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { FlightActions } from "./FlightActions.js"; import type { ISimpleFlight } from "../../types.js"; @@ -108,4 +108,15 @@ describe("FlightActions", () => { render(); expect(screen.getByTestId("flight-actions")).toBeTruthy(); }); + + it("shares the flight details URL instead of the current page URL", () => { + window.history.pushState({}, "", "/ru-ru/schedule/SVO/SU0022-20260417/LED?request=schedule-route-MOW-LED-20260413-20260419"); + render(); + fireEvent.click(screen.getByTestId("share-button")); + const link = screen.getByTestId("share-vk") as HTMLAnchorElement; + expect(decodeURIComponent(link.href)).toContain( + `${window.location.origin}/ru-ru/schedule/SVO/SU0022-20260417/LED`, + ); + expect(decodeURIComponent(link.href)).not.toContain("request=schedule-route"); + }); }); diff --git a/src/features/online-board/components/BoardDetailsHeader/FlightActions.tsx b/src/features/online-board/components/BoardDetailsHeader/FlightActions.tsx index f5202599..8b175618 100644 --- a/src/features/online-board/components/BoardDetailsHeader/FlightActions.tsx +++ b/src/features/online-board/components/BoardDetailsHeader/FlightActions.tsx @@ -10,6 +10,7 @@ import { RegistrationButton } from "./RegistrationButton.js"; import { FlightStatusButton } from "./FlightStatusButton.js"; import { ShareButton } from "./ShareButton.js"; import { PrintButton } from "./PrintButton.js"; +import { buildFlightShareUrl, type FlightShareViewType } from "./shareUrl.js"; export interface FlightActionsProps { flight: ISimpleFlight; @@ -27,6 +28,7 @@ export interface FlightActionsProps { * particular day's "Cancelled" status would hide a useful action. */ forceBuy?: boolean; + viewType?: FlightShareViewType; } export const FlightActions: FC = ({ @@ -38,6 +40,7 @@ export const FlightActions: FC = ({ showRegister = true, showBuy = true, forceBuy = false, + viewType = "Onlineboard", }) => { const { flightStatusAvailableFromHours, buyTicketMinHours, buyTicketMaxHours } = useAppSettings(); const now = new Date(); @@ -49,7 +52,7 @@ export const FlightActions: FC = ({ showStatus && canViewFlightStatus(flight, now, flightStatusAvailableFromHours, AIRLINES_WITH_STATUS); - const shareUrl = typeof window !== "undefined" ? window.location.href : ""; + const shareUrl = buildFlightShareUrl(flight, locale, viewType); return (
diff --git a/src/features/online-board/components/BoardDetailsHeader/LastUpdate.tsx b/src/features/online-board/components/BoardDetailsHeader/LastUpdate.tsx index ea5d1638..5c097e51 100644 --- a/src/features/online-board/components/BoardDetailsHeader/LastUpdate.tsx +++ b/src/features/online-board/components/BoardDetailsHeader/LastUpdate.tsx @@ -3,10 +3,12 @@ import { format } from "date-fns"; import { useTranslation } from "@/i18n/provider.js"; import type { ISimpleFlight } from "../../types.js"; import { ShareButton } from "./ShareButton.js"; +import { buildFlightShareUrl, type FlightShareViewType } from "./shareUrl.js"; export interface LastUpdateProps { flight: ISimpleFlight; locale: string; + viewType?: FlightShareViewType; } function formatStamp(d: Date): string { @@ -21,7 +23,7 @@ function formatStamp(d: Date): string { * timestamp. We mirror that here: capture `Date.now()` the first time we * see a given flight.id, then re-capture whenever the id changes. */ -export const LastUpdate: FC = ({ flight, locale }) => { +export const LastUpdate: FC = ({ flight, locale, viewType = "Onlineboard" }) => { const { t } = useTranslation(); const [loadedAt, setLoadedAt] = useState(() => new Date()); const seenFlightIdRef = useRef(null); @@ -34,7 +36,7 @@ export const LastUpdate: FC = ({ flight, locale }) => { }, [flight.id]); const timestamp = formatStamp(loadedAt); - const shareUrl = typeof window !== "undefined" ? window.location.href : ""; + const shareUrl = buildFlightShareUrl(flight, locale, viewType); return (
diff --git a/src/features/online-board/components/BoardDetailsHeader/SharePanel.test.tsx b/src/features/online-board/components/BoardDetailsHeader/SharePanel.test.tsx index ab89e7ea..6c196561 100644 --- a/src/features/online-board/components/BoardDetailsHeader/SharePanel.test.tsx +++ b/src/features/online-board/components/BoardDetailsHeader/SharePanel.test.tsx @@ -27,11 +27,19 @@ describe("SharePanel", () => { expect(screen.queryByTestId("share-weibo")).toBeNull(); }); - it("renders 4 social links for zh locale (includes Weibo)", () => { + it("renders only Weibo social link for zh locale", () => { render( {}} />); - expect(screen.getByTestId("share-facebook")).toBeTruthy(); - expect(screen.getByTestId("share-vk")).toBeTruthy(); - expect(screen.getByTestId("share-twitter")).toBeTruthy(); + expect(screen.queryByTestId("share-facebook")).toBeNull(); + expect(screen.queryByTestId("share-vk")).toBeNull(); + expect(screen.queryByTestId("share-twitter")).toBeNull(); + expect(screen.getByTestId("share-weibo")).toBeTruthy(); + }); + + it("renders only Weibo social link for cn country locale", () => { + render( {}} />); + expect(screen.queryByTestId("share-facebook")).toBeNull(); + expect(screen.queryByTestId("share-vk")).toBeNull(); + expect(screen.queryByTestId("share-twitter")).toBeNull(); expect(screen.getByTestId("share-weibo")).toBeTruthy(); }); diff --git a/src/features/online-board/components/BoardDetailsHeader/SharePanel.tsx b/src/features/online-board/components/BoardDetailsHeader/SharePanel.tsx index 00c8c82c..b4878e2d 100644 --- a/src/features/online-board/components/BoardDetailsHeader/SharePanel.tsx +++ b/src/features/online-board/components/BoardDetailsHeader/SharePanel.tsx @@ -8,9 +8,16 @@ export interface SharePanelProps { onClose: () => void; } +function isChinaLocale(locale: string): boolean { + const normalized = locale.toLowerCase(); + const [country, language] = normalized.split("-"); + return country === "cn" || language === "zh" || normalized === "zh"; +} + export const SharePanel: FC = ({ url, locale, onClose }) => { const { t } = useTranslation(); const encoded = encodeURIComponent(url); + const chinaLocale = isChinaLocale(locale); // Close on Escape — matches Angular's PrimeNG p-overlayPanel dismissable behaviour. useEffect(() => { @@ -34,40 +41,44 @@ export const SharePanel: FC = ({ url, locale, onClose }) => { return (
- - - - {locale === "zh" && ( + {!chinaLocale && ( + <> + + + + + )} + {chinaLocale && (
{ + it("builds an online-board details URL from the flight local departure date", () => { + expect(buildFlightShareUrl(makeDirectFlight(), "ru-ru")).toBe( + `${window.location.origin}/ru-ru/onlineboard/SU0022-20260417`, + ); + }); + + it("builds a clean schedule details URL without request query", () => { + expect(buildFlightShareUrl(makeDirectFlight(), "ru-ru", "Schedule")).toBe( + `${window.location.origin}/ru-ru/schedule/SVO/SU0022-20260417/LED`, + ); + }); + + it("builds an airport-interleaved schedule URL for connecting itineraries", () => { + const flight = { + ...makeDirectFlight(), + id: "connecting", + routeType: "MultiLeg", + flightId: { carrier: "SU", flightNumber: "5752", suffix: "", date: "20260529" }, + legs: [ + makeLeg("VVO", "KJA", "2026-05-29T11:00:00"), + makeLeg("KJA", "MJZ", "2026-05-30T08:00:00"), + ], + _childFlightIds: [ + { carrier: "SU", flightNumber: "5752", suffix: "", date: "20260529" }, + { carrier: "SU", flightNumber: "6837", suffix: "", date: "20260530" }, + ], + } as unknown as ISimpleFlight; + + expect(buildFlightShareUrl(flight, "ru-ru", "Schedule")).toBe( + `${window.location.origin}/ru-ru/schedule/VVO/SU5752-20260529/KJA/SU6837-20260530/MJZ`, + ); + }); +}); diff --git a/src/features/online-board/components/BoardDetailsHeader/shareUrl.ts b/src/features/online-board/components/BoardDetailsHeader/shareUrl.ts new file mode 100644 index 00000000..8b9acc5f --- /dev/null +++ b/src/features/online-board/components/BoardDetailsHeader/shareUrl.ts @@ -0,0 +1,72 @@ +import { buildFlightUrlParams } from "../../url.js"; +import { getFlightSearchDate } from "../../flightSearchDate.js"; +import type { IFlightLeg, ISimpleFlight } from "../../types.js"; + +export type FlightShareViewType = "Onlineboard" | "Schedule"; + +function getOrigin(): string { + if (typeof window === "undefined") return ""; + return window.location.origin; +} + +function getLegs(flight: ISimpleFlight): IFlightLeg[] { + return flight.routeType === "Direct" ? [flight.leg] : flight.legs; +} + +function compactLocalDate(leg: IFlightLeg | undefined, fallback: string): string { + const local = leg?.departure.times.scheduledDeparture.local; + if (local) { + const date = local.slice(0, 10).replace(/-/g, ""); + if (/^\d{8}$/.test(date)) return date; + } + return fallback.replace(/-/g, ""); +} + +function buildScheduleSegment(flight: ISimpleFlight): string { + const legs = getLegs(flight); + const childIds = (flight as ISimpleFlight & { + _childFlightIds?: Array<{ + carrier: string; + flightNumber: string; + suffix?: string; + date?: string; + }>; + })._childFlightIds; + const ids = childIds && childIds.length > 0 ? childIds : [flight.flightId]; + const parts: string[] = []; + + ids.forEach((id, index) => { + const leg = legs[index] ?? legs[legs.length - 1]; + if (index === 0 && leg) parts.push(leg.departure.scheduled.airportCode); + parts.push( + buildFlightUrlParams({ + carrier: id.carrier, + flightNumber: id.flightNumber, + ...(id.suffix ? { suffix: id.suffix } : {}), + date: compactLocalDate(leg, id.date ?? flight.flightId.date), + }), + ); + if (leg) parts.push(leg.arrival.scheduled.airportCode); + }); + + return parts.join("/"); +} + +export function buildFlightShareUrl( + flight: ISimpleFlight, + locale: string, + viewType: FlightShareViewType = "Onlineboard", +): string { + const origin = getOrigin(); + + if (viewType === "Schedule") { + return `${origin}/${locale}/schedule/${buildScheduleSegment(flight)}`; + } + + return `${origin}/${locale}/onlineboard/${buildFlightUrlParams({ + carrier: flight.flightId.carrier, + flightNumber: flight.flightId.flightNumber, + ...(flight.flightId.suffix ? { suffix: flight.flightId.suffix } : {}), + date: getFlightSearchDate(flight), + })}`; +} diff --git a/src/features/online-board/components/OnlineBoardSearchPage.tsx b/src/features/online-board/components/OnlineBoardSearchPage.tsx index aa385522..50c55112 100644 --- a/src/features/online-board/components/OnlineBoardSearchPage.tsx +++ b/src/features/online-board/components/OnlineBoardSearchPage.tsx @@ -38,6 +38,7 @@ import { buildOnlineBoardUrl } from "../url.js"; import { buildFlightListJsonLd } from "../json-ld.js"; import { sortFlights } from "../sortFlights.js"; import { getFlightSearchDate } from "../flightSearchDate.js"; +import { buildFlightShareUrl } from "./BoardDetailsHeader/shareUrl.js"; import { PobedaAuroraBanner, shouldShowPobedaAuroraBanner, @@ -619,6 +620,7 @@ export const OnlineBoardSearchPage: FC = ({ onFlightClick={handleFlightClick} initialCurrentFlightId={initialCurrentFlightId} direction={params.type} + shareUrlFor={(flight) => buildFlightShareUrl(flight, locale)} renderActions={(flight) => ( // Mirrors Angular's board search expansion: each row // shows [Купить] [Онлайн регистрация] alongside the diff --git a/src/features/schedule/components/DayGroupedFlightList.tsx b/src/features/schedule/components/DayGroupedFlightList.tsx index 67cfc654..bdfcb30c 100644 --- a/src/features/schedule/components/DayGroupedFlightList.tsx +++ b/src/features/schedule/components/DayGroupedFlightList.tsx @@ -16,6 +16,7 @@ import { FlightList } from "@/ui/flights/FlightList.js"; import { FlightListSkeleton } from "@/ui/flights/FlightListSkeleton.js"; import { useLocale } from "@/i18n/useLocale.js"; import type { ISimpleFlight, IFlightLeg } from "@/features/online-board/types.js"; +import { buildFlightShareUrl } from "@/features/online-board/components/BoardDetailsHeader/shareUrl.js"; import { ScheduleFlightBody } from "./ScheduleFlightBody.js"; import type { ScheduleSortMode } from "./ScheduleColumnHeaders.js"; import "./DayGroupedFlightList.scss"; @@ -286,6 +287,7 @@ export const DayGroupedFlightList: FC = ({ loading={false} direction="schedule" renderExpandedBody={renderScheduleBody} + shareUrlFor={(flight) => buildFlightShareUrl(flight, locale, "Schedule")} {...(onFlightClick ? { onFlightClick } : {})} {...(buyUrlFor ? { buyUrlFor } : {})} {...(resolvedInitialFlightId @@ -362,6 +364,7 @@ export const DayGroupedFlightList: FC = ({ loading={false} direction="schedule" renderExpandedBody={renderScheduleBody} + shareUrlFor={(flight) => buildFlightShareUrl(flight, locale, "Schedule")} {...(onFlightClick ? { onFlightClick } : {})} {...(buyUrlFor ? { buyUrlFor } : {})} {...(resolvedInitialFlightId diff --git a/src/features/schedule/components/ScheduleDetailsPage.tsx b/src/features/schedule/components/ScheduleDetailsPage.tsx index 5548f06b..5b283718 100644 --- a/src/features/schedule/components/ScheduleDetailsPage.tsx +++ b/src/features/schedule/components/ScheduleDetailsPage.tsx @@ -414,11 +414,12 @@ export const ScheduleDetailsPage: FC = ({ - +
{miniListCurrentFlight.routeType !== "Direct" && ( diff --git a/src/features/schedule/components/ScheduleFlightBody.tsx b/src/features/schedule/components/ScheduleFlightBody.tsx index cced04a3..3303348a 100644 --- a/src/features/schedule/components/ScheduleFlightBody.tsx +++ b/src/features/schedule/components/ScheduleFlightBody.tsx @@ -397,6 +397,7 @@ export const ScheduleFlightBody: FC = ({ { expect(screen.getByTestId("flight-share-button")).toBeTruthy(); }); + it("4.1.13.4-R: share button copies caller-provided details URL", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + writable: true, + configurable: true, + }); + render( + {}} + shareUrl="https://example.test/ru-ru/onlineboard/SU0022-20260417" + />, + ); + + fireEvent.click(screen.getByTestId("flight-share-button")); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith( + "https://example.test/ru-ru/onlineboard/SU0022-20260417", + ); + }); + }); + it("4.1.13.4-R: Details button present in expanded actions row", () => { render( {}} />); expect(screen.getByTestId("flight-details-button")).toBeTruthy(); diff --git a/src/ui/flights/FlightCard.tsx b/src/ui/flights/FlightCard.tsx index 7f7d891d..14b25658 100644 --- a/src/ui/flights/FlightCard.tsx +++ b/src/ui/flights/FlightCard.tsx @@ -60,6 +60,8 @@ export interface FlightCardProps { * buttons Angular shows on the search results expansion. */ renderActions?: (flight: ISimpleFlight) => ReactNode; + /** Absolute URL copied/shared by the row share button. */ + shareUrl?: string; /** * Aeroflot booking URL for the per-row "Купить билет" hover link * (TIRREDESIGN-6 / TZ §4.1.14.4.4). Rendered inside the collapsed row @@ -161,6 +163,7 @@ export const FlightCard: FC = ({ direction = "route", renderExpandedBody, renderActions, + shareUrl, inlineBuyUrl, }) => { const { t } = useTranslation(); @@ -672,9 +675,9 @@ export const FlightCard: FC = ({ title={t("BOARD.SHARE")} onClick={(e) => { e.stopPropagation(); - const url = typeof window !== "undefined" + const url = shareUrl ?? (typeof window !== "undefined" ? window.location.href - : ""; + : ""); const navShare = ( typeof navigator !== "undefined" && (navigator as Navigator & { share?: (data: ShareData) => Promise }).share diff --git a/src/ui/flights/FlightList.tsx b/src/ui/flights/FlightList.tsx index 61a64198..8dc26b29 100644 --- a/src/ui/flights/FlightList.tsx +++ b/src/ui/flights/FlightList.tsx @@ -40,6 +40,11 @@ export interface FlightListProps { * Купить / Онлайн регистрация alongside Детали рейса. */ renderActions?: (flight: ISimpleFlight) => ReactNode; + /** + * Absolute URL for each row-level share button. Callers provide this so + * feature-specific route semantics stay outside shared card rendering. + */ + shareUrlFor?: (flight: ISimpleFlight) => string; /** * Aeroflot booking URL per flight for the hover-reveal inline * "Купить билет" link (TIRREDESIGN-6). Return null to hide. @@ -62,6 +67,7 @@ export const FlightList: FC = ({ direction = "route", renderExpandedBody, renderActions, + shareUrlFor, buyUrlFor, }) => { const { t } = useTranslation(); @@ -132,6 +138,10 @@ export const FlightList: FC = ({ : {})} {...(renderExpandedBody ? { renderExpandedBody } : {})} {...(renderActions ? { renderActions } : {})} + {...(() => { + const url = shareUrlFor?.(flight); + return url ? { shareUrl: url } : {}; + })()} {...(() => { const url = buyUrlFor?.(flight); return url ? { inlineBuyUrl: url } : {}; diff --git a/tests/e2e/share-button-parity.spec.ts b/tests/e2e/share-button-parity.spec.ts new file mode 100644 index 00000000..e3ebf4ed --- /dev/null +++ b/tests/e2e/share-button-parity.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from "./fixtures/console-gate"; +import { routeScheduleVvoMjzFixtures } from "./helpers/api-fixtures"; +import { vvoMjzDetailsUrl } from "./helpers/dates"; + +test("TIRREDESIGN-32: schedule share button uses clean details URL", async ({ + page, + consoleMessages, +}) => { + const detailsUrl = vvoMjzDetailsUrl(); + const cleanPath = detailsUrl.split("?")[0] ?? detailsUrl; + + await routeScheduleVvoMjzFixtures(page); + await page.goto(detailsUrl); + await expect(page.getByTestId("schedule-details")).toBeVisible({ + timeout: 15000, + }); + + await page.getByTestId("share-button").first().click(); + const vk = page.getByTestId("share-vk"); + await expect(vk).toBeVisible(); + + const href = await vk.getAttribute("href"); + expect(href).toBeTruthy(); + const decoded = decodeURIComponent(href ?? ""); + expect(decoded).toContain(`${new URL(page.url()).origin}${cleanPath}`); + expect(decoded).not.toContain("request=schedule-route"); +}); + +test("TIRREDESIGN-32: China locale shows Weibo-only share panel", async ({ + page, + consoleMessages, +}) => { + const detailsUrl = vvoMjzDetailsUrl().replace("/ru-ru/", "/cn-zh/"); + + await routeScheduleVvoMjzFixtures(page); + await page.goto(detailsUrl); + await expect(page.getByTestId("schedule-details")).toBeVisible({ + timeout: 15000, + }); + + await page.getByTestId("share-button").first().click(); + await expect(page.getByTestId("share-weibo")).toBeVisible(); + await expect(page.getByTestId("share-facebook")).toHaveCount(0); + await expect(page.getByTestId("share-vk")).toHaveCount(0); + await expect(page.getByTestId("share-twitter")).toHaveCount(0); +});