diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json
index 335703b96..9e74bb34c 100644
--- a/client/scripts/feature-pairs.json
+++ b/client/scripts/feature-pairs.json
@@ -900,6 +900,7 @@
"src/mobile/screens/trip/roadtrip/useMRoadtrip.ts": "Phone stage screen adapter over the shared roadtripRowModel plus a live clock tick.",
"src/mobile/screens/trip/roadtrip/useMRtAlternatives.ts": "Tap to preview, confirm to write layer over the planner's shared route alternatives.",
"src/mobile/screens/trip/roadtrip/useMRtCorridor.ts": "Phone reach and anchor layer over the planner's shared corridor state.",
+ "src/mobile/screens/trip/sheets/MBookingFields.tsx": "Markup only: the booking code, notes, traveler and route stop fields both phone booking sheets draw.",
"src/mobile/screens/trip/sheets/MBookingFilesCosts.tsx": "Phone booking sheet footer that composes PlFileAttach and MLinkedCosts.",
"src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": "Phone action sheet for a pool place that calls planner handlers and the shared placeToSaveTarget.",
"src/mobile/screens/trip/sheets/MDaysSheet.tsx": "Button based day reorder sheet over planner.handleReorderDays; the desktop reorders by drag.",
@@ -913,6 +914,7 @@
"src/mobile/screens/trip/tabs/MCollabTab.tsx": "Phone collab sub tab router that reuses the desktop CollabLinks component.",
"src/mobile/screens/trip/tabs/MListsTab.tsx": "Phone router between the packing and to-do bodies, with no logic of its own.",
"src/mobile/screens/trip/tabs/MReceiptScanButton.tsx": "Already shares useReceiptScan and ReceiptScanModal with the desktop CostsPanel.",
+ "src/mobile/screens/trip/tabs/MReservationCardParts.tsx": "Markup only: the title, actions, date, files and delete sheet parts both phone booking tabs draw.",
"src/mobile/screens/trip/tabs/MTripTabPanel.tsx": "Phone tab router that mounts the per tab screens and plugin frames, with no logic of its own.",
"src/mobile/screens/trip/tabs/listsModel.ts": "Priority colour and label constants only the phone to-do list and task sheet use.",
"src/mobile/screens/trip/tabs/tabChrome.tsx": "Phone tab chrome primitives (scroller, section headers, fields, traveler chips, plugin slots).",
diff --git a/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx b/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx
index 6b3fc1243..26c5728d7 100644
--- a/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx
+++ b/client/src/mobile/screens/trip/tabs/MBookingsTab.tsx
@@ -1,11 +1,11 @@
-import { FileText, MapPin, Pencil, Trash2 } from 'lucide-react'
+import { MapPin } from 'lucide-react'
import MDancingTrek from '../../../components/MDancingTrek'
import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constants'
import { formatTime, formatPriceText } from '../../../../utils/formatters'
import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types'
-import MConfirmSheet from '../../settings/MConfirmSheet'
-import { ConfirmationCode, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
+import { CardActions, CardDeleteSheet, CardFiles, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'
+import { Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { cardWhen, parseTransportMeta } from './transportsModel'
import { BOOKING_TYPE_COLOR } from '../../../../components/Planner/bookings/bookingsModel'
@@ -136,59 +136,22 @@ function BookingCard({ res, planner, canEdit, compact }: {
{/* Header */}
{/* Body — split around the booking code so the reveal can be its own
control instead of a click handler buried inside the card button. */}
{!compact && (
-
-
- {res.confirmation_number && (
-
- )}
+
)}
- {files.length > 0 && (
-
-
- {t('files.title')}
-
-
- {/* A span with a button role, not a
-
- )}
+
)}
{!compact && }
-
+
)
}
diff --git a/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx b/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx
new file mode 100644
index 000000000..18e95e990
--- /dev/null
+++ b/client/src/mobile/screens/trip/tabs/MReservationCardParts.test.tsx
@@ -0,0 +1,122 @@
+// FE-COMP-MRESCARD-001 to FE-COMP-MRESCARD-007
+import { fireEvent, render, screen } from '@testing-library/react';
+import { Hotel, Plane } from 'lucide-react';
+import { describe, expect, it, vi } from 'vitest';
+
+import type { useReservationCard } from '../../../../components/Planner/bookings/useReservationCard';
+import type { Reservation } from '../../../../types';
+import { CardActions, CardDeleteSheet, CardFiles, CardTitleButton, CardWhenAndCode } from './MReservationCardParts';
+
+const t = (key: string, params?: Record) =>
+ params ? `${key}:${Object.values(params).join(',')}` : key;
+
+function reservation(overrides: Partial = {}): Reservation {
+ return { id: 1, trip_id: 7, type: 'flight', title: 'LH 400', status: 'confirmed', ...overrides } as Reservation;
+}
+
+function cardState(overrides: Partial> = {}) {
+ return {
+ codeBlurred: false,
+ toggleCode: undefined,
+ confirmingDelete: false,
+ askDelete: vi.fn(),
+ cancelDelete: vi.fn(),
+ confirmDelete: vi.fn(),
+ fileChip: () => ({ onClick: vi.fn(), onKeyDown: vi.fn() }),
+ ...overrides,
+ } as unknown as ReturnType;
+}
+
+describe('MReservationCardParts', () => {
+ it('FE-COMP-MRESCARD-001: the title button shows the type pill and title, and opens the card (transport variant)', () => {
+ const onOpen = vi.fn();
+ render();
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveTextContent('reservations.type.flight');
+ expect(button).toHaveTextContent('LH 400');
+ expect(screen.queryByText('reservations.needsReview')).toBeNull();
+ expect(button.querySelector('.lucide-plane')).toHaveStyle({ color: '#123456' });
+ fireEvent.click(button);
+ expect(onOpen).toHaveBeenCalledOnce();
+ });
+
+ it('FE-COMP-MRESCARD-002: the title button flags a booking that needs review (booking variant)', () => {
+ render(
+ )}
+ TypeIcon={Hotel}
+ typeColor="#654321"
+ onOpen={vi.fn()}
+ t={t}
+ />
+ );
+
+ expect(screen.getByText('reservations.type.hotel')).toBeInTheDocument();
+ expect(screen.getByText('reservations.needsReview')).toBeInTheDocument();
+ });
+
+ it('FE-COMP-MRESCARD-003: the actions call edit and delete', () => {
+ const onEdit = vi.fn();
+ const onDelete = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: 'common.edit' }));
+ fireEvent.click(screen.getByRole('button', { name: 'common.delete' }));
+ expect(onEdit).toHaveBeenCalledOnce();
+ expect(onDelete).toHaveBeenCalledOnce();
+ });
+
+ it('FE-COMP-MRESCARD-004: date and time open the card, and a missing code draws no code box', () => {
+ const onOpen = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByRole('button'));
+ expect(onOpen).toHaveBeenCalledOnce();
+ expect(screen.getByText('Mon 3')).toBeInTheDocument();
+ expect(screen.getByText('09:00')).toBeInTheDocument();
+ expect(screen.queryByText('reservations.confirmationCode')).toBeNull();
+ });
+
+ it('FE-COMP-MRESCARD-005: a blurred code is its own reveal control', () => {
+ const toggleCode = vi.fn();
+ render(
+
+ );
+
+ const reveal = screen.getByRole('button', { name: 'ABC123' });
+ expect(reveal).toHaveAttribute('aria-pressed', 'false');
+ fireEvent.click(reveal);
+ expect(toggleCode).toHaveBeenCalledOnce();
+ });
+
+ it('FE-COMP-MRESCARD-006: files render as button-role chips, and no files render nothing', () => {
+ const { container, rerender } = render();
+ expect(container).toBeEmptyDOMElement();
+
+ const files = [{ id: 5, original_name: 'ticket.pdf', url: '/uploads/f/5' }] as Parameters<
+ typeof CardFiles
+ >[0]['files'];
+ rerender();
+ expect(screen.getByText('files.title')).toBeInTheDocument();
+ const chip = screen.getByRole('button', { name: 'ticket.pdf' });
+ expect(chip.tagName).toBe('SPAN');
+ expect(chip).toHaveAttribute('tabindex', '0');
+ });
+
+ it('FE-COMP-MRESCARD-007: the delete sheet names the reservation and confirms through the card', () => {
+ const card = cardState({ confirmingDelete: true });
+ render();
+
+ expect(screen.getByText('reservations.confirm.deleteBody:LH 400')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'common.delete' }));
+ expect(card.confirmDelete).toHaveBeenCalledOnce();
+ });
+});
diff --git a/client/src/mobile/screens/trip/tabs/MReservationCardParts.tsx b/client/src/mobile/screens/trip/tabs/MReservationCardParts.tsx
new file mode 100644
index 000000000..75b033c71
--- /dev/null
+++ b/client/src/mobile/screens/trip/tabs/MReservationCardParts.tsx
@@ -0,0 +1,160 @@
+import { FileText, type LucideIcon, Pencil, Trash2 } from 'lucide-react';
+
+import type { useReservationCard } from '../../../../components/Planner/bookings/useReservationCard';
+import type { useTranslation } from '../../../../i18n';
+import type { Reservation } from '../../../../types';
+import type { filesFor } from '../../../../utils/reservationFiles';
+import MConfirmSheet from '../../settings/MConfirmSheet';
+import { ConfirmationCode, Field } from './tabChrome';
+
+/**
+ * Pieces of the phone reservation card that the transports and bookings tabs
+ * draw the same way. Each tab keeps its own card and decides what a tap does
+ * (the transports tab opens the detail sheet, the bookings tab the edit modal);
+ * these only paint the shared markup.
+ */
+
+type T = ReturnType['t'];
+type CardState = ReturnType;
+
+/** Header button: type pill, title and the needs-review badge. */
+export function CardTitleButton({
+ res,
+ TypeIcon,
+ typeColor,
+ onOpen,
+ t,
+}: {
+ res: Reservation;
+ TypeIcon: LucideIcon;
+ typeColor: string;
+ onOpen: () => void;
+ t: T;
+}) {
+ return (
+
+ );
+}
+
+/** Round edit and delete buttons at the end of the header. */
+export function CardActions({ onEdit, onDelete, t }: { onEdit: () => void; onDelete: () => void; t: T }) {
+ return (
+ <>
+
+
+ >
+ );
+}
+
+/**
+ * Date and time row, followed by the booking code. The code sits outside the
+ * row button so its reveal can be its own control.
+ */
+export function CardWhenAndCode({
+ dayValue,
+ timeValue,
+ code,
+ card,
+ onOpen,
+ t,
+}: {
+ dayValue: string;
+ timeValue: string;
+ code: string | null | undefined;
+ card: CardState;
+ onOpen: () => void;
+ t: T;
+}) {
+ return (
+ <>
+
+
+ {code && (
+
+ )}
+ >
+ );
+}
+
+/** Attached files, listed inside the card body button. */
+export function CardFiles({ files, card, t }: { files: ReturnType; card: CardState; t: T }) {
+ if (files.length === 0) return null;
+ return (
+
+
+ {t('files.title')}
+
+
+ {/* A span with a button role, not a
+
+ );
+}
+
+/** Confirmation sheet behind the delete button. */
+export function CardDeleteSheet({ title, card, t }: { title: string; card: CardState; t: T }) {
+ return (
+
+ );
+}
diff --git a/client/src/mobile/screens/trip/tabs/MTransportsTab.tsx b/client/src/mobile/screens/trip/tabs/MTransportsTab.tsx
index 0a7ebaf46..ad93bde64 100644
--- a/client/src/mobile/screens/trip/tabs/MTransportsTab.tsx
+++ b/client/src/mobile/screens/trip/tabs/MTransportsTab.tsx
@@ -1,11 +1,10 @@
-import { FileText, Pencil, Trash2 } from 'lucide-react'
import MDancingTrek from '../../../components/MDancingTrek'
import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constants'
import { formatPriceText } from '../../../../utils/formatters'
import { useTranslation } from '../../../../i18n'
import type { Reservation } from '../../../../types'
-import MConfirmSheet from '../../settings/MConfirmSheet'
-import { ConfirmationCode, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
+import { CardActions, CardDeleteSheet, CardFiles, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'
+import { Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { cardWhen, parseTransportMeta } from './transportsModel'
import { TRANSPORT_TYPE_COLOR } from '../../../../components/Planner/bookings/bookingsModel'
@@ -149,63 +148,22 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
) : (
)}
-
- {canEdit && (
-
- )}
- {canEdit && (
-
- )}
+
+ {canEdit && }
{/* Body — split around the booking code so the reveal can be its own
control instead of a click handler buried inside the card button. */}
{!compact && (