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 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 25075109b..984356d1f 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