diff --git a/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx b/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx index ff2a2617c..f84175274 100644 --- a/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx @@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload' import { useTranslation } from '../../../../i18n' import type { Reservation } from '../../../../types' import MConfirmSheet from '../../settings/MConfirmSheet' -import { ConfirmationCode, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome' +import { ConfirmationCode, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome' import { STATUS_COLOR, type MTabScreenProps } from './tabModel' import { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel' import { BOOKING_TYPE_COLOR } from './bookingsModel' @@ -261,6 +261,7 @@ function BookingCard({ res, planner, canEdit, compact }: { )} + {!compact && } )} + {!compact && } } +/** + * Plugins that declared a reservation-detail slot, mounted at the foot of a + * transport / booking card and scoped to that reservation: the phone half of + * what ReservationsPanel does on the desktop, which the phone cards never had, + * so a widget like Flight Tracker only appeared once the viewport crossed the + * desktop breakpoint (#2440). Sits outside the card's body button (buttons + * cannot nest, and a tap in the frame must not open the detail sheet), and the + * frame follows the host colour scheme like the desktop detail slots do, so a + * kit plugin that goes dark with TREK keeps compositing transparently. + */ +export function ReservationPluginSlots({ tripId, reservationId }: { tripId: number; reservationId: number }) { + const plugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget' && p.slot === 'reservation-detail') + if (plugins.length === 0) return null + return ( +
+ {plugins.map(p => ( +
+ +
+ ))} +
+ ) +} + /** * Read-only avatar-chip cluster for a reservation's assigned travelers (#1517). * The mobile transport / booking cards only display them — assignment editing diff --git a/client/tests/unit/mobile/trip/MBookingsTab.test.tsx b/client/tests/unit/mobile/trip/MBookingsTab.test.tsx index bb38efa03..2179c5769 100644 --- a/client/tests/unit/mobile/trip/MBookingsTab.test.tsx +++ b/client/tests/unit/mobile/trip/MBookingsTab.test.tsx @@ -3,17 +3,27 @@ import MBookingsTab from '../../../../src/mobile/screens/trip/tabs/MBookingsTab' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import { openFile } from '../../../../src/utils/fileDownload' import type { Day, Reservation, TripFile } from '../../../../src/types' +import { usePluginStore } from '../../../../src/store/pluginStore' import { buildSettings } from '../../../helpers/factories' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' +import { seedStore } from '../../../helpers/store' -// FE-MOB-BKTAB-001 to FE-MOB-BKTAB-018 +// FE-MOB-BKTAB-001 to FE-MOB-BKTAB-023 vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({ ...(await importOriginal()), openFile: vi.fn(), })) +vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({ + default: ({ pluginId, tripId, reservationId, surface }: { pluginId: string; tripId: string | null; reservationId?: string | null; surface?: string }) => ( +
+ ), +})) + +const SEAT_MAP = { id: 'seat-map', name: 'Seat Map', type: 'widget', icon: null, slot: 'reservation-detail' } as const + const DAYS = [ { id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null }, { id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' }, @@ -93,6 +103,7 @@ function cardOf(title: string): HTMLElement { describe('MBookingsTab', () => { beforeEach(() => { vi.mocked(openFile).mockClear() + seedStore(usePluginStore, { plugins: [] }) }) it('FE-MOB-BKTAB-001: keeps only the non-transport reservations, split by status', () => { @@ -285,4 +296,29 @@ describe('MBookingsTab', () => { // the check-in cell and the time field both read 09:00 expect(within(card).getAllByText('09:00')).toHaveLength(2) }) + + it('FE-MOB-BKTAB-022: mounts a reservation-detail plugin on every booking card, scoped to that booking (#2440)', () => { + seedStore(usePluginStore, { + plugins: [SEAT_MAP, { id: 'dash-widget', name: 'Dash', type: 'widget', icon: null, slot: 'sidebar' }], + }) + const { planner: p } = renderTab() + const frame = within(cardOf('Hotel Granvia')).getByTestId('plugin-frame') + expect(frame).toHaveAttribute('data-plugin', 'seat-map') + expect(frame).toHaveAttribute('data-trip', '7') + expect(frame).toHaveAttribute('data-reservation', '201') + expect(frame).toHaveAttribute('data-surface', 'detail-slot') + // the slot sits outside the card's body button: a tap in it opens no editor + fireEvent.click(frame) + expect(p.setShowReservationModal).not.toHaveBeenCalled() + expect(within(cardOf('Bamboo walk')).getByTestId('plugin-frame')).toHaveAttribute('data-reservation', '204') + expect(screen.getAllByTestId('plugin-frame')).toHaveLength(4) + expect(document.querySelector('[data-plugin="dash-widget"]')).toBeNull() + }) + + it('FE-MOB-BKTAB-023: compact mode drops the plugin frames with the rest of the body', () => { + seedStore(usePluginStore, { plugins: [SEAT_MAP] }) + renderTab(planner(), buildShell({ bookingsCompact: true })) + expect(screen.getByText('Hotel Granvia')).toBeInTheDocument() + expect(screen.queryByTestId('plugin-frame')).not.toBeInTheDocument() + }) }) diff --git a/client/tests/unit/mobile/trip/MTransportsTab.test.tsx b/client/tests/unit/mobile/trip/MTransportsTab.test.tsx index 17e5d078d..b0e2c4d85 100644 --- a/client/tests/unit/mobile/trip/MTransportsTab.test.tsx +++ b/client/tests/unit/mobile/trip/MTransportsTab.test.tsx @@ -3,17 +3,27 @@ import MTransportsTab from '../../../../src/mobile/screens/trip/tabs/MTransports import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import { openFile } from '../../../../src/utils/fileDownload' import type { Day, Reservation, TripFile } from '../../../../src/types' +import { usePluginStore } from '../../../../src/store/pluginStore' import { buildSettings } from '../../../helpers/factories' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' +import { seedStore } from '../../../helpers/store' -// FE-MOB-TRTAB-001 to FE-MOB-TRTAB-022 +// FE-MOB-TRTAB-001 to FE-MOB-TRTAB-028 vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({ ...(await importOriginal()), openFile: vi.fn(), })) +vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({ + default: ({ pluginId, tripId, reservationId, surface }: { pluginId: string; tripId: string | null; reservationId?: string | null; surface?: string }) => ( +
+ ), +})) + +const FLIGHT_TRACKER = { id: 'flight-tracker', name: 'Flight Tracker', type: 'widget', icon: null, slot: 'reservation-detail' } as const + const DAYS = [ { id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null }, { id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: 'Kyoto' }, @@ -101,6 +111,7 @@ function cardOf(title: string): HTMLElement { describe('MTransportsTab', () => { beforeEach(() => { vi.mocked(openFile).mockClear() + seedStore(usePluginStore, { plugins: [] }) }) it('FE-MOB-TRTAB-001: groups the transport reservations and leaves bookings out', () => { @@ -322,4 +333,35 @@ describe('MTransportsTab', () => { // nobody is assigned, so the traveler filter row stays away expect(screen.queryByTitle('Ada')).not.toBeInTheDocument() }) + + it('FE-MOB-TRTAB-027: mounts a reservation-detail plugin on every card, scoped to that reservation (#2440)', () => { + seedStore(usePluginStore, { + plugins: [ + FLIGHT_TRACKER, + { id: 'dash-widget', name: 'Dash', type: 'widget', icon: null, slot: 'hero' }, + { id: 'seat-map', name: 'Seat Map', type: 'widget', icon: null, slot: 'place-detail' }, + ], + }) + const { shell } = renderTab() + const frame = within(cardOf('HND to ITM')).getByTestId('plugin-frame') + expect(frame).toHaveAttribute('data-plugin', 'flight-tracker') + expect(frame).toHaveAttribute('data-trip', '7') + expect(frame).toHaveAttribute('data-reservation', '101') + expect(frame).toHaveAttribute('data-surface', 'detail-slot') + // the slot sits outside the card's body button: a tap in it opens no sheet + fireEvent.click(frame) + expect(shell.openSheet).not.toHaveBeenCalled() + expect(within(cardOf('Metro to hotel')).getByTestId('plugin-frame')).toHaveAttribute('data-reservation', '104') + // one frame per transport card, and only for the reservation-detail slot + expect(screen.getAllByTestId('plugin-frame')).toHaveLength(5) + expect(document.querySelector('[data-plugin="dash-widget"]')).toBeNull() + expect(document.querySelector('[data-plugin="seat-map"]')).toBeNull() + }) + + it('FE-MOB-TRTAB-028: compact mode drops the plugin frames with the rest of the body', () => { + seedStore(usePluginStore, { plugins: [FLIGHT_TRACKER] }) + renderTab(planner(), buildShell({ transportsCompact: true })) + expect(screen.getByText('HND to ITM')).toBeInTheDocument() + expect(screen.queryByTestId('plugin-frame')).not.toBeInTheDocument() + }) }) diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md index 47289490f..ac73d38f5 100644 --- a/wiki/Plugin-Development.md +++ b/wiki/Plugin-Development.md @@ -152,7 +152,8 @@ permissions still requires explicit re-consent). the home for per-day content like outfit planning, live flight status or logistics), or a panel at the foot of a booking card in the **reservation-detail** view (`reservation-detail` slot — receives the open `reservationId`, for things like - live check-in status or a seat map). Set the slot in `capabilities.widget.slot`. + live check-in status or a seat map; shown on the desktop panel and on the phone's + transport and booking cards). Set the slot in `capabilities.widget.slot`. - **trip-page** — adds a tab **inside every trip planner**, so your UI lives in the trip alongside Plan / Transports / Files. The frame is the same sandboxed iframe as a `page`, but it receives the current `tripId` in `trek:context` (so you can scope