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