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);
+});