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')} +