mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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
This commit is contained in:
@@ -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 }: {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
|
||||
|
||||
<MConfirmSheet
|
||||
open={confirmDelete}
|
||||
|
||||
@@ -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,
|
||||
@@ -279,6 +279,7 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
|
||||
|
||||
<MConfirmSheet
|
||||
open={confirmDelete}
|
||||
|
||||
@@ -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 <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).
|
||||
* 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 { 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<typeof import('../../../../src/utils/fileDownload')>()),
|
||||
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 = [
|
||||
{ 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof import('../../../../src/utils/fileDownload')>()),
|
||||
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 = [
|
||||
{ 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user