mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Merge branches 'fix/mcp-trip-default-currency' and 'fix/mobile-reservation-detail-slot' into test/dev1-mcp-mobile
This commit is contained in:
+7
-8
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user