Fix share button URL parity
This commit is contained in:
@@ -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(<FlightActions flight={makeFlight()} locale="ru" />);
|
||||
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(<FlightActions flight={makeFlight()} locale="ru-ru" viewType="Schedule" />);
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<FlightActionsProps> = ({
|
||||
@@ -38,6 +40,7 @@ export const FlightActions: FC<FlightActionsProps> = ({
|
||||
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<FlightActionsProps> = ({
|
||||
showStatus &&
|
||||
canViewFlightStatus(flight, now, flightStatusAvailableFromHours, AIRLINES_WITH_STATUS);
|
||||
|
||||
const shareUrl = typeof window !== "undefined" ? window.location.href : "";
|
||||
const shareUrl = buildFlightShareUrl(flight, locale, viewType);
|
||||
|
||||
return (
|
||||
<div className="flight-actions" data-testid="flight-actions">
|
||||
|
||||
@@ -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<LastUpdateProps> = ({ flight, locale }) => {
|
||||
export const LastUpdate: FC<LastUpdateProps> = ({ flight, locale, viewType = "Onlineboard" }) => {
|
||||
const { t } = useTranslation();
|
||||
const [loadedAt, setLoadedAt] = useState<Date>(() => new Date());
|
||||
const seenFlightIdRef = useRef<string | null>(null);
|
||||
@@ -34,7 +36,7 @@ export const LastUpdate: FC<LastUpdateProps> = ({ flight, locale }) => {
|
||||
}, [flight.id]);
|
||||
|
||||
const timestamp = formatStamp(loadedAt);
|
||||
const shareUrl = typeof window !== "undefined" ? window.location.href : "";
|
||||
const shareUrl = buildFlightShareUrl(flight, locale, viewType);
|
||||
|
||||
return (
|
||||
<div className="last-update">
|
||||
|
||||
@@ -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(<SharePanel url="https://example.com/flight" locale="zh" onClose={() => {}} />);
|
||||
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(<SharePanel url="https://example.com/flight" locale="cn-en" onClose={() => {}} />);
|
||||
expect(screen.queryByTestId("share-facebook")).toBeNull();
|
||||
expect(screen.queryByTestId("share-vk")).toBeNull();
|
||||
expect(screen.queryByTestId("share-twitter")).toBeNull();
|
||||
expect(screen.getByTestId("share-weibo")).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<SharePanelProps> = ({ 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<SharePanelProps> = ({ url, locale, onClose }) => {
|
||||
return (
|
||||
<div className="share-panel" data-testid="share-panel" role="dialog" aria-label={t("BOARD.SHARE")}>
|
||||
<div className="share-elements">
|
||||
<div>
|
||||
<a
|
||||
className="share-element facebook"
|
||||
data-testid="share-facebook"
|
||||
href={`https://www.facebook.com/sharer/sharer.php?u=${encoded}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("SHARE.FACEBOOK")}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
className="share-element vk"
|
||||
data-testid="share-vk"
|
||||
href={`https://vk.com/share.php?url=${encoded}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("SHARE.VK")}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
className="share-element twitter"
|
||||
data-testid="share-twitter"
|
||||
href={`https://twitter.com/share?text=${encodeURIComponent("My Flight")}&url=${encoded}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("SHARE.TWITTER")}
|
||||
</a>
|
||||
</div>
|
||||
{locale === "zh" && (
|
||||
{!chinaLocale && (
|
||||
<>
|
||||
<div>
|
||||
<a
|
||||
className="share-element facebook"
|
||||
data-testid="share-facebook"
|
||||
href={`https://www.facebook.com/sharer/sharer.php?u=${encoded}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("SHARE.FACEBOOK")}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
className="share-element vk"
|
||||
data-testid="share-vk"
|
||||
href={`https://vk.com/share.php?url=${encoded}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("SHARE.VK")}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
className="share-element twitter"
|
||||
data-testid="share-twitter"
|
||||
href={`https://twitter.com/share?text=${encodeURIComponent("My Flight")}&url=${encoded}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("SHARE.TWITTER")}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{chinaLocale && (
|
||||
<div>
|
||||
<a
|
||||
className="share-element weibo"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { IFlightLeg, ISimpleFlight } from "../../types.js";
|
||||
import { buildFlightShareUrl } from "./shareUrl.js";
|
||||
|
||||
function makeLeg(
|
||||
departure: string,
|
||||
arrival: string,
|
||||
local: string,
|
||||
): IFlightLeg {
|
||||
return {
|
||||
arrival: {
|
||||
scheduled: { airport: "", airportCode: arrival, city: "", cityCode: "", countryCode: "" },
|
||||
times: {
|
||||
scheduledArrival: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local: "",
|
||||
localTime: "",
|
||||
tzOffset: 0,
|
||||
utc: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
dayChange: 0,
|
||||
departure: {
|
||||
scheduled: { airport: "", airportCode: departure, city: "", cityCode: "", countryCode: "" },
|
||||
checkingStatus: "Scheduled",
|
||||
times: {
|
||||
scheduledDeparture: {
|
||||
dayChange: { value: 0, title: "" },
|
||||
local,
|
||||
localTime: "",
|
||||
tzOffset: 0,
|
||||
utc: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
equipment: {},
|
||||
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
|
||||
flyingTime: "1h",
|
||||
index: 0,
|
||||
operatingBy: {},
|
||||
status: "Scheduled",
|
||||
updated: "",
|
||||
} as IFlightLeg;
|
||||
}
|
||||
|
||||
function makeDirectFlight(): ISimpleFlight {
|
||||
return {
|
||||
id: "direct",
|
||||
routeType: "Direct",
|
||||
flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260416" },
|
||||
flyingTime: "1h",
|
||||
operatingBy: {},
|
||||
status: "Scheduled",
|
||||
leg: makeLeg("SVO", "LED", "2026-04-17T10:00:00"),
|
||||
} as ISimpleFlight;
|
||||
}
|
||||
|
||||
describe("buildFlightShareUrl", () => {
|
||||
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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
})}`;
|
||||
}
|
||||
@@ -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<OnlineBoardSearchPageProps> = ({
|
||||
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
|
||||
|
||||
@@ -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<DayGroupedFlightListProps> = ({
|
||||
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<DayGroupedFlightListProps> = ({
|
||||
loading={false}
|
||||
direction="schedule"
|
||||
renderExpandedBody={renderScheduleBody}
|
||||
shareUrlFor={(flight) => buildFlightShareUrl(flight, locale, "Schedule")}
|
||||
{...(onFlightClick ? { onFlightClick } : {})}
|
||||
{...(buyUrlFor ? { buyUrlFor } : {})}
|
||||
{...(resolvedInitialFlightId
|
||||
|
||||
@@ -414,11 +414,12 @@ export const ScheduleDetailsPage: FC<ScheduleDetailsPageProps> = ({
|
||||
<FlightActions
|
||||
flight={miniListCurrentFlight}
|
||||
locale={locale}
|
||||
viewType="Schedule"
|
||||
showStatus={miniListCurrentFlight.routeType === "Direct"}
|
||||
showRegister={false}
|
||||
forceBuy
|
||||
/>
|
||||
<LastUpdate flight={miniListCurrentFlight} locale={locale} />
|
||||
<LastUpdate flight={miniListCurrentFlight} locale={locale} viewType="Schedule" />
|
||||
</div>
|
||||
</div>
|
||||
{miniListCurrentFlight.routeType !== "Direct" && (
|
||||
|
||||
@@ -397,6 +397,7 @@ export const ScheduleFlightBody: FC<ScheduleFlightBodyProps> = ({
|
||||
<FlightActions
|
||||
flight={flight}
|
||||
locale={locale}
|
||||
viewType="Schedule"
|
||||
showShare
|
||||
showBuy
|
||||
showRegister
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* and is labelled with the table + column reference for traceability.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { FlightCard } from "./FlightCard.js";
|
||||
import type { ISimpleFlight } from "@/features/online-board/types.js";
|
||||
|
||||
@@ -628,6 +628,32 @@ describe("4.1.13.4 — Expanded row content (TZ §4.1.13.4.3)", () => {
|
||||
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(
|
||||
<FlightCard
|
||||
flight={makeFlight({})}
|
||||
expandable
|
||||
initialExpanded
|
||||
onViewDetails={() => {}}
|
||||
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(<FlightCard flight={makeFlight({})} expandable initialExpanded onViewDetails={() => {}} />);
|
||||
expect(screen.getByTestId("flight-details-button")).toBeTruthy();
|
||||
|
||||
@@ -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<FlightCardProps> = ({
|
||||
direction = "route",
|
||||
renderExpandedBody,
|
||||
renderActions,
|
||||
shareUrl,
|
||||
inlineBuyUrl,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -672,9 +675,9 @@ export const FlightCard: FC<FlightCardProps> = ({
|
||||
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<void> }).share
|
||||
|
||||
@@ -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<FlightListProps> = ({
|
||||
direction = "route",
|
||||
renderExpandedBody,
|
||||
renderActions,
|
||||
shareUrlFor,
|
||||
buyUrlFor,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -132,6 +138,10 @@ export const FlightList: FC<FlightListProps> = ({
|
||||
: {})}
|
||||
{...(renderExpandedBody ? { renderExpandedBody } : {})}
|
||||
{...(renderActions ? { renderActions } : {})}
|
||||
{...(() => {
|
||||
const url = shareUrlFor?.(flight);
|
||||
return url ? { shareUrl: url } : {};
|
||||
})()}
|
||||
{...(() => {
|
||||
const url = buyUrlFor?.(flight);
|
||||
return url ? { inlineBuyUrl: url } : {};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user