From a08d4b944ee75a84e92bf9c4ef611796b7f40097 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 13 Nov 2026 10:49:30 +0100 Subject: [PATCH] feat(mobile): icon-only action rows in the transport and booking sheets (#2585) The transport sheet's row wrapped once a ticket and the wallet were there. Its actions are icons now, named by their labels, and the attachments sit in their own rows with a download, like the transports list. The wallet is a black circle, beside the close button in the booking editor. A failed download on the phone now says so. --- .../bookings/useReservationCard.test.ts | 8 +++ .../Planner/bookings/useReservationCard.ts | 9 +-- .../mobile/components/MWalletButton.test.tsx | 6 +- .../src/mobile/components/MWalletButton.tsx | 28 ++++++-- .../screens/trip/sheets/MReservationSheet.tsx | 4 +- .../screens/trip/sheets/MTransportSheet.tsx | 68 +++++++------------ .../screens/trip/sheets/MTripSheetUi.tsx | 14 ++-- .../screens/trip/sheets/PlSheetChrome.tsx | 5 +- .../trip/tabs/MReservationCardParts.tsx | 10 ++- .../unit/mobile/trip/MTransportSheet.test.tsx | 18 ++++- shared/src/i18n/ar/reservations.ts | 1 - shared/src/i18n/az/reservations.ts | 1 - shared/src/i18n/br/reservations.ts | 1 - shared/src/i18n/ca/reservations.ts | 1 - shared/src/i18n/cs/reservations.ts | 1 - shared/src/i18n/de/reservations.ts | 1 - shared/src/i18n/en/reservations.ts | 1 - shared/src/i18n/es/reservations.ts | 1 - shared/src/i18n/et/reservations.ts | 1 - shared/src/i18n/fr/reservations.ts | 1 - shared/src/i18n/gr/reservations.ts | 1 - shared/src/i18n/he/reservations.ts | 1 - shared/src/i18n/hu/reservations.ts | 1 - shared/src/i18n/id/reservations.ts | 1 - shared/src/i18n/it/reservations.ts | 1 - shared/src/i18n/ja/reservations.ts | 1 - shared/src/i18n/ko/reservations.ts | 1 - shared/src/i18n/nl/reservations.ts | 1 - shared/src/i18n/pl/reservations.ts | 1 - shared/src/i18n/ru/reservations.ts | 1 - shared/src/i18n/sk/reservations.ts | 1 - shared/src/i18n/sv/reservations.ts | 1 - shared/src/i18n/th/reservations.ts | 1 - shared/src/i18n/tr/reservations.ts | 1 - shared/src/i18n/uk/reservations.ts | 1 - shared/src/i18n/vi/reservations.ts | 1 - shared/src/i18n/zh-TW/reservations.ts | 1 - shared/src/i18n/zh/reservations.ts | 1 - 38 files changed, 103 insertions(+), 95 deletions(-) diff --git a/client/src/components/Planner/bookings/useReservationCard.test.ts b/client/src/components/Planner/bookings/useReservationCard.test.ts index 004e14b2e..7921e6839 100644 --- a/client/src/components/Planner/bookings/useReservationCard.test.ts +++ b/client/src/components/Planner/bookings/useReservationCard.test.ts @@ -85,6 +85,14 @@ describe('useReservationCard', () => { expect(openFile).toHaveBeenCalledTimes(1); }); + it('FE-PLANNER-RESLISTFILTER-008b: a download that fails says so with a toast', async () => { + vi.mocked(downloadFile).mockRejectedValueOnce(new Error('offline')); + const opts = host(); + const { result } = renderHook(() => useReservationCard(ann, opts)); + result.current.downloadFile(file); + await vi.waitFor(() => expect(opts.toast.error).toHaveBeenCalledWith('files.downloadError')); + }); + it('FE-PLANNER-RESLISTFILTER-009: the card knows whether deleting it takes a linked expense along (#2604)', () => { resetAllStores(); seedStore(useTripStore, { budgetItems: [buildBudgetItem({ id: 1, reservation_id: 10 } as never)] }); diff --git a/client/src/components/Planner/bookings/useReservationCard.ts b/client/src/components/Planner/bookings/useReservationCard.ts index c617bdde3..5b36264b4 100644 --- a/client/src/components/Planner/bookings/useReservationCard.ts +++ b/client/src/components/Planner/bookings/useReservationCard.ts @@ -19,9 +19,10 @@ export interface ReservationCardHost { * opening: its booking code blurred until tapped while the "Blur booking codes" * preference is on (off, the code is plain and has no toggle), the delete * behind a question with a toast when it fails, and its attached files, which - * open or download from their own row. A file that cannot be opened or saved - * stays silent here; the desktop panel toasts it. The delete question names a - * linked expense, which goes with the booking. + * open or download from their own row. A download that fails says so with a + * toast, since a save button that does nothing reads as broken; opening stays + * silent as it always was. The delete question names a linked expense, which + * goes with the booking. */ export function useReservationCard(res: Reservation, host: ReservationCardHost) { const blurCodes = host.settings.blur_booking_codes; @@ -45,6 +46,6 @@ export function useReservationCard(res: Reservation, host: ReservationCardHost) void confirmDelete(); }, openFile: (f: Attachment) => openAttachment(f), - downloadFile: (f: Attachment) => downloadAttachment(f), + downloadFile: (f: Attachment) => downloadAttachment(f, () => host.toast.error(host.t('files.downloadError'))), }; } diff --git a/client/src/mobile/components/MWalletButton.test.tsx b/client/src/mobile/components/MWalletButton.test.tsx index 075a4d56e..ce1444576 100644 --- a/client/src/mobile/components/MWalletButton.test.tsx +++ b/client/src/mobile/components/MWalletButton.test.tsx @@ -66,9 +66,9 @@ describe('MWalletButton', () => { wallet.addGoogle.mockResolvedValue('added'); const onError = vi.fn(); render(); - // The label is short, the accessible name says which wallet. - expect(screen.getByRole('button')).toHaveTextContent(/^Wallet$/); - expect(screen.getByRole('button', { name: /Google Wallet/ })).toBeInTheDocument(); + // An icon only: the name and the tooltip say which wallet. + expect(screen.getByRole('button')).toHaveTextContent(''); + expect(screen.getByRole('button', { name: /Google Wallet/ })).toHaveAttribute('title', 'Add to Google Wallet'); fireEvent.click(screen.getByRole('button')); await waitFor(() => expect(wallet.addGoogle).toHaveBeenCalledWith(42, 7, expect.any(String))); expect(wallet.add).not.toHaveBeenCalled(); diff --git a/client/src/mobile/components/MWalletButton.tsx b/client/src/mobile/components/MWalletButton.tsx index 2b79635ab..a7b557b40 100644 --- a/client/src/mobile/components/MWalletButton.tsx +++ b/client/src/mobile/components/MWalletButton.tsx @@ -17,15 +17,24 @@ interface MWalletButtonProps { className?: string; /** Shown when the pass could not be made (a toast in the sheet that hosts the button). */ onError: (message: string) => void; + /** 34px beside a sheet header's close button instead of 40px in an action row. */ + compact?: boolean; } /** * "Add to Apple Wallet" on a booking in the iOS app (feature wallet), "Add to * Google Wallet" in the Android app (feature googleWallet): the booking becomes a - * TREK pass that shows up around its time and place. Black like both stores' own - * badges, so it reads as the wallet at a glance. + * TREK pass that shows up around its time and place. A black circle like both + * stores' own badges, so it reads as the wallet at a glance; the phone's action + * rows are icons only, the name is in the label and tooltip. */ -export default function MWalletButton({ tripId, reservationId, onError, className }: MWalletButtonProps) { +export default function MWalletButton({ + tripId, + reservationId, + onError, + className, + compact = false, +}: MWalletButtonProps) { const { t, language } = useTranslation(); // Re-renders once the app binary has said what it can do. useNativeCapabilities(); @@ -51,17 +60,22 @@ export default function MWalletButton({ tripId, reservationId, onError, classNam } }; + const label = t(wallet === 'google' ? 'reservations.wallet.addGoogle' : 'reservations.wallet.add'); const button = ( ); return className ?
{button}
: button; diff --git a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx index d63da671f..f73b0143f 100644 --- a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx @@ -185,13 +185,13 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati } onClose={handleClose} closeLabel={t('common.close')} /> - {/* A saved booking goes into Apple Wallet (iOS app only). */} - {/* BOOKING TYPE */} {t('reservations.bookingType')} diff --git a/client/src/mobile/screens/trip/sheets/MTransportSheet.tsx b/client/src/mobile/screens/trip/sheets/MTransportSheet.tsx index e6c6f9abc..631114780 100644 --- a/client/src/mobile/screens/trip/sheets/MTransportSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MTransportSheet.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { ArrowRight, Footprints, Paperclip, Pencil, Route as RouteIcon, Trash2 } from 'lucide-react' +import { ArrowRight, Footprints, Pencil, Route as RouteIcon, Trash2 } from 'lucide-react' import MSheet from '../../../components/MSheet' import MWalletButton from '../../../components/MWalletButton' import type { MTripSheetsProps } from '../MTripShell' @@ -8,7 +8,6 @@ import { useSettingsStore } from '../../../../store/settingsStore' import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constants' import { splitReservationDateTime } from '../../../../utils/formatters' import { getFlightLegs, getTrainLegs, usesStationRoute, type FlightLeg, type TrainLeg } from '../../../../utils/flightLegs' -import { openFile } from '../../../../utils/fileDownload' import { runsOnDay } from '../../../../utils/reservationRoutes' import { filesFor } from '../../../../utils/reservationFiles' import { bookingDayLabel, segmentCodeLabel } from '../../../../components/Planner/transportDetailModel' @@ -18,7 +17,9 @@ import { isTransportReservationType } from '@trek/shared' import { useBookingLink } from '../../../../hooks/useBookingLink' import { bookingFacts, stayStats } from '../../../../components/Planner/bookings/bookingFacts' import { parseMeta } from '../../../../components/Planner/bookings/bookingsModel' -import { Eyebrow, INNER_CLS, StatBox, TileHeader, displayTime } from './MTripSheetUi' +import { ActionCircle, Eyebrow, INNER_CLS, StatBox, TileHeader, displayTime } from './MTripSheetUi' +import { CardFiles } from '../tabs/MReservationCardParts' +import { downloadAttachment, openAttachment } from '../../../../components/Planner/bookings/openAttachment' import { flowText } from '../../../../utils/flowText' interface TransportSheetPayload { @@ -342,50 +343,31 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) { )} - {/* ── Actions ── */} -
- + {/* ── Files: their own rows, so a long name never pushes the actions onto a second line ── */} + downloadAttachment(f, () => planner.toast.error(t('files.downloadError'))), + }} + t={t} + /> + + {/* ── Actions: icons only, the names are their labels ── */} +
+ + + - {resFiles.map(f => ( - - ))} {canEdit && ( - + + + )} {canEdit && ( - + + + )}
diff --git a/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx b/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx index 1fe212153..c213ef202 100644 --- a/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx +++ b/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx @@ -160,23 +160,29 @@ interface ActionCircleProps { label: string primary?: boolean danger?: boolean + /** A toggle (shown on the map): announced as pressed and filled while on. */ + pressed?: boolean className?: string children: ReactNode /** For callers that anchor a popup to the button. React 19 passes it through. */ ref?: Ref } -/** 38px round action button of the inspector footer rows. */ -export function ActionCircle({ onClick, label, primary = false, danger = false, className = '', children, ref }: ActionCircleProps) { +/** + * 40px round action button of the sheets' action rows. The rows hold icons + * only, so a phone never wraps them; the name is the label and the tooltip. + */ +export function ActionCircle({ onClick, label, primary = false, danger = false, pressed, className = '', children, ref }: ActionCircleProps) { return (