mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user