From c0b691b8e5ad3b7fb96f3b4aa30291aa805c9745 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 22 Sep 2026 09:56:13 +0200 Subject: [PATCH 1/2] 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 | 4 +- .../screens/trip/tabs/MTransportsTab.tsx | 4 +- .../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, 120 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..628fc4e85 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' @@ -262,6 +262,8 @@ function BookingCard({ res, planner, canEdit, compact }: { )} + {!compact && } + setConfirmDelete(false)} diff --git a/client/src/mobile/screens/trip/tabs/MTransportsTab.tsx b/client/src/mobile/screens/trip/tabs/MTransportsTab.tsx index 1a47bedcf..26444ce11 100644 --- a/client/src/mobile/screens/trip/tabs/MTransportsTab.tsx +++ b/client/src/mobile/screens/trip/tabs/MTransportsTab.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 { TRANSPORT_TYPE_COLOR, @@ -280,6 +280,8 @@ function TransportCard({ res, planner, shell, canEdit, compact }: { )} + {!compact && } + setConfirmDelete(false)} diff --git a/client/src/mobile/screens/trip/tabs/tabChrome.tsx b/client/src/mobile/screens/trip/tabs/tabChrome.tsx index 5e1891f86..a69af806d 100644 --- a/client/src/mobile/screens/trip/tabs/tabChrome.tsx +++ b/client/src/mobile/screens/trip/tabs/tabChrome.tsx @@ -3,6 +3,8 @@ import { ChevronDown, ChevronUp } from 'lucide-react' import type { ReservationTraveler } from '@trek/shared' import { avatarSrc } from '../../../../utils/avatarSrc' import GuestBadge from '../../../../components/shared/GuestBadge' +import PluginFrame from '../../../../components/Plugins/PluginFrame' +import { usePluginStore } from '../../../../store/pluginStore' /** * Scroll body shared by the list-style trip tabs (transports, bookings, costs, @@ -120,6 +122,36 @@ export function StatusDot({ color }: { color: string }) { return } +/** + * 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 From 8936049a818c4ce23fe75a83d4384032bbdc13da Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 22 Sep 2026 09:56:13 +0200 Subject: [PATCH 2/2] fix(client): take the reported version as one anchored match before storing it The dev branch gate on SonarCloud has been red since the 4.3.0 cycle on this one line: the version from /api/config is written to localStorage, and the taint engine did not read the replace() plus equality check as sanitisation (S8475). The value is the match of ^[\w.+-]{1,64}$ now, the same characters and length as before, in the shape the engine recognises. --- client/src/App.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 9d27ac6d1..e4c72ab8e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -320,14 +320,13 @@ export default function App() { if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions) // A version is a short release tag and nothing else. It arrives over the - // wire and is written to this device's storage, so it is rebuilt from the - // characters a tag may contain and kept only when nothing had to be - // stripped. Anything else is ignored, which also keeps a malformed value - // from being compared against the stored marker and starting an update on - // every launch. - const reportedVersion = typeof config?.version === 'string' ? config.version : '' - const version = reportedVersion.replace(/[^\w.+-]/g, '').slice(0, 64) - if (version && version === reportedVersion) { + // wire and is written to this device's storage, so only a value made of + // the characters a tag may contain is taken, as the match itself. Anything + // else is ignored, which also keeps a malformed value from being compared + // against the stored marker and starting an update on every launch. + const releaseTag = /^[\w.+-]{1,64}$/.exec(typeof config?.version === 'string' ? config.version : '') + if (releaseTag) { + const version = releaseTag[0] const storedVersion = localStorage.getItem('trek_app_version') // Record the version BEFORE acting on it. The old code wrote the marker // after the purge and outside its try, so a throwing setItem (private