mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(reservations): traveler filter + transit-card badge (desktop) and full mobile traveler UI (cards + sheet pickers)
This commit is contained in:
@@ -623,6 +623,11 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{r.travelers && r.travelers.length > 0 && (
|
||||
<div style={{ paddingLeft: 44 }} onClick={e => e.stopPropagation()}>
|
||||
<TravelerChips travelers={r.travelers} onSetTravelers={() => {}} readOnly compact={false} />
|
||||
</div>
|
||||
)}
|
||||
<PluginCardFooter items={contributions} tripId={tripId} />
|
||||
{/* Reservation-detail plugin slots: sandboxed, scoped to this journey. The
|
||||
card itself is clickable, so keep frame interactions from opening it. */}
|
||||
@@ -705,11 +710,31 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
})
|
||||
}
|
||||
|
||||
// Filter by assigned traveler (#1517, #1557), persisted alongside the type filter.
|
||||
const [travelerFilters, setTravelerFilters] = useState<Set<number>>(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(`${storageKey}-travelers`)
|
||||
return saved ? new Set<number>(JSON.parse(saved)) : new Set<number>()
|
||||
} catch { return new Set<number>() }
|
||||
})
|
||||
const toggleTravelerFilter = (userId: number) => {
|
||||
setTravelerFilters(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(userId)) next.delete(userId); else next.add(userId)
|
||||
sessionStorage.setItem(`${storageKey}-travelers`, JSON.stringify([...next]))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
|
||||
|
||||
const filtered = useMemo(() =>
|
||||
typeFilters.size === 0 ? reservations : reservations.filter(r => typeFilters.has(r.type)),
|
||||
[reservations, typeFilters])
|
||||
const filtered = useMemo(() => {
|
||||
let list = typeFilters.size === 0 ? reservations : reservations.filter(r => typeFilters.has(r.type))
|
||||
if (travelerFilters.size > 0) {
|
||||
list = list.filter(r => (r.travelers || []).some(tv => travelerFilters.has(tv.user_id)))
|
||||
}
|
||||
return list
|
||||
}, [reservations, typeFilters, travelerFilters])
|
||||
|
||||
// Chronological order (#1507): day-linked transports often carry no date in
|
||||
// reservation_time, so resolve each entry to an effective departure datetime —
|
||||
@@ -818,6 +843,31 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Filter by traveler — members/guests assigned to bookings (#1517/#1557). */}
|
||||
{tripMembers.length > 1 && reservations.some(r => (r.travelers || []).length > 0) && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }} title={t('reservations.travelers.label')}>
|
||||
{tripMembers.map(tm => {
|
||||
const active = travelerFilters.has(tm.id)
|
||||
return (
|
||||
<button key={tm.id} type="button" onClick={() => toggleTravelerFilter(tm.id)} title={tm.username}
|
||||
style={{
|
||||
width: 26, height: 26, borderRadius: '50%', cursor: 'pointer', padding: 0, flexShrink: 0,
|
||||
border: active ? '2px solid var(--accent)' : '2px solid var(--border-primary)',
|
||||
background: 'var(--bg-tertiary)', overflow: 'hidden',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 10, fontWeight: 700, color: 'var(--text-muted)',
|
||||
opacity: active || travelerFilters.size === 0 ? 1 : 0.45,
|
||||
transition: 'opacity 0.15s, border-color 0.15s',
|
||||
}}>
|
||||
{tm.avatar_url
|
||||
? <img src={tm.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="" />
|
||||
: tm.username?.[0]?.toUpperCase()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto', flexShrink: 0 }}>
|
||||
{onImport && bookingImportAvailable && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { FileText, Hotel, Link2, ParkingSquare, Plus, Ticket, Users, Utensils } from 'lucide-react'
|
||||
import { Check, FileText, Hotel, Link2, ParkingSquare, Plus, Ticket, Users, Utensils } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import { useAddonStore } from '../../../../store/addonStore'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
@@ -10,6 +10,10 @@ import CustomTimePicker from '../../../../components/shared/CustomTimePicker'
|
||||
import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
|
||||
import PlFileAttach from './PlFileAttach'
|
||||
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'
|
||||
|
||||
@@ -27,6 +31,9 @@ const TYPE_OPTIONS = [
|
||||
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
|
||||
]
|
||||
|
||||
// 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-left'
|
||||
|
||||
const EMPTY = {
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
@@ -45,7 +52,7 @@ const EMPTY = {
|
||||
*/
|
||||
export default function MReservationSheet({ planner, onOpenExpense }: MReservationSheetProps) {
|
||||
const {
|
||||
t, toast, tripId, days, places, tripAccommodations, selectedDayId,
|
||||
t, toast, tripId, days, places, tripAccommodations, tripMembers, selectedDayId,
|
||||
showReservationModal, setShowReservationModal,
|
||||
editingReservation, setEditingReservation, reservationPrefill,
|
||||
bookingForAssignmentId, setBookingForAssignmentId,
|
||||
@@ -53,11 +60,15 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
handleSaveReservation, canUploadFiles, tripActions,
|
||||
} = planner
|
||||
const { locale } = useTranslation()
|
||||
const setReservationTravelers = useTripStore(s => s.setReservationTravelers)
|
||||
|
||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||
|
||||
const [form, setForm] = useState(EMPTY)
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
// Travelers assigned to this booking (#1517) — seeded from the editing
|
||||
// reservation on open, persisted separately after the save resolves.
|
||||
const [travelerIds, setTravelerIds] = useState<Set<number>>(new Set())
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
// Ref (not state) so handleSubmit reads the intent set by the same click.
|
||||
const expenseIntentRef = useRef(false)
|
||||
@@ -71,6 +82,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
setSnap({ res: editingReservation, assignmentId: bookingForAssignmentId ?? null })
|
||||
expenseIntentRef.current = false
|
||||
setPendingFiles([])
|
||||
setTravelerIds(new Set((editingReservation?.travelers || []).map(tv => tv.user_id)))
|
||||
|
||||
const res = editingReservation
|
||||
if (res) {
|
||||
@@ -122,6 +134,26 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
const isHotel = form.type === 'hotel'
|
||||
const set = (field: keyof typeof EMPTY, value: string | number) => setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
const toggleTraveler = (id: number) => setTravelerIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
|
||||
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 isEndBeforeStart = (() => {
|
||||
if (isHotel || !form.end_date || !form.reservation_time) return false
|
||||
const sDate = form.reservation_time.split('T')[0]
|
||||
@@ -194,6 +226,15 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
}
|
||||
}
|
||||
const saved = await handleSaveReservation(saveData as never)
|
||||
// Persist the traveler assignment once we have the reservation id (from the
|
||||
// save result on create, or the edited reservation) — only when it changed.
|
||||
const savedId = saved?.id ?? res?.id
|
||||
if (savedId) {
|
||||
const original = (res?.travelers || []).map(tv => tv.user_id)
|
||||
const next = [...travelerIds]
|
||||
const changed = original.length !== next.length || next.some(id => !original.includes(id))
|
||||
if (changed) await setReservationTravelers(tripId, savedId, next)
|
||||
}
|
||||
if (!res?.id && saved?.id && pendingFiles.length > 0 && canUploadFiles) {
|
||||
for (const file of pendingFiles) {
|
||||
const fd = new FormData()
|
||||
@@ -461,6 +502,31 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
className={FIELD_AREA_CLS}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* FILES */}
|
||||
{canUploadFiles && (
|
||||
<PlFileAttach
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Bike, Bus, Car, CarTaxiFront, Plane, Plus, Route, Sailboat, Ship, Train, TrainFront, TramFront, Trash2 } from 'lucide-react'
|
||||
import { Bike, Bus, Car, CarTaxiFront, Check, Plane, Plus, Route, Sailboat, Ship, Train, TrainFront, TramFront, Trash2 } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import { useAddonStore } from '../../../../store/addonStore'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
@@ -13,7 +13,10 @@ import LocationSelect, { type LocationPoint } from '../../../../components/Plann
|
||||
import TransitSearchPanel from '../../../../components/Planner/TransitSearchPanel'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
|
||||
import PlFileAttach from './PlFileAttach'
|
||||
import type { Day, Place, Reservation, ReservationEndpoint } from '../../../../types'
|
||||
import GuestBadge from '../../../../components/shared/GuestBadge'
|
||||
import { SPLIT_COLORS } from '../../../../components/Budget/BudgetPanel.constants'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import type { Day, Place, Reservation, ReservationEndpoint, TripMember } from '../../../../types'
|
||||
import type { BookingReviewDraft } from '../../../../components/Planner/parsedItemToDraft'
|
||||
import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
@@ -92,6 +95,9 @@ function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm
|
||||
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '' }
|
||||
}
|
||||
|
||||
// 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-left'
|
||||
|
||||
const EMPTY = {
|
||||
title: '',
|
||||
type: 'flight' as TransportType,
|
||||
@@ -115,7 +121,7 @@ const EMPTY = {
|
||||
*/
|
||||
export default function MTransportFormSheet({ planner, onOpenExpense }: MTransportFormSheetProps) {
|
||||
const {
|
||||
t, toast, tripId, trip, days, places, assignments, tripAccommodations,
|
||||
t, toast, tripId, trip, days, places, assignments, tripAccommodations, tripMembers,
|
||||
showTransportModal, setShowTransportModal,
|
||||
editingTransport, setEditingTransport,
|
||||
transportModalDayId, setTransportModalDayId,
|
||||
@@ -126,6 +132,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
canUploadFiles, tripActions,
|
||||
} = planner
|
||||
const { locale } = useTranslation()
|
||||
const setReservationTravelers = useTripStore(s => s.setReservationTravelers)
|
||||
|
||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||
const tripHasDates = Boolean(trip?.start_date && trip?.end_date)
|
||||
@@ -137,6 +144,9 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
|
||||
const [trainWaypoints, setTrainWaypoints] = useState<StationWaypointForm[]>([emptyStationWaypoint(), emptyStationWaypoint()])
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
// Travelers assigned to this booking (#1517) — seeded from the editing
|
||||
// reservation on open, persisted separately after the save resolves.
|
||||
const [travelerIds, setTravelerIds] = useState<Set<number>>(new Set())
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
// Ref (not state) so handleSubmit reads the intent set by the same click — a
|
||||
// state value would be stale in that render's closure and never open the editor.
|
||||
@@ -153,6 +163,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
setDeleteArmed(false)
|
||||
// On a review-import, seed the booking's Files with the parsed source document.
|
||||
setPendingFiles(!editingTransport && transportPrefill?._sourceFiles ? transportPrefill._sourceFiles : [])
|
||||
setTravelerIds(new Set((editingTransport?.travelers || []).map(tv => tv.user_id)))
|
||||
|
||||
// Edit uses the saved `editingTransport`; a review-import populates from the
|
||||
// prefill. Either way the init reads the same fields; the reservation still
|
||||
@@ -272,6 +283,26 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
const prefill = snap.prefill
|
||||
const set = (field: keyof typeof EMPTY, value: string | number) => setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
const toggleTraveler = (id: number) => setTravelerIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
|
||||
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 = [
|
||||
@@ -469,6 +500,15 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
}
|
||||
}
|
||||
const saved = await saveTransport(payload)
|
||||
// Persist the traveler assignment once we have the reservation id (from the
|
||||
// save result on create, or the edited reservation) — only when it changed.
|
||||
const savedId = saved?.id ?? res?.id
|
||||
if (savedId) {
|
||||
const original = (res?.travelers || []).map(tv => tv.user_id)
|
||||
const next = [...travelerIds]
|
||||
const changed = original.length !== next.length || next.some(id => !original.includes(id))
|
||||
if (changed) await setReservationTravelers(tripId, savedId, next)
|
||||
}
|
||||
if (!res?.id && saved?.id && pendingFiles.length > 0 && canUploadFiles) {
|
||||
for (const file of pendingFiles) {
|
||||
const fd = new FormData()
|
||||
@@ -853,6 +893,31 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
className={FIELD_AREA_CLS}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* FILES */}
|
||||
{canUploadFiles && (
|
||||
<PlFileAttach
|
||||
|
||||
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Reservation } from '../../../../types'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import { CountPill, Field, SectionHeader, StatusDot, TabScroller } from './tabChrome'
|
||||
import { CountPill, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars } from './tabChrome'
|
||||
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||
import { groupTransports, orderedEndpoints, parseTransportMeta } from './transportsModel'
|
||||
import { BOOKING_TYPE_COLOR } from './bookingsModel'
|
||||
@@ -214,6 +214,8 @@ function BookingCard({ res, planner, canEdit, compact }: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TravelerAvatars travelers={res.travelers || []} label={t('reservations.travelers.label')} />
|
||||
|
||||
{res.location && (
|
||||
<div className="mt-2 flex items-center gap-[6px] rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-[7px]">
|
||||
<MapPin size={12} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
|
||||
@@ -7,7 +7,7 @@ import { openFile } from '../../../../utils/fileDownload'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Reservation } from '../../../../types'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import { CountPill, Field, SectionHeader, StatusDot, TabScroller } from './tabChrome'
|
||||
import { CountPill, Field, SectionHeader, StatusDot, TabScroller, TravelerAvatars } from './tabChrome'
|
||||
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||
import {
|
||||
TRANSPORT_TYPE_COLOR,
|
||||
@@ -247,6 +247,8 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TravelerAvatars travelers={res.travelers || []} label={t('reservations.travelers.label')} />
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="mb-[3px] font-geist text-[0.5625rem] font-bold uppercase tracking-[.08em] text-m-faint">
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import type { ReservationTraveler } from '@trek/shared'
|
||||
import { avatarSrc } from '../../../../utils/avatarSrc'
|
||||
import GuestBadge from '../../../../components/shared/GuestBadge'
|
||||
|
||||
/**
|
||||
* Scroll body shared by the list-style trip tabs (transports, bookings, costs,
|
||||
@@ -89,3 +92,41 @@ export function Field({ label, children, className = '', tabular = false }: {
|
||||
export function StatusDot({ color }: { color: string }) {
|
||||
return <span className="h-[7px] w-[7px] flex-none rounded-full" style={{ background: color }} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only avatar-chip cluster for a reservation's assigned travelers (#1517).
|
||||
* The mobile transport / booking cards only display them — assignment editing
|
||||
* lives in the reservation sheet, so there is no picker here. Reuses the shared
|
||||
* avatarSrc + GuestBadge like the cost member chips; renders nothing when nobody
|
||||
* is assigned. `label` comes from 'reservations.travelers.label'.
|
||||
*/
|
||||
export function TravelerAvatars({ travelers, label }: {
|
||||
travelers: ReservationTraveler[]
|
||||
label: string
|
||||
}) {
|
||||
if (travelers.length === 0) return null
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="mb-[3px] font-geist text-[0.5625rem] font-bold uppercase tracking-[.08em] text-m-faint">
|
||||
{label}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-[6px]">
|
||||
{travelers.map(tv => {
|
||||
const src = tv.avatar_url || avatarSrc(tv.avatar)
|
||||
return (
|
||||
<span
|
||||
key={tv.user_id}
|
||||
className="flex items-center gap-[6px] rounded-full border border-[color:var(--m-rowbr)] bg-m-card py-[3px] pl-[3px] pr-[10px]"
|
||||
>
|
||||
<span className="flex h-[20px] w-[20px] flex-none items-center justify-center overflow-hidden rounded-full bg-m-act text-[0.5625rem] font-extrabold text-m-actfg">
|
||||
{src ? <img src={src} alt="" className="h-full w-full object-cover" /> : tv.username?.[0]?.toUpperCase()}
|
||||
</span>
|
||||
<span className="truncate text-[0.71875rem] font-semibold text-m-ink">{tv.username}</span>
|
||||
{!!tv.is_guest && <GuestBadge size="xs" />}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user