diff --git a/src/features/schedule/components/DayGroupedFlightList.test.tsx b/src/features/schedule/components/DayGroupedFlightList.test.tsx index 23d54468..c54a77bd 100644 --- a/src/features/schedule/components/DayGroupedFlightList.test.tsx +++ b/src/features/schedule/components/DayGroupedFlightList.test.tsx @@ -29,7 +29,12 @@ vi.mock("@/shared/hooks/useDictionaries.js", () => ({ }, })); vi.mock("./ScheduleFlightBody.js", () => ({ - ScheduleFlightBody: () =>
, + ScheduleFlightBody: ({ showActions }: { showActions?: boolean }) => ( +
+ ), })); vi.mock("@/ui/flights/IFlyWarning.js", () => ({ IFlyWarning: () => null })); vi.mock("@/features/online-board/components/BoardDetailsHeader/FlightEvents.js", () => ({ @@ -240,6 +245,39 @@ describe("§4.1.14.3 Table 38 — Connecting flight collapsed row", () => { }); }); +// --------------------------------------------------------------------------- +// Buy/share/status action strip — Angular's `` +// renders inside each expanded flight body on the search results page. +// DayGroupedFlightList must pass `showActions={true}` so the strip appears. +// --------------------------------------------------------------------------- + +describe("schedule search results body — action strip", () => { + // Same-day flights so DayGroupedFlightList takes the single-group + // FlightList path (renderExpandedBody flows directly to FlightCard). + const a = makeDirectFlight({ + depLocal: "2026-05-01T08:00:00+03:00", + arrLocal: "2026-05-01T10:30:00+03:00", + }); + const b = makeDirectFlight({ + depLocal: "2026-05-01T14:00:00+03:00", + arrLocal: "2026-05-01T16:30:00+03:00", + }); + const flights = [ + { ...a, id: "f-a" }, + { ...b, id: "f-b" }, + ] as ISimpleFlight[]; + + it("renders the schedule body with showActions=true so the buy/share strip surfaces", () => { + render(); + // Expand the first card so the body mock renders. + const row = document.querySelector(".flight-card__row") as HTMLElement; + fireEvent.click(row); + const body = screen.getAllByTestId("schedule-flight-body")[0]; + expect(body).toBeTruthy(); + expect(body?.getAttribute("data-show-actions")).toBe("true"); + }); +}); + // --------------------------------------------------------------------------- // §4.1.14.3 — DayGroupedFlightList grouping + day headers // --------------------------------------------------------------------------- diff --git a/src/features/schedule/components/DayGroupedFlightList.tsx b/src/features/schedule/components/DayGroupedFlightList.tsx index 52cf7e0a..a245dc51 100644 --- a/src/features/schedule/components/DayGroupedFlightList.tsx +++ b/src/features/schedule/components/DayGroupedFlightList.tsx @@ -134,7 +134,7 @@ export const DayGroupedFlightList: FC = ({ onSortChange, hideColumnHeaders, }) => { - const { language } = useLocale(); + const { locale, language } = useLocale(); const { t } = useTranslation(); const [internalSortMode, setInternalSortMode] = useState("none"); const sortMode = sortModeProp ?? internalSortMode; @@ -246,20 +246,15 @@ export const DayGroupedFlightList: FC = ({ ); // The schedule expanded body — a per-leg route diagram with transfer - // boxes — replaces the default time/transition rows. Buy/Status - // buttons live inside this body (only place Angular renders them). + // boxes — replaces the default time/transition rows. Mirrors Angular's + // `` strip (share + buy + register + status) + // via `showActions`. The collapsed-row inline buy link wired through + // FlightList's `buyUrlFor` prop is independent of this strip. const renderScheduleBody = useCallback( - (f: ISimpleFlight) => { - const buyUrl = buyUrlFor?.(f) ?? null; - return ( - onFlightClick(f) } : {})} - {...(buyUrl ? { buyUrl } : {})} - /> - ); - }, - [onFlightClick, buyUrlFor], + (f: ISimpleFlight) => ( + + ), + [locale], ); if (loading) return ; diff --git a/src/features/schedule/components/ScheduleFlightBody.scss b/src/features/schedule/components/ScheduleFlightBody.scss index e6536984..1555e624 100644 --- a/src/features/schedule/components/ScheduleFlightBody.scss +++ b/src/features/schedule/components/ScheduleFlightBody.scss @@ -163,10 +163,17 @@ &__actions { display: flex; align-items: center; + justify-content: flex-end; gap: vars.$space-m; padding: vars.$space-l vars.$space-xl; border-top: 1px dashed colors.$border; background: colors.$white; + + .flight-actions { + display: flex; + align-items: center; + gap: vars.$space-m; + } } &__spacer { diff --git a/src/features/schedule/components/ScheduleFlightBody.test.tsx b/src/features/schedule/components/ScheduleFlightBody.test.tsx index b36dc13e..93fd83a4 100644 --- a/src/features/schedule/components/ScheduleFlightBody.test.tsx +++ b/src/features/schedule/components/ScheduleFlightBody.test.tsx @@ -39,6 +39,12 @@ vi.mock("@/ui/flights/OperatorLogo.js", () => ({ {carrier} ), })); +vi.mock( + "@/features/online-board/components/BoardDetailsHeader/FlightActions.js", + () => ({ + FlightActions: () =>
, + }), +); import { ScheduleFlightBody } from "./ScheduleFlightBody.js"; @@ -197,9 +203,27 @@ describe("ScheduleFlightBody – TZ §4.1.14.4", () => { expect(stations.some((s) => s.textContent === "LED")).toBe(true); }); - // Share/Buy/Status no longer render inside the per-leg body — they - // live in the page-level summary header (mirroring Angular's - // `flight-schedule-details [share]=false [buy]=false [print]=false`). + it("does NOT render the action strip when showActions is omitted", () => { + // Schedule details page omits `showActions` because its page-level + // summary header owns share/buy. Mirrors Angular's + // `flight-schedule-details [share]=false [buy]=false [print]=false`. + render(); + expect(screen.queryByTestId("schedule-flight-body-actions")).toBeNull(); + }); + + it("renders the action strip when showActions is true", () => { + // Search results page opts in to mirror Angular's + // `` → `` (defaults all + // true except print). + render( + , + ); + expect(screen.getByTestId("schedule-flight-body-actions")).toBeTruthy(); + expect(screen.getByTestId("flight-actions")).toBeTruthy(); + }); }); describe("Multi-leg flight structure", () => { diff --git a/src/features/schedule/components/ScheduleFlightBody.tsx b/src/features/schedule/components/ScheduleFlightBody.tsx index d09c76ec..28fd5cfb 100644 --- a/src/features/schedule/components/ScheduleFlightBody.tsx +++ b/src/features/schedule/components/ScheduleFlightBody.tsx @@ -29,10 +29,21 @@ import { resolveCarrierByFlightNumber } from "@/shared/operatorIcon.js"; import { TimeGroup } from "@/ui/flights/TimeGroup.js"; import { StationDisplay } from "@/ui/flights/StationDisplay.js"; import { OperatorLogo } from "@/ui/flights/OperatorLogo.js"; +import { FlightActions } from "@/features/online-board/components/BoardDetailsHeader/FlightActions.js"; import "./ScheduleFlightBody.scss"; export interface ScheduleFlightBodyProps { flight: ISimpleFlight; + /** + * Render the share/buy/register/status action strip at the bottom of + * the body — mirrors Angular's `flight-details-body-actions` on the + * schedule search results page. Off by default so the schedule + * details page (which owns its own page-level summary actions) stays + * unchanged. + */ + showActions?: boolean; + /** Locale used to build the buy-ticket URL when `showActions` is true. */ + locale?: string; } interface ChildFlightId { @@ -81,14 +92,10 @@ function transferDuration(prev: IFlightLeg, next: IFlightLeg): string { return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:00`; } -// ── TZ §4.1.14.4.4 – Buy-button visibility gate ───────────────────────────── -// Visible when: -// • Departure UTC is > 2 h from now (not about to depart / already departed) -// • Departure UTC is < 330 days from now -// Eligibility is assessed on the FIRST leg of the flight (TZ §4.1.14.4.4: -// "рассчитывается исходя из времени первого сегмента"). export const ScheduleFlightBody: FC = ({ flight, + showActions = false, + locale = "ru-ru", }) => { const { t } = useTranslation(); const { language } = useLocale(); @@ -367,12 +374,27 @@ export const ScheduleFlightBody: FC = ({ ); })} - {/* Angular's `flight-schedule-details` renders `flight-actions` - with `share=false buy=false print=false details=false - register=false`, so the per-leg body has no action strip — - share + buy live in the page-level summary header instead. - Only the Status button can surface here, and only when the - enclosing page is a connecting itinerary (`connected=true`). */} + {/* Angular search-results expanded body renders + `` → `` with all + defaults (share+buy+register+status, no print). The schedule + details page suppresses these (page-level summary owns them), + so callers opt in via `showActions`. Buy/Status visibility is + gated by `` (TZ §4.1.14.4.4 / §4.1.14.4.5). */} + {showActions && ( +
+ +
+ )}
); }; diff --git a/tests/e2e/schedule-route-buy-button.spec.ts b/tests/e2e/schedule-route-buy-button.spec.ts new file mode 100644 index 00000000..097898cb --- /dev/null +++ b/tests/e2e/schedule-route-buy-button.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from "./fixtures/console-gate"; + +// Schedule search-results page must mirror Angular's +// `` strip in each expanded flight body — +// that's where Angular surfaces the buy ticket button (TZ §4.1.14.4.4). +// The component had been silently dropped during a refactor that confused +// the search-results page with the dedicated `flight-schedule-details` +// page (which DOES suppress buy/share because its page-level summary +// owns those affordances). + +test("schedule route page surfaces the buy ticket button inside an expanded flight body", async ({ + page, +}) => { + await page.goto("/ru-ru/schedule/route/MOW-MMK-20260427-20260503"); + + // Wait for flights to render. The schedule list builds rows of + // .flight-card items inside DayGroupedFlightList. + const firstCard = page.locator(".flight-card").first(); + await expect(firstCard).toBeVisible({ timeout: 30000 }); + + // Expand the first flight by clicking its row. FlightCard wires + // expandable=true on the schedule path so a click toggles the body. + await firstCard.click(); + + // The action strip (`schedule-flight-body-actions`) wraps the + // restored `` row. + const actions = page + .locator('[data-testid="schedule-flight-body-actions"]') + .first(); + await expect(actions).toBeVisible({ timeout: 10000 }); + + // The buy ticket button (FlightActions → BuyTicketButton) must be + // present whenever the flight is in the [now+2h, now+330d] window. + // Today + few days is squarely inside that window for typical flights. + await expect( + actions.locator('[data-testid="buy-ticket-button"]'), + ).toBeVisible(); +});