From 3de3ffcd1c9ad94abf511298f658135422007b02 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 15:56:40 +0200 Subject: [PATCH] refactor(bookings): draw the shared phone booking fields with one component The phone booking and transport sheets each carried their own copy of the booking code and status row, the notes field, the traveler picker with its avatar, and the transport sheet drew the card of a route stop twice, once for flights and once for trains and cruises. They now render the same components from MBookingFields next to the sheets, which take the sheet's own translate function so every string stays what it was. The rendered markup is unchanged. --- client/scripts/i18n-keys.mjs | 12 +- .../trip/sheets/MBookingFields.test.tsx | 136 +++++++++ .../screens/trip/sheets/MBookingFields.tsx | 253 ++++++++++++++++ .../screens/trip/sheets/MReservationSheet.tsx | 94 +----- .../trip/sheets/MTransportFormSheet.tsx | 280 +++++------------- 5 files changed, 485 insertions(+), 290 deletions(-) create mode 100644 client/src/mobile/screens/trip/sheets/MBookingFields.test.tsx create mode 100644 client/src/mobile/screens/trip/sheets/MBookingFields.tsx diff --git a/client/scripts/i18n-keys.mjs b/client/scripts/i18n-keys.mjs index 7beb4feef..1d42d6526 100644 --- a/client/scripts/i18n-keys.mjs +++ b/client/scripts/i18n-keys.mjs @@ -101,13 +101,11 @@ export const DYNAMIC_ALLOWED = [ template: 'journey.studio.${key}', because: 'key comes from an `as const` tuple of frame and stroke styles written beside the call, typed literals', })), - ...['mobile/screens/trip/sheets/MReservationSheet.tsx', 'mobile/screens/trip/sheets/MTransportFormSheet.tsx'].map( - (file) => ({ - file, - template: 'reservations.${s}', - because: "s iterates the literal ['pending', 'confirmed'] as const; reservations.pending/confirmed exist", - }) - ), + { + file: 'mobile/screens/trip/sheets/MBookingFields.tsx', + template: 'reservations.${s}', + because: "s iterates the literal ['pending', 'confirmed'] as const; reservations.pending/confirmed exist", + }, ...['components/Admin/AdminPluginsPanel.tsx', 'mobile/screens/admin/MAdminPluginsPanel.tsx'].map((file) => ({ file, template: 'admin.plugins.perm.${perm}', diff --git a/client/src/mobile/screens/trip/sheets/MBookingFields.test.tsx b/client/src/mobile/screens/trip/sheets/MBookingFields.test.tsx new file mode 100644 index 000000000..29a31b17b --- /dev/null +++ b/client/src/mobile/screens/trip/sheets/MBookingFields.test.tsx @@ -0,0 +1,136 @@ +// FE-COMP-MBOOKINGFIELDS-001 to FE-COMP-MBOOKINGFIELDS-008 +import { describe, expect, it, vi } from 'vitest'; + +import { fireEvent, render, screen } from '@testing-library/react'; +import type { TripMember } from '../../../../types'; +import { MBookingCodeStatus, MBookingNotes, MBookingTravelers, MRouteStopCard } from './MBookingFields'; + +const members: TripMember[] = [ + { id: 1, username: 'anna', avatar_url: '/uploads/avatars/anna.png' }, + { id: 2, username: 'ben', is_guest: true }, +]; + +const times = { arrDayId: '', arrTime: '', depDayId: '', depTime: '' }; +// The sheets hand in their own translate function; the key itself is enough here. +const t = (key: string) => key; + +const days = [{ value: '', label: 'none' }]; + +describe('MBookingCodeStatus', () => { + it('FE-COMP-MBOOKINGFIELDS-001: reports the typed code and the picked status', () => { + const onCodeChange = vi.fn(); + const onStatusChange = vi.fn(); + render( + + ); + + const input = screen.getByPlaceholderText('reservations.confirmationPlaceholder'); + expect(input).toHaveValue('ABC'); + fireEvent.change(input, { target: { value: 'XYZ9' } }); + expect(onCodeChange).toHaveBeenCalledWith('XYZ9'); + + fireEvent.click(screen.getByRole('button', { name: 'reservations.confirmed' })); + expect(onStatusChange).toHaveBeenCalledWith('confirmed'); + }); + + it('FE-COMP-MBOOKINGFIELDS-002: marks the current status as the active segment', () => { + render(); + + expect(screen.getByRole('button', { name: 'reservations.confirmed' }).className).toContain('bg-m-act'); + expect(screen.getByRole('button', { name: 'reservations.pending' }).className).toContain('text-m-muted'); + }); +}); + +describe('MBookingNotes', () => { + it('FE-COMP-MBOOKINGFIELDS-003: shows the notes and reports an edit', () => { + const onChange = vi.fn(); + render(); + + const area = screen.getByPlaceholderText('reservations.notesPlaceholder'); + expect(area).toHaveValue('Late check-in'); + fireEvent.change(area, { target: { value: 'Early' } }); + expect(onChange).toHaveBeenCalledWith('Early'); + }); +}); + +describe('MBookingTravelers', () => { + it('FE-COMP-MBOOKINGFIELDS-004: says so when the trip has no members', () => { + render(); + + expect(screen.getByText('reservations.travelers.none')).toBeInTheDocument(); + expect(screen.queryByRole('button')).toBeNull(); + }); + + it('FE-COMP-MBOOKINGFIELDS-005: dims the members not on the booking and toggles one on click', () => { + const onToggle = vi.fn(); + const { container } = render( + + ); + + const anna = screen.getByRole('button', { name: /anna/ }); + const ben = screen.getByRole('button', { name: /ben/ }); + expect(anna.className).not.toContain('opacity-60'); + expect(ben.className).toContain('opacity-60'); + // A photo for the member who has one, the initial for the one who has not. + expect(container.querySelector('img')).toHaveAttribute('src', '/uploads/avatars/anna.png'); + expect(screen.getByText('B')).toBeInTheDocument(); + + fireEvent.click(ben); + expect(onToggle).toHaveBeenCalledWith(2); + }); +}); + +describe('MRouteStopCard', () => { + const card = (isFirst: boolean, isLast: boolean, onRemove = vi.fn()) => + render( + picker} + isFirst={isFirst} + isLast={isLast} + onRemove={onRemove} + times={times} + onTimesChange={vi.fn()} + dayOptions={days} + > + leg fields + + ); + + it('FE-COMP-MBOOKINGFIELDS-006: the origin has a departure and the leg fields, but no arrival and no remove', () => { + card(true, false); + + expect(screen.getByText('Stop')).toBeInTheDocument(); + expect(screen.getByText('picker')).toBeInTheDocument(); + expect(screen.queryByText('reservations.arrivalDate')).toBeNull(); + expect(screen.getByText('reservations.departureDate')).toBeInTheDocument(); + expect(screen.getByText('leg fields')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'common.delete' })).toBeNull(); + }); + + it('FE-COMP-MBOOKINGFIELDS-007: the destination has an arrival only', () => { + card(false, true); + + expect(screen.getByText('reservations.arrivalDate')).toBeInTheDocument(); + expect(screen.queryByText('reservations.departureDate')).toBeNull(); + expect(screen.queryByText('leg fields')).toBeNull(); + expect(screen.queryByRole('button', { name: 'common.delete' })).toBeNull(); + }); + + it('FE-COMP-MBOOKINGFIELDS-008: a stop in between has both and can be removed', () => { + const onRemove = vi.fn(); + card(false, false, onRemove); + + expect(screen.getByText('reservations.arrivalDate')).toBeInTheDocument(); + expect(screen.getByText('reservations.departureDate')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'common.delete' })); + expect(onRemove).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/mobile/screens/trip/sheets/MBookingFields.tsx b/client/src/mobile/screens/trip/sheets/MBookingFields.tsx new file mode 100644 index 000000000..aabf41e9a --- /dev/null +++ b/client/src/mobile/screens/trip/sheets/MBookingFields.tsx @@ -0,0 +1,253 @@ +import { Check, Trash2 } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import { SPLIT_COLORS } from '../../../../components/Budget/BudgetPanel.constants'; +import { BookingCodeInput } from '../../../../components/shared/BookingCode'; +import CustomSelect from '../../../../components/shared/CustomSelect'; +import CustomTimePicker from '../../../../components/shared/CustomTimePicker'; +import GuestBadge from '../../../../components/shared/GuestBadge'; +import type { TripMember } from '../../../../types'; +import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS } from './PlSheetChrome'; + +/** + * The fields the booking and transport sheets share: booking code with the + * status toggle, notes, the traveler picker and the card of one stop on a + * multi-leg route. + */ + +// Traveler picker row: same surface as the cost-split rows (bg on --m-ic). +const TRAVELER_ROW_CLS = + 'flex w-full items-center gap-[9px] rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-[9px] text-start'; + +type BookingStatus = 'pending' | 'confirmed'; + +/** The sheet's own translate function, so these fields read the same strings as the rest of it. */ +type Translate = (key: string, params?: Record) => string; + +interface MBookingCodeStatusProps { + t: Translate; + code: string; + onCodeChange: (code: string) => void; + status: string; + onStatusChange: (status: BookingStatus) => void; +} + +/** Booking code and the pending / confirmed toggle, side by side. */ +export function MBookingCodeStatus({ t, code, onCodeChange, status, onStatusChange }: MBookingCodeStatusProps) { + return ( +
+
+ {t('reservations.confirmationCode')} + onCodeChange(e.target.value)} + placeholder={t('reservations.confirmationPlaceholder')} + className={FIELD_CLS} + /> +
+
+ {t('reservations.status')} +
+ {(['pending', 'confirmed'] as const).map((s) => ( + + ))} +
+
+
+ ); +} + +/** The notes label and its two-row textarea. */ +export function MBookingNotes({ + t, + value, + onChange, +}: { + t: Translate; + value: string; + onChange: (notes: string) => void; +}) { + return ( + <> + {t('reservations.notes')} +