refactor(bookings): draw the shared parts of the phone reservation cards once

The transports and bookings tabs drew the same title button, edit and
delete buttons, date and time row with the booking code, file chips and
delete confirmation sheet as two copies. Both cards now render them from
MReservationCardParts, and each tab still decides what a tap opens. The
rendered markup stays the same for both tabs.
This commit is contained in:
Maurice
2026-10-09 15:47:05 +02:00
parent a843b66c33
commit c45ac648c9
5 changed files with 313 additions and 168 deletions
+2
View File
@@ -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).",
@@ -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 */}
<div className="flex items-center gap-[7px] border-b border-[color:var(--m-rowbr)] px-3 py-[10px]" style={{ background: tint }}>
<StatusDot color={dotColor} />
<button type="button" onClick={openEdit} className="flex min-w-0 flex-1 items-center gap-[7px] text-start">
<span className="inline-flex flex-none items-center gap-1 rounded-full border border-[color:var(--m-rowbr)] bg-m-card px-2 py-[2px] font-geist text-[0.5625rem] font-bold uppercase tracking-[.06em] text-m-muted">
<TypeIcon size={10} strokeWidth={2.2} style={{ color: typeColor }} />
{t(`reservations.type.${res.type}`)}
</span>
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-bold text-m-ink">{res.title}</span>
{!!res.needs_review && (
<span className="flex-none rounded-full bg-[rgba(232,161,58,.16)] px-2 py-[2px] font-geist text-[0.5rem] font-bold uppercase tracking-[.03em] text-[color:var(--m-st-pending)]">
{t('reservations.needsReview')}
</span>
)}
</button>
{canEdit && (
<button
type="button"
onClick={openEdit}
aria-label={t('common.edit')}
className="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted"
>
<Pencil size={12} strokeWidth={2} />
</button>
)}
{canEdit && (
<button
type="button"
onClick={card.askDelete}
aria-label={t('common.delete')}
className="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted"
>
<Trash2 size={12} strokeWidth={2} />
</button>
)}
<CardTitleButton res={res} TypeIcon={TypeIcon} typeColor={typeColor} onOpen={openEdit} t={t} />
{canEdit && <CardActions onEdit={openEdit} onDelete={card.askDelete} t={t} />}
</div>
{/* 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 && (
<div className="px-3 pb-3 pt-[9px]">
<button type="button" onClick={openEdit} className="block w-full text-start">
<div className="flex gap-2">
<Field label={t('reservations.date')} className="flex-[1.4]">{dayValue}</Field>
<Field label={t('reservations.time')} className="flex-1" tabular>{timeValue}</Field>
</div>
</button>
{res.confirmation_number && (
<ConfirmationCode
code={res.confirmation_number}
label={t('reservations.confirmationCode')}
blurred={card.codeBlurred}
onToggle={card.toggleCode}
/>
)}
<CardWhenAndCode
dayValue={dayValue}
timeValue={timeValue}
code={res.confirmation_number}
card={card}
onOpen={openEdit}
t={t}
/>
<button type="button" onClick={openEdit} className="block w-full text-start">
{metaCells.length > 0 && (
@@ -214,44 +177,13 @@ function BookingCard({ res, planner, canEdit, compact }: {
</div>
)}
{files.length > 0 && (
<div className="mt-2">
<div className="mb-[3px] font-geist text-[0.5625rem] font-bold uppercase tracking-[.08em] text-m-faint">
{t('files.title')}
</div>
<div className="flex flex-col gap-1">
{/* A span with a button role, not a <button>: the whole card body
is already one, and buttons cannot nest. */}
{files.map(f => (
<span
key={f.id}
role="button"
tabIndex={0}
{...card.fileChip(f)}
className="flex items-center gap-[6px] rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-[7px]"
>
<FileText size={12} strokeWidth={2} className="flex-none text-m-muted" />
<span className="truncate font-geist text-[0.65625rem] font-semibold text-m-muted">{f.original_name}</span>
</span>
))}
</div>
</div>
)}
<CardFiles files={files} card={card} t={t} />
</button>
</div>
)}
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
<MConfirmSheet
open={card.confirmingDelete}
onClose={card.cancelDelete}
title={t('reservations.confirm.deleteTitle')}
message={t('reservations.confirm.deleteBody', { name: res.title })}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
danger
onConfirm={card.confirmDelete}
/>
<CardDeleteSheet title={res.title} card={card} t={t} />
</div>
)
}
@@ -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<string, string | number>) =>
params ? `${key}:${Object.values(params).join(',')}` : key;
function reservation(overrides: Partial<Reservation> = {}): Reservation {
return { id: 1, trip_id: 7, type: 'flight', title: 'LH 400', status: 'confirmed', ...overrides } as Reservation;
}
function cardState(overrides: Partial<ReturnType<typeof useReservationCard>> = {}) {
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<typeof useReservationCard>;
}
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(<CardTitleButton res={reservation()} TypeIcon={Plane} typeColor="#123456" onOpen={onOpen} t={t} />);
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(
<CardTitleButton
res={reservation({ type: 'hotel', title: 'Ryokan', needs_review: 1 } as Partial<Reservation>)}
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(<CardActions onEdit={onEdit} onDelete={onDelete} t={t} />);
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(<CardWhenAndCode dayValue="Mon 3" timeValue="09:00" code={null} card={cardState()} onOpen={onOpen} t={t} />);
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(
<CardWhenAndCode
dayValue="Mon 3"
timeValue="09:00"
code="ABC123"
card={cardState({ codeBlurred: true, toggleCode })}
onOpen={vi.fn()}
t={t}
/>
);
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(<CardFiles files={[]} card={cardState()} t={t} />);
expect(container).toBeEmptyDOMElement();
const files = [{ id: 5, original_name: 'ticket.pdf', url: '/uploads/f/5' }] as Parameters<
typeof CardFiles
>[0]['files'];
rerender(<CardFiles files={files} card={cardState()} t={t} />);
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(<CardDeleteSheet title="LH 400" card={card} t={t} />);
expect(screen.getByText('reservations.confirm.deleteBody:LH 400')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }));
expect(card.confirmDelete).toHaveBeenCalledOnce();
});
});
@@ -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<typeof useTranslation>['t'];
type CardState = ReturnType<typeof useReservationCard>;
/** 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 (
<button type="button" onClick={onOpen} className="flex min-w-0 flex-1 items-center gap-[7px] text-start">
<span className="inline-flex flex-none items-center gap-1 rounded-full border border-[color:var(--m-rowbr)] bg-m-card px-2 py-[2px] font-geist text-[0.5625rem] font-bold uppercase tracking-[.06em] text-m-muted">
<TypeIcon size={10} strokeWidth={2.2} style={{ color: typeColor }} />
{t(`reservations.type.${res.type}`)}
</span>
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-bold text-m-ink">{res.title}</span>
{!!res.needs_review && (
<span className="flex-none rounded-full bg-[rgba(232,161,58,.16)] px-2 py-[2px] font-geist text-[0.5rem] font-bold uppercase tracking-[.03em] text-[color:var(--m-st-pending)]">
{t('reservations.needsReview')}
</span>
)}
</button>
);
}
/** Round edit and delete buttons at the end of the header. */
export function CardActions({ onEdit, onDelete, t }: { onEdit: () => void; onDelete: () => void; t: T }) {
return (
<>
<button
type="button"
onClick={onEdit}
aria-label={t('common.edit')}
className="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted"
>
<Pencil size={12} strokeWidth={2} />
</button>
<button
type="button"
onClick={onDelete}
aria-label={t('common.delete')}
className="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted"
>
<Trash2 size={12} strokeWidth={2} />
</button>
</>
);
}
/**
* 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 (
<>
<button type="button" onClick={onOpen} className="block w-full text-start">
<div className="flex gap-2">
<Field label={t('reservations.date')} className="flex-[1.4]">
{dayValue}
</Field>
<Field label={t('reservations.time')} className="flex-1" tabular>
{timeValue}
</Field>
</div>
</button>
{code && (
<ConfirmationCode
code={code}
label={t('reservations.confirmationCode')}
blurred={card.codeBlurred}
onToggle={card.toggleCode}
/>
)}
</>
);
}
/** Attached files, listed inside the card body button. */
export function CardFiles({ files, card, t }: { files: ReturnType<typeof filesFor>; card: CardState; t: T }) {
if (files.length === 0) return null;
return (
<div className="mt-2">
<div className="mb-[3px] font-geist text-[0.5625rem] font-bold uppercase tracking-[.08em] text-m-faint">
{t('files.title')}
</div>
<div className="flex flex-col gap-1">
{/* A span with a button role, not a <button>: the whole card body
is already one, and buttons cannot nest. */}
{files.map((f) => (
<span
key={f.id}
role="button"
tabIndex={0}
{...card.fileChip(f)}
className="flex items-center gap-[6px] rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-[7px]"
>
<FileText size={12} strokeWidth={2} className="flex-none text-m-muted" />
<span className="truncate font-geist text-[0.65625rem] font-semibold text-m-muted">{f.original_name}</span>
</span>
))}
</div>
</div>
);
}
/** Confirmation sheet behind the delete button. */
export function CardDeleteSheet({ title, card, t }: { title: string; card: CardState; t: T }) {
return (
<MConfirmSheet
open={card.confirmingDelete}
onClose={card.cancelDelete}
title={t('reservations.confirm.deleteTitle')}
message={t('reservations.confirm.deleteBody', { name: title })}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
danger
onConfirm={card.confirmDelete}
/>
);
}
@@ -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 }: {
) : (
<StatusDot color={dotColor} />
)}
<button
type="button"
onClick={openDetail}
className="flex min-w-0 flex-1 items-center gap-[7px] text-start"
>
<span className="inline-flex flex-none items-center gap-1 rounded-full border border-[color:var(--m-rowbr)] bg-m-card px-2 py-[2px] font-geist text-[0.5625rem] font-bold uppercase tracking-[.06em] text-m-muted">
<TypeIcon size={10} strokeWidth={2.2} style={{ color: typeColor }} />
{t(`reservations.type.${res.type}`)}
</span>
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-bold text-m-ink">{res.title}</span>
{!!res.needs_review && (
<span className="flex-none rounded-full bg-[rgba(232,161,58,.16)] px-2 py-[2px] font-geist text-[0.5rem] font-bold uppercase tracking-[.03em] text-[color:var(--m-st-pending)]">
{t('reservations.needsReview')}
</span>
)}
</button>
{canEdit && (
<button
type="button"
onClick={editTransport}
aria-label={t('common.edit')}
className="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted"
>
<Pencil size={12} strokeWidth={2} />
</button>
)}
{canEdit && (
<button
type="button"
onClick={card.askDelete}
aria-label={t('common.delete')}
className="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted"
>
<Trash2 size={12} strokeWidth={2} />
</button>
)}
<CardTitleButton res={res} TypeIcon={TypeIcon} typeColor={typeColor} onOpen={openDetail} t={t} />
{canEdit && <CardActions onEdit={editTransport} onDelete={card.askDelete} t={t} />}
</div>
{/* 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 && (
<div className="px-3 pb-3 pt-[9px]">
<button type="button" onClick={openDetail} className="block w-full text-start">
<div className="flex gap-2">
<Field label={t('reservations.date')} className="flex-[1.4]">{dayValue}</Field>
<Field label={t('reservations.time')} className="flex-1" tabular>{timeValue}</Field>
</div>
</button>
{res.confirmation_number && (
<ConfirmationCode
code={res.confirmation_number}
label={t('reservations.confirmationCode')}
blurred={card.codeBlurred}
onToggle={card.toggleCode}
/>
)}
<CardWhenAndCode
dayValue={dayValue}
timeValue={timeValue}
code={res.confirmation_number}
card={card}
onOpen={openDetail}
t={t}
/>
<button type="button" onClick={openDetail} className="block w-full text-start">
{eps.length >= 2 && (
@@ -229,42 +187,13 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
<TravelerAvatars travelers={res.travelers || []} label={t('reservations.travelers.label')} />
{files.length > 0 && (
<div className="mt-2">
<div className="mb-[3px] font-geist text-[0.5625rem] font-bold uppercase tracking-[.08em] text-m-faint">
{t('files.title')}
</div>
<div className="flex flex-col gap-1">
{files.map(f => (
<span
key={f.id}
role="button"
tabIndex={0}
{...card.fileChip(f)}
className="flex items-center gap-[6px] rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-[7px]"
>
<FileText size={12} strokeWidth={2} className="flex-none text-m-muted" />
<span className="truncate font-geist text-[0.65625rem] font-semibold text-m-muted">{f.original_name}</span>
</span>
))}
</div>
</div>
)}
<CardFiles files={files} card={card} t={t} />
</button>
</div>
)}
{!compact && <ReservationPluginSlots tripId={planner.tripId} reservationId={res.id} />}
<MConfirmSheet
open={card.confirmingDelete}
onClose={card.cancelDelete}
title={t('reservations.confirm.deleteTitle')}
message={t('reservations.confirm.deleteBody', { name: res.title })}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
danger
onConfirm={card.confirmDelete}
/>
<CardDeleteSheet title={res.title} card={card} t={t} />
</div>
)
}