import { useState, useMemo, useEffect, type CSSProperties } from 'react'
import { createPortal } from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import {
Plane, Hotel, Utensils, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Ticket, FileText, MapPin,
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
ExternalLink, Lightbulb, Link2, Clock, ArrowRight, AlertCircle, Download,
TramFront, Footprints, StickyNote, ParkingSquare,
} from 'lucide-react'
import { openFile } from '../../utils/fileDownload'
import { safeExternalHref } from '../../utils/safeUrl'
import { TransitTitle, TransitLegChips, TransitMetaBadges, fmtTransitDuration } from './transitDisplay'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { markdownLinkComponents } from '../shared/markdownLink'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
import type { ViewContribution } from '../../api/client'
import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
import { usePluginStore, type ActivePlugin } from '../../store/pluginStore'
import PluginFrame from '../Plugins/PluginFrame'
import { splitReservationDateTime, formatTime, cleanAmountText } from '../../utils/formatters'
import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
import EmptyState from '../shared/EmptyState'
import { TravelerAvatarRow, TravelerFilterAvatars } from './TravelerPicker'
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
interface AssignmentLookupEntry {
dayNumber: number
dayTitle: string | null
dayDate: string
placeName: string
startTime: string | null
endTime: string | null
}
const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane, color: '#3b82f6' },
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel, color: '#8b5cf6' },
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils, color: '#ef4444' },
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train, color: '#06b6d4' },
{ value: 'bus', labelKey: 'reservations.type.bus', Icon: Bus, color: '#059669' },
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car, color: '#6b7280' },
{ value: 'taxi', labelKey: 'reservations.type.taxi', Icon: CarTaxiFront, color: '#ca8a04' },
{ value: 'bicycle', labelKey: 'reservations.type.bicycle', Icon: Bike, color: '#84cc16' },
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship, color: '#0ea5e9' },
{ value: 'ferry', labelKey: 'reservations.type.ferry', Icon: Sailboat, color: '#0d9488' },
{ value: 'transit', labelKey: 'reservations.type.transit', Icon: TramFront, color: '#7c3aed' },
{ value: 'transport_other', labelKey: 'reservations.type.transport_other', Icon: Route, color: '#6b7280' },
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket, color: '#f59e0b' },
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users, color: '#10b981' },
{ value: 'parking', labelKey: 'reservations.type.parking', Icon: ParkingSquare, color: '#2563eb' },
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText, color: '#6b7280' },
]
function getType(type) {
return TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]
}
function buildAssignmentLookup(days, assignments) {
const map = {}
for (const day of (days || [])) {
const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
for (const a of da) {
if (!a.place) continue
map[a.id] = { dayNumber: day.day_number, dayTitle: day.title, dayDate: day.date, placeName: a.place.name, startTime: a.place.place_time, endTime: a.place.end_time }
}
}
return map
}
/* ── Shared field label/value styles ── */
const fieldLabelClass = 'text-[10px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[5px]'
const fieldValueClass = 'text-[13px] font-medium text-content px-[10px] py-[8px] bg-surface-tertiary rounded-[10px]'
// A booking code is only a control while the blur setting is on: then it lifts on
// hover and toggles on click or Enter. With blurring off it is plain text, so it
// stays a plain element instead of a button that can never do anything.
function ConfirmationCode({ code, blurred, revealed, onReveal }: {
code: React.ReactNode
blurred: boolean
revealed: boolean
onReveal: (next: boolean | ((v: boolean) => boolean)) => void
}) {
const codeStyle: CSSProperties = {
fontFamily: '"SF Mono", "JetBrains Mono", Menlo, monospace', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))',
filter: blurred && !revealed ? 'blur(5px)' : 'none',
transition: 'filter 0.2s',
}
if (!blurred) return
{code}
return (
)
}
interface ReservationCardProps {
r: Reservation
tripId: number
onEdit: (reservation: Reservation) => void
onDelete: (id: number) => void
files?: TripFile[]
onNavigateToFiles: () => void
assignmentLookup: Record
canEdit: boolean
days?: Day[]
contributions?: ViewContribution[]
/** Plugins that declared a reservation-detail slot — mounted at the card's foot, scoped to this reservation. */
detailPlugins?: ActivePlugin[]
}
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit, days = [], contributions = [], detailPlugins = [] }: ReservationCardProps) {
const toast = useToast()
const { t, locale } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
const [codeRevealed, setCodeRevealed] = useState(false)
const typeInfo = getType(r.type)
const TypeIcon = typeInfo.Icon
const confirmed = r.status === 'confirmed'
// A multi-leg AirTrail import is detached from sync *by design* — AirTrail has
// no single flight to round-trip a layover chain to, so it's created with
// sync_enabled=0 (#1535). Distinguish it from a flight that was removed
// upstream so the badge doesn't falsely claim it "was removed in AirTrail" (#1646).
// Mirror the server's hasLocalMultiLegShape (airtrailSync.ts): a metadata legs
// array of length > 1, OR — for a flight grown into multiple legs locally, which
// carries no legs array — more than two endpoints. Both are detached, not removed.
const airtrailMultiLeg = r.external_source === 'airtrail' && !r.sync_enabled && (() => {
try {
const m = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
if (Array.isArray(m?.legs) && m.legs.length > 1) return true
} catch { /* malformed metadata — fall through to the endpoint count */ }
return (r.endpoints || []).length > 2
})()
const attachedFiles = files.filter(f => f.reservation_id === r.id || (f.linked_reservation_ids || []).includes(r.id))
const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null
// A booking link is deliberately free-form - people paste bare hosts and long
// provider deep links - so this refuses the schemes that execute in this
// origin and leaves everything else linkable.
const bookingUrl = safeExternalHref(r.url)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const handleDelete = async () => {
setShowDeleteConfirm(false)
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
}
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const startDt = splitReservationDateTime(r.reservation_time)
const endDt = splitReservationDateTime(r.reservation_end_time)
const fmtDate = (date: string) =>
new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
const hasDate = !!startDt.date
const hasTime = !!(startDt.time || endDt.time)
const hasCode = !!r.confirmation_number
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
const isHotel = r.type === 'hotel'
// For a hotel linked to an accommodation, the accommodation's own start/end days are
// the source of truth for the stay range: a stale day_id left behind by a range edit
// would otherwise mislabel the card, so prefer the accommodation ids here (#1383).
const startDay = (isHotel && r.accommodation_start_day_id) ? days.find(d => d.id === r.accommodation_start_day_id)
: r.day_id ? days.find(d => d.id === r.day_id)
: undefined
const endDay = (isHotel && r.accommodation_end_day_id) ? days.find(d => d.id === r.accommodation_end_day_id)
: r.end_day_id ? days.find(d => d.id === r.end_day_id)
: undefined
const DayLabel = ({ day }: { day: typeof startDay }) => {
if (!day) return null
const name = day.title || t('dayplan.dayN', { n: day.day_number })
const badge = day.date
? new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
: null
return (
{name}
{badge && (
{badge}
)}
)
}
return (
e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
>
{/* Header — wraps to a second row on narrow screens so the status/category chips
never collide with the title. */}
{/* Day label for transport/hotel reservations linked to days */}
{(isTransportType || isHotel) && startDay && (
{t('reservations.date')}
{endDay && endDay.id !== startDay.id && (
<>–>
)}
)}
{/* Date / Time row — hidden for a hotel linked to an accommodation: its stay
already shows as the day-range label above, and a reservation_time stamped
on the auto-created reservation would otherwise duplicate it (#1383). */}
{(hasDate || hasTime) && !(isHotel && (r.accommodation_start_day_id || r.accommodation_end_day_id)) && (
)}
{(() => {
// Full route over all waypoints (from · stops · to), ordered by sequence.
const eps = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
if (eps.length < 2) return null
return (
{eps.map((ep, i) => (
{i > 0 && }
{ep.name}
))}
)
})()}
{/* Per-segment booking codes (#1943). A stopover booking can carry its own
reference per leg, and the cell above only ever shows the booking's own.
The reveal is shared with that cell on purpose: uncovering the codes is
one gesture per card, not one per segment. */}
{(() => {
if (r.type !== 'flight' && r.type !== 'train') return null
const legs = r.type === 'flight' ? getFlightLegs(r) : getTrainLegs(r)
if (legs.length < 2) return null
const coded = legs.filter(l => l.confirmation_number)
if (coded.length === 0) return null
return (
onOpen(r)}
role="button"
// No press-scale on the card — it would shift the nested controls out
// from under the pointer mid-click (#2158).
data-no-press
tabIndex={0}
onKeyDown={e => { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onOpen(r) } }}
style={{ borderRadius: 12, border: '1px solid rgba(124,58,237,0.22)', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 9, cursor: 'pointer', transition: 'box-shadow 0.15s ease' }}
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
>
{canEdit && (
)}
{transit && (
)}
{r.notes && (
{r.notes.split('\n')[0]}
)}
{r.travelers && r.travelers.length > 0 && (
e.stopPropagation()}>
)}
{/* Reservation-detail plugin slots: sandboxed, scoped to this journey. The
card itself is clickable, so keep frame interactions from opening it. */}
{detailPlugins.length > 0 && (