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.
This commit is contained in:
Maurice
2026-10-09 15:56:40 +02:00
parent e86cb6a897
commit 3de3ffcd1c
5 changed files with 485 additions and 290 deletions
+5 -7
View File
@@ -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}',
@@ -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(
<MBookingCodeStatus
t={t}
code="ABC"
onCodeChange={onCodeChange}
status="pending"
onStatusChange={onStatusChange}
/>
);
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(<MBookingCodeStatus t={t} code="" onCodeChange={vi.fn()} status="confirmed" onStatusChange={vi.fn()} />);
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(<MBookingNotes t={t} value="Late check-in" onChange={onChange} />);
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(<MBookingTravelers t={t} tripMembers={[]} selectedIds={new Set()} onToggle={vi.fn()} />);
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(
<MBookingTravelers t={t} tripMembers={members} selectedIds={new Set([1])} onToggle={onToggle} />
);
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(
<MRouteStopCard
t={t}
roleLabel="Stop"
picker={<span>picker</span>}
isFirst={isFirst}
isLast={isLast}
onRemove={onRemove}
times={times}
onTimesChange={vi.fn()}
dayOptions={days}
>
<span>leg fields</span>
</MRouteStopCard>
);
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);
});
});
@@ -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, string | number>) => 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 (
<div className="mt-3 flex gap-2">
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.confirmationCode')}</Eyebrow>
<BookingCodeInput
value={code}
onChange={(e) => onCodeChange(e.target.value)}
placeholder={t('reservations.confirmationPlaceholder')}
className={FIELD_CLS}
/>
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.status')}</Eyebrow>
<div className="flex rounded-full bg-[color:var(--m-ic)] p-[3px]">
{(['pending', 'confirmed'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => onStatusChange(s)}
className={`flex-1 rounded-full py-[7px] text-[0.71875rem] font-semibold ${
status === s ? 'bg-m-act text-m-actfg' : 'text-m-muted'
}`}
>
{t(`reservations.${s}`)}
</button>
))}
</div>
</div>
</div>
);
}
/** The notes label and its two-row textarea. */
export function MBookingNotes({
t,
value,
onChange,
}: {
t: Translate;
value: string;
onChange: (notes: string) => void;
}) {
return (
<>
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('reservations.notes')}</Eyebrow>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={2}
placeholder={t('reservations.notesPlaceholder')}
className={FIELD_AREA_CLS}
/>
</>
);
}
function TravelerAvatar({ m, idx, dim }: { m: TripMember; idx: number; dim: boolean }) {
return m.avatar_url ? (
<img
src={m.avatar_url}
alt=""
style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', flexShrink: 0, opacity: dim ? 0.45 : 1 }}
/>
) : (
<span
style={{
width: 22,
height: 22,
borderRadius: '50%',
background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient,
color: '#fff',
display: 'grid',
placeItems: 'center',
fontSize: 8.8,
fontWeight: 700,
flexShrink: 0,
opacity: dim ? 0.45 : 1,
}}
>
{(m.username || '?').charAt(0).toUpperCase()}
</span>
);
}
interface MBookingTravelersProps {
t: Translate;
tripMembers: TripMember[];
selectedIds: Set<number>;
onToggle: (memberId: number) => void;
}
/** The trip members and guests on this booking, one toggle row each. */
export function MBookingTravelers({ t, tripMembers, selectedIds, onToggle }: MBookingTravelersProps) {
return (
<>
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('reservations.travelers.label')}</Eyebrow>
{tripMembers.length === 0 ? (
<div className="text-[0.71875rem] text-m-faint">{t('reservations.travelers.none')}</div>
) : (
<div className="flex flex-col gap-[6px]">
{tripMembers.map((m, idx) => {
const on = selectedIds.has(m.id);
return (
<button
key={m.id}
type="button"
onClick={() => onToggle(m.id)}
className={`${TRAVELER_ROW_CLS} ${on ? '' : 'opacity-60'}`}
>
<TravelerAvatar m={m} idx={idx} dim={!on} />
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-medium text-m-ink">{m.username}</span>
{m.is_guest && <GuestBadge size="xs" />}
{on && <Check size={15} strokeWidth={2.4} className="flex-none text-m-act" />}
</button>
);
})}
</div>
)}
</>
);
}
interface RouteStopTimes {
arrDayId: string | number;
arrTime: string;
depDayId: string | number;
depTime: string;
}
interface MRouteStopCardProps {
t: Translate;
roleLabel: string;
/** The airport or station picker beside the role label. */
picker: ReactNode;
isFirst: boolean;
isLast: boolean;
/** Drops this stop; offered on the stops between origin and destination. */
onRemove: () => void;
times: RouteStopTimes;
onTimesChange: (patch: Partial<RouteStopTimes>) => void;
dayOptions: { value: string | number; label: string; badge?: string }[];
/** Fields of the leg that leaves this stop, under its departure row. */
children?: ReactNode;
}
/**
* One stop on a multi-leg route: its role and picker, the arrival day and
* time unless it is the origin, and the departure day and time with the
* leg's own fields unless it is the destination.
*/
export function MRouteStopCard({
t,
roleLabel,
picker,
isFirst,
isLast,
onRemove,
times,
onTimesChange,
dayOptions,
children,
}: MRouteStopCardProps) {
return (
<div className="rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px]">
<div className="mb-[8px] flex items-center gap-2">
<span className="flex-none font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">
{roleLabel}
</span>
<div className="min-w-0 flex-1">{picker}</div>
{!isFirst && !isLast && (
<button type="button" onClick={onRemove} aria-label={t('common.delete')} className="flex-none text-m-faint">
<Trash2 size={14} strokeWidth={2} />
</button>
)}
</div>
{!isFirst && (
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.arrivalDate')}</Eyebrow>
<CustomSelect
value={times.arrDayId}
onChange={(v) => onTimesChange({ arrDayId: v })}
placeholder={t('dayplan.dayN', { n: '?' })}
options={dayOptions}
size="sm"
/>
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.arrivalTime')}</Eyebrow>
<CustomTimePicker value={times.arrTime} onChange={(v) => onTimesChange({ arrTime: v })} />
</div>
</div>
)}
{!isLast && (
<>
<div className={`flex gap-2 ${!isFirst ? 'mt-2' : ''}`}>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.departureDate')}</Eyebrow>
<CustomSelect
value={times.depDayId}
onChange={(v) => onTimesChange({ depDayId: v })}
placeholder={t('dayplan.dayN', { n: '?' })}
options={dayOptions}
size="sm"
/>
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.departureTime')}</Eyebrow>
<CustomTimePicker value={times.depTime} onChange={(v) => onTimesChange({ depTime: v })} />
</div>
</div>
{children}
</>
)}
</div>
);
}
@@ -1,13 +1,13 @@
import { useEffect, useMemo, useState } from 'react'
import { Check, Link2, Ticket } from 'lucide-react'
import { Link2, Ticket } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import { MBookingCodeStatus, MBookingNotes, MBookingTravelers } from './MBookingFields'
import { useAddonStore } from '../../../../store/addonStore'
import { useTranslation } from '../../../../i18n'
import CustomSelect from '../../../../components/shared/CustomSelect'
import CustomTimePicker from '../../../../components/shared/CustomTimePicker'
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker'
import { BookingCodeInput } from '../../../../components/shared/BookingCode'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import MBookingFilesCosts from './MBookingFilesCosts'
import { expenseRequestAfterSave, travelerIdsOf, travelersChanged, uploadBookingFiles } from '../../../../components/Planner/bookingFormModel'
import { useBookingExpenseIntent } from '../../../../components/Planner/useBookingExpenseIntent'
@@ -16,10 +16,7 @@ import {
} from '../../../../components/Planner/reservationFormModel'
import { useReservationForm } from '../../../../components/Planner/useReservationForm'
import { buildAssignmentOptions } from '../../../../components/Planner/assignmentOptions'
import GuestBadge from '../../../../components/shared/GuestBadge'
import { SPLIT_COLORS } from '../../../../components/Budget/BudgetPanel.constants'
import { useTripStore } from '../../../../store/tripStore'
import type { TripMember } from '../../../../types'
import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types'
import type { TripPlanner } from '../MTripShell'
import { stayPlaces } from '../../../../utils/stayPlaces'
@@ -29,9 +26,6 @@ export interface MReservationSheetProps {
onOpenExpense: (req: BookingExpenseRequest) => void
}
// 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'
const EMPTY = {
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
@@ -123,20 +117,6 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
const res = snap.res
const isHotel = form.type === 'hotel'
const TravelerAvatar = ({ m, idx, dim }: { m: TripMember; idx: number; dim: boolean }) =>
m.avatar_url
? <img src={m.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', flexShrink: 0, opacity: dim ? 0.45 : 1 }} />
: (
<span
style={{
width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient,
color: '#fff', display: 'grid', placeItems: 'center', fontSize: 8.8, fontWeight: 700, flexShrink: 0, opacity: dim ? 0.45 : 1,
}}
>
{(m.username || '?').charAt(0).toUpperCase()}
</span>
)
const fmtDate = (d?: string | null) =>
d ? new Date(`${d.slice(0, 10)}T00:00:00Z`).toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : undefined
@@ -353,34 +333,13 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
)}
{/* BOOKING CODE + STATUS */}
<div className="mt-3 flex gap-2">
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.confirmationCode')}</Eyebrow>
<BookingCodeInput
value={form.confirmation_number}
onChange={e => set('confirmation_number', e.target.value)}
placeholder={t('reservations.confirmationPlaceholder')}
className={FIELD_CLS}
/>
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.status')}</Eyebrow>
<div className="flex rounded-full bg-[color:var(--m-ic)] p-[3px]">
{(['pending', 'confirmed'] as const).map(s => (
<button
key={s}
type="button"
onClick={() => set('status', s)}
className={`flex-1 rounded-full py-[7px] text-[0.71875rem] font-semibold ${
form.status === s ? 'bg-m-act text-m-actfg' : 'text-m-muted'
}`}
>
{t(`reservations.${s}`)}
</button>
))}
</div>
</div>
</div>
<MBookingCodeStatus
t={t}
code={form.confirmation_number}
onCodeChange={value => set('confirmation_number', value)}
status={form.status}
onStatusChange={value => set('status', value)}
/>
{/* LINK */}
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('reservations.urlLabel')}</Eyebrow>
@@ -396,39 +355,10 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
</div>
{/* NOTES */}
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('reservations.notes')}</Eyebrow>
<textarea
value={form.notes}
onChange={e => set('notes', e.target.value)}
rows={2}
placeholder={t('reservations.notesPlaceholder')}
className={FIELD_AREA_CLS}
/>
<MBookingNotes t={t} value={form.notes} onChange={value => set('notes', value)} />
{/* TRAVELERS */}
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('reservations.travelers.label')}</Eyebrow>
{tripMembers.length === 0 ? (
<div className="text-[0.71875rem] text-m-faint">{t('reservations.travelers.none')}</div>
) : (
<div className="flex flex-col gap-[6px]">
{tripMembers.map((m, idx) => {
const on = travelerIds.has(m.id)
return (
<button
key={m.id}
type="button"
onClick={() => toggleTraveler(m.id)}
className={`${TRAVELER_ROW_CLS} ${on ? '' : 'opacity-60'}`}
>
<TravelerAvatar m={m} idx={idx} dim={!on} />
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-medium text-m-ink">{m.username}</span>
{m.is_guest && <GuestBadge size="xs" />}
{on && <Check size={15} strokeWidth={2.4} className="flex-none text-m-act" />}
</button>
)
})}
</div>
)}
<MBookingTravelers t={t} tripMembers={tripMembers} selectedIds={travelerIds} onToggle={toggleTraveler} />
{/* LINK TO A STOP IN THE PLAN (#2216) */}
{!isHotel && assignmentOptions.length > 0 && (
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown, ChevronUp, Plus, TrainFront, TramFront, Trash2, X } from 'lucide-react'
import { ChevronDown, ChevronUp, Plus, TrainFront, TramFront, X } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import { MBookingCodeStatus, MBookingNotes, MBookingTravelers, MRouteStopCard } from './MBookingFields'
import { useAddonStore } from '../../../../store/addonStore'
import { useTranslation } from '../../../../i18n'
import CustomSelect from '../../../../components/shared/CustomSelect'
@@ -17,12 +18,10 @@ import {
import { useTransportForm } from '../../../../components/Planner/useTransportForm'
import { expenseRequestAfterSave, travelerIdsOf, travelersChanged, uploadBookingFiles } from '../../../../components/Planner/bookingFormModel'
import { useBookingExpenseIntent } from '../../../../components/Planner/useBookingExpenseIntent'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import MBookingFilesCosts from './MBookingFilesCosts'
import GuestBadge from '../../../../components/shared/GuestBadge'
import { SPLIT_COLORS } from '../../../../components/Budget/BudgetPanel.constants'
import { useTripStore } from '../../../../store/tripStore'
import type { Place, Reservation, TripMember } from '../../../../types'
import type { Place, Reservation } from '../../../../types'
import type { BookingReviewDraft } from '../../../../components/Planner/parsedItemToDraft'
import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types'
import type { TripPlanner } from '../MTripShell'
@@ -32,9 +31,6 @@ export interface MTransportFormSheetProps {
onOpenExpense: (req: BookingExpenseRequest) => void
}
// 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'
/**
* Add/edit transport sheet — the mobile counterpart of the desktop
* TransportModal, driven by the planner's own editor flags (showTransportModal /
@@ -104,20 +100,6 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
const res = snap.res
const prefill = snap.prefill
const TravelerAvatar = ({ m, idx, dim }: { m: TripMember; idx: number; dim: boolean }) =>
m.avatar_url
? <img src={m.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', flexShrink: 0, opacity: dim ? 0.45 : 1 }} />
: (
<span
style={{
width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient,
color: '#fff', display: 'grid', placeItems: 'center', fontSize: 8.8, fontWeight: 700, flexShrink: 0, opacity: dim ? 0.45 : 1,
}}
>
{(m.username || '?').charAt(0).toUpperCase()}
</span>
)
const showModeToggle = !res && tripHasDates
const dayOptions = transportDayOptions(days, t, locale)
@@ -317,70 +299,43 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
const roleLabel = isFirst ? t('reservations.meta.from') : isLast ? t('reservations.meta.to') : t('reservations.layover.stop')
return (
<div key={i} className="flex flex-col gap-[6px]">
<div className="rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px]">
<div className="mb-[8px] flex items-center gap-2">
<span className="flex-none font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">{roleLabel}</span>
<MRouteStopCard
t={t}
roleLabel={roleLabel}
picker={<AirportSelect value={wp.airport} onChange={a => updateWp({ airport: a || null })} />}
isFirst={isFirst}
isLast={isLast}
onRemove={() => setWaypoints(prev => prev.filter((_, j) => j !== i))}
times={wp}
onTimesChange={updateWp}
dayOptions={dayOptions}
>
<div className="mt-2 flex gap-2">
<div className="min-w-0 flex-[1.2]">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.airline')}</Eyebrow>
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={FIELD_CLS} />
</div>
<div className="min-w-0 flex-1">
<AirportSelect value={wp.airport} onChange={a => updateWp({ airport: a || null })} />
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.flightNumber')}</Eyebrow>
<input type="text" value={wp.flight_number} onChange={e => updateWp({ flight_number: e.target.value })} placeholder="LH 123" className={FIELD_CLS} />
</div>
<div className="min-w-0 flex-[0.7]">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.seat')}</Eyebrow>
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="12A" className={FIELD_CLS} />
</div>
{!isFirst && !isLast && (
<button type="button" onClick={() => setWaypoints(prev => prev.filter((_, j) => j !== i))} aria-label={t('common.delete')} className="flex-none text-m-faint">
<Trash2 size={14} strokeWidth={2} />
</button>
)}
</div>
{!isFirst && (
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.arrivalDate')}</Eyebrow>
<CustomSelect value={wp.arrDayId} onChange={v => updateWp({ arrDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.arrivalTime')}</Eyebrow>
<CustomTimePicker value={wp.arrTime} onChange={v => updateWp({ arrTime: v })} />
</div>
{writesFlightLegs && (
<div className="mt-2">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.confirmationCode')}</Eyebrow>
<BookingCodeInput
value={wp.confirmation_number}
onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')}
className={FIELD_CLS}
/>
</div>
)}
{!isLast && (
<>
<div className={`flex gap-2 ${!isFirst ? 'mt-2' : ''}`}>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.departureDate')}</Eyebrow>
<CustomSelect value={wp.depDayId} onChange={v => updateWp({ depDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.departureTime')}</Eyebrow>
<CustomTimePicker value={wp.depTime} onChange={v => updateWp({ depTime: v })} />
</div>
</div>
<div className="mt-2 flex gap-2">
<div className="min-w-0 flex-[1.2]">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.airline')}</Eyebrow>
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={FIELD_CLS} />
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.flightNumber')}</Eyebrow>
<input type="text" value={wp.flight_number} onChange={e => updateWp({ flight_number: e.target.value })} placeholder="LH 123" className={FIELD_CLS} />
</div>
<div className="min-w-0 flex-[0.7]">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.seat')}</Eyebrow>
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="12A" className={FIELD_CLS} />
</div>
</div>
{writesFlightLegs && (
<div className="mt-2">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.confirmationCode')}</Eyebrow>
<BookingCodeInput
value={wp.confirmation_number}
onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')}
className={FIELD_CLS}
/>
</div>
)}
</>
)}
</div>
</MRouteStopCard>
{!isLast && (
<button
type="button"
@@ -409,70 +364,43 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
: t(cruise ? 'reservations.cruise.port' : 'reservations.layover.stop')
return (
<div key={i} className="flex flex-col gap-[6px]">
<div className="rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px]">
<div className="mb-[8px] flex items-center gap-2">
<span className="flex-none font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">{roleLabel}</span>
<MRouteStopCard
t={t}
roleLabel={roleLabel}
picker={<LocationSelect value={wp.location} onChange={l => updateWp({ location: l || null })} places={locationPicks} />}
isFirst={isFirst}
isLast={isLast}
onRemove={() => setTrainWaypoints(prev => prev.filter((_, j) => j !== i))}
times={wp}
onTimesChange={updateWp}
dayOptions={dayOptions}
>
{!cruise && <div className="mt-2 flex gap-2">
<div className="min-w-0 flex-[1.2]">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.trainNumber')}</Eyebrow>
<input type="text" value={wp.train_number} onChange={e => updateWp({ train_number: e.target.value })} placeholder="ICE 123" className={FIELD_CLS} />
</div>
<div className="min-w-0 flex-1">
<LocationSelect value={wp.location} onChange={l => updateWp({ location: l || null })} places={locationPicks} />
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.platform')}</Eyebrow>
<input type="text" value={wp.platform} onChange={e => updateWp({ platform: e.target.value })} placeholder="12" className={FIELD_CLS} />
</div>
{!isFirst && !isLast && (
<button type="button" onClick={() => setTrainWaypoints(prev => prev.filter((_, j) => j !== i))} aria-label={t('common.delete')} className="flex-none text-m-faint">
<Trash2 size={14} strokeWidth={2} />
</button>
)}
</div>
{!isFirst && (
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.arrivalDate')}</Eyebrow>
<CustomSelect value={wp.arrDayId} onChange={v => updateWp({ arrDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.arrivalTime')}</Eyebrow>
<CustomTimePicker value={wp.arrTime} onChange={v => updateWp({ arrTime: v })} />
</div>
<div className="min-w-0 flex-[0.7]">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.seat')}</Eyebrow>
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="42A" className={FIELD_CLS} />
</div>
</div>}
{writesTrainLegs && !cruise && (
<div className="mt-2">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.confirmationCode')}</Eyebrow>
<BookingCodeInput
value={wp.confirmation_number}
onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')}
className={FIELD_CLS}
/>
</div>
)}
{!isLast && (
<>
<div className={`flex gap-2 ${!isFirst ? 'mt-2' : ''}`}>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.departureDate')}</Eyebrow>
<CustomSelect value={wp.depDayId} onChange={v => updateWp({ depDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.departureTime')}</Eyebrow>
<CustomTimePicker value={wp.depTime} onChange={v => updateWp({ depTime: v })} />
</div>
</div>
{!cruise && <div className="mt-2 flex gap-2">
<div className="min-w-0 flex-[1.2]">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.trainNumber')}</Eyebrow>
<input type="text" value={wp.train_number} onChange={e => updateWp({ train_number: e.target.value })} placeholder="ICE 123" className={FIELD_CLS} />
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.platform')}</Eyebrow>
<input type="text" value={wp.platform} onChange={e => updateWp({ platform: e.target.value })} placeholder="12" className={FIELD_CLS} />
</div>
<div className="min-w-0 flex-[0.7]">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.meta.seat')}</Eyebrow>
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="42A" className={FIELD_CLS} />
</div>
</div>}
{writesTrainLegs && !cruise && (
<div className="mt-2">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.confirmationCode')}</Eyebrow>
<BookingCodeInput
value={wp.confirmation_number}
onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')}
className={FIELD_CLS}
/>
</div>
)}
</>
)}
</div>
</MRouteStopCard>
{!isLast && (
<button
type="button"
@@ -589,69 +517,19 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
)}
{/* BOOKING CODE + STATUS */}
<div className="mt-3 flex gap-2">
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.confirmationCode')}</Eyebrow>
<BookingCodeInput
value={form.confirmation_number}
onChange={e => set('confirmation_number', e.target.value)}
placeholder={t('reservations.confirmationPlaceholder')}
className={FIELD_CLS}
/>
</div>
<div className="min-w-0 flex-1">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.status')}</Eyebrow>
<div className="flex rounded-full bg-[color:var(--m-ic)] p-[3px]">
{(['pending', 'confirmed'] as const).map(s => (
<button
key={s}
type="button"
onClick={() => set('status', s)}
className={`flex-1 rounded-full py-[7px] text-[0.71875rem] font-semibold ${
form.status === s ? 'bg-m-act text-m-actfg' : 'text-m-muted'
}`}
>
{t(`reservations.${s}`)}
</button>
))}
</div>
</div>
</div>
{/* NOTES */}
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('reservations.notes')}</Eyebrow>
<textarea
value={form.notes}
onChange={e => set('notes', e.target.value)}
rows={2}
placeholder={t('reservations.notesPlaceholder')}
className={FIELD_AREA_CLS}
<MBookingCodeStatus
t={t}
code={form.confirmation_number}
onCodeChange={value => set('confirmation_number', value)}
status={form.status}
onStatusChange={value => set('status', value)}
/>
{/* NOTES */}
<MBookingNotes t={t} value={form.notes} onChange={value => set('notes', value)} />
{/* TRAVELERS */}
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('reservations.travelers.label')}</Eyebrow>
{tripMembers.length === 0 ? (
<div className="text-[0.71875rem] text-m-faint">{t('reservations.travelers.none')}</div>
) : (
<div className="flex flex-col gap-[6px]">
{tripMembers.map((m, idx) => {
const on = travelerIds.has(m.id)
return (
<button
key={m.id}
type="button"
onClick={() => toggleTraveler(m.id)}
className={`${TRAVELER_ROW_CLS} ${on ? '' : 'opacity-60'}`}
>
<TravelerAvatar m={m} idx={idx} dim={!on} />
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-medium text-m-ink">{m.username}</span>
{m.is_guest && <GuestBadge size="xs" />}
{on && <Check size={15} strokeWidth={2.4} className="flex-none text-m-act" />}
</button>
)
})}
</div>
)}
<MBookingTravelers t={t} tripMembers={tripMembers} selectedIds={travelerIds} onToggle={toggleTraveler} />
{/* FILES + COSTS */}
<MBookingFilesCosts