Merge branches 'fix/mcp-trip-default-currency' and 'fix/mobile-reservation-detail-slot' into test/dev1-mcp-mobile

This commit is contained in:
Maurice
2026-09-22 09:59:05 +02:00
7 changed files with 127 additions and 13 deletions
+7 -8
View File
@@ -320,14 +320,13 @@ export default function App() {
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions) if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
// A version is a short release tag and nothing else. It arrives over the // 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 // wire and is written to this device's storage, so only a value made of
// characters a tag may contain and kept only when nothing had to be // the characters a tag may contain is taken, as the match itself. Anything
// stripped. Anything else is ignored, which also keeps a malformed value // else is ignored, which also keeps a malformed value from being compared
// from being compared against the stored marker and starting an update on // against the stored marker and starting an update on every launch.
// every launch. const releaseTag = /^[\w.+-]{1,64}$/.exec(typeof config?.version === 'string' ? config.version : '')
const reportedVersion = typeof config?.version === 'string' ? config.version : '' if (releaseTag) {
const version = reportedVersion.replace(/[^\w.+-]/g, '').slice(0, 64) const version = releaseTag[0]
if (version && version === reportedVersion) {
const storedVersion = localStorage.getItem('trek_app_version') const storedVersion = localStorage.getItem('trek_app_version')
// Record the version BEFORE acting on it. The old code wrote the marker // 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 // after the purge and outside its try, so a throwing setItem (private
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
import { useTranslation } from '../../../../i18n' import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types' import type { Reservation } from '../../../../types'
import MConfirmSheet from '../../settings/MConfirmSheet' 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 { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel' import { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel'
import { BOOKING_TYPE_COLOR } from './bookingsModel' import { BOOKING_TYPE_COLOR } from './bookingsModel'
@@ -262,6 +262,8 @@ function BookingCard({ res, planner, canEdit, compact }: {
</div> </div>
)} )}
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
<MConfirmSheet <MConfirmSheet
open={confirmDelete} open={confirmDelete}
onClose={() => setConfirmDelete(false)} onClose={() => setConfirmDelete(false)}
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
import { useTranslation } from '../../../../i18n' import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types' import type { Reservation } from '../../../../types'
import MConfirmSheet from '../../settings/MConfirmSheet' 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 { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { import {
TRANSPORT_TYPE_COLOR, TRANSPORT_TYPE_COLOR,
@@ -280,6 +280,8 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
</div> </div>
)} )}
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
<MConfirmSheet <MConfirmSheet
open={confirmDelete} open={confirmDelete}
onClose={() => setConfirmDelete(false)} onClose={() => setConfirmDelete(false)}
@@ -3,6 +3,8 @@ import { ChevronDown, ChevronUp } from 'lucide-react'
import type { ReservationTraveler } from '@trek/shared' import type { ReservationTraveler } from '@trek/shared'
import { avatarSrc } from '../../../../utils/avatarSrc' import { avatarSrc } from '../../../../utils/avatarSrc'
import GuestBadge from '../../../../components/shared/GuestBadge' 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, * Scroll body shared by the list-style trip tabs (transports, bookings, costs,
@@ -120,6 +122,36 @@ export function StatusDot({ color }: { color: string }) {
return <span className="h-[7px] w-[7px] flex-none rounded-full" style={{ background: color }} /> return <span className="h-[7px] w-[7px] flex-none rounded-full" style={{ background: color }} />
} }
/**
* 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 (
<div className="flex flex-col gap-2 px-3 pb-3">
{plugins.map(p => (
<div key={p.id} className="overflow-hidden rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card">
<PluginFrame
pluginId={p.id}
tripId={String(tripId)}
reservationId={String(reservationId)}
title={p.name}
surface="detail-slot"
/>
</div>
))}
</div>
)
}
/** /**
* Read-only avatar-chip cluster for a reservation's assigned travelers (#1517). * Read-only avatar-chip cluster for a reservation's assigned travelers (#1517).
* The mobile transport / booking cards only display them — assignment editing * The mobile transport / booking cards only display them — assignment editing
@@ -3,17 +3,27 @@ import MBookingsTab from '../../../../src/mobile/screens/trip/tabs/MBookingsTab'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { openFile } from '../../../../src/utils/fileDownload' import { openFile } from '../../../../src/utils/fileDownload'
import type { Day, Reservation, TripFile } from '../../../../src/types' import type { Day, Reservation, TripFile } from '../../../../src/types'
import { usePluginStore } from '../../../../src/store/pluginStore'
import { buildSettings } from '../../../helpers/factories' import { buildSettings } from '../../../helpers/factories'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' 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 => ({ vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()), ...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
openFile: vi.fn(), openFile: vi.fn(),
})) }))
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
default: ({ pluginId, tripId, reservationId, surface }: { pluginId: string; tripId: string | null; reservationId?: string | null; surface?: string }) => (
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-reservation={String(reservationId)} data-surface={surface} />
),
}))
const SEAT_MAP = { id: 'seat-map', name: 'Seat Map', type: 'widget', icon: null, slot: 'reservation-detail' } as const
const DAYS = [ const DAYS = [
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null }, { 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' }, { 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', () => { describe('MBookingsTab', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(openFile).mockClear() vi.mocked(openFile).mockClear()
seedStore(usePluginStore, { plugins: [] })
}) })
it('FE-MOB-BKTAB-001: keeps only the non-transport reservations, split by status', () => { 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 // the check-in cell and the time field both read 09:00
expect(within(card).getAllByText('09:00')).toHaveLength(2) 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()
})
}) })
@@ -3,17 +3,27 @@ import MTransportsTab from '../../../../src/mobile/screens/trip/tabs/MTransports
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { openFile } from '../../../../src/utils/fileDownload' import { openFile } from '../../../../src/utils/fileDownload'
import type { Day, Reservation, TripFile } from '../../../../src/types' import type { Day, Reservation, TripFile } from '../../../../src/types'
import { usePluginStore } from '../../../../src/store/pluginStore'
import { buildSettings } from '../../../helpers/factories' import { buildSettings } from '../../../helpers/factories'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip' import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render' 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 => ({ vi.mock('../../../../src/utils/fileDownload', async importOriginal => ({
...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()), ...(await importOriginal<typeof import('../../../../src/utils/fileDownload')>()),
openFile: vi.fn(), openFile: vi.fn(),
})) }))
vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({
default: ({ pluginId, tripId, reservationId, surface }: { pluginId: string; tripId: string | null; reservationId?: string | null; surface?: string }) => (
<div data-testid="plugin-frame" data-plugin={pluginId} data-trip={String(tripId)} data-reservation={String(reservationId)} data-surface={surface} />
),
}))
const FLIGHT_TRACKER = { id: 'flight-tracker', name: 'Flight Tracker', type: 'widget', icon: null, slot: 'reservation-detail' } as const
const DAYS = [ const DAYS = [
{ id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null }, { 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' }, { 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', () => { describe('MTransportsTab', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(openFile).mockClear() vi.mocked(openFile).mockClear()
seedStore(usePluginStore, { plugins: [] })
}) })
it('FE-MOB-TRTAB-001: groups the transport reservations and leaves bookings out', () => { 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 // nobody is assigned, so the traveler filter row stays away
expect(screen.queryByTitle('Ada')).not.toBeInTheDocument() 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()
})
}) })
+2 -1
View File
@@ -152,7 +152,8 @@ permissions still requires explicit re-consent).
the home for per-day content like outfit planning, live flight status or logistics), 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 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 (`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-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 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 a `page`, but it receives the current `tripId` in `trek:context` (so you can scope