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