From b1baf3baa9a6bbe2e77a53a342aab5152e7bafe6 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 22 Sep 2026 09:56:13 +0200 Subject: [PATCH] fix(mobile): reservation-detail widgets mount on the phone's booking cards A plugin declaring the reservation-detail slot mounted at the foot of every card in the desktop ReservationsPanel and nowhere else: the phone's transport and booking cards never rendered the slot, so on a phone in portrait the reservation was there and the widget was not, and turning the phone sideways made it appear. ReservationPluginSlots mounts one PluginFrame per widget under each card, scoped to that reservation with surface detail-slot, outside the card's body button and gone in compact mode. Closes #2440 --- .../mobile/screens/trip/tabs/MBookingsTab.tsx | 3 +- .../screens/trip/tabs/MTransportsTab.tsx | 3 +- .../mobile/screens/trip/tabs/tabChrome.tsx | 32 ++++++++++++++ .../unit/mobile/trip/MBookingsTab.test.tsx | 38 +++++++++++++++- .../unit/mobile/trip/MTransportsTab.test.tsx | 44 ++++++++++++++++++- wiki/Plugin-Development.md | 3 +- 6 files changed, 118 insertions(+), 5 deletions(-) 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