import { useEffect, useRef, useState } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { ArrowRight, ArrowRightLeft, Bold, CalendarDays, Clock, Code, Footprints, Heading2, Italic, Link2, List, ListChecks, MoveRight, Pencil, RefreshCw, Strikethrough, TramFront, Trash2 } from 'lucide-react' import Modal from '../shared/Modal' import ConfirmDialog from '../shared/ConfirmDialog' import { DialogShell, DialogSection, DialogFooter, FooterSpacer, DialogButton, DeleteButton, PILL, fs } from '../shared/DialogShell' import { Segmented, TEXTAREA } from '../shared/dialogParts' import { Tooltip } from '../shared/Tooltip' import { BookingDialogHeader, TypePill } from './bookings/BookingDialogShell' import { TransitLegs } from './bookings/transitParts' import { deleteBodyKey } from './bookings/bookingsModel' import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import { useTripStore } from '../../store/tripStore' import { splitReservationDateTime, formatTime } from '../../utils/formatters' import { TransitTitle, TransitMetaBadges, TransitWalkDivider, fmtTransitDuration } from './transitDisplay' import type { Reservation } from '../../types' import { useIsPhone } from '../../mobile/useIsPhone' /** * The journey view for an automated public-transit entry (#1065): a roomy modal * around the stop-by-stop itinerary. The title renames inline right in the * header, notes get the full width with markdown support, and "Change route" * re-enters the transit search pre-seeded with this journey's route. "Edit * details" hands off to the full transport editor for the booking fields * (travelers, costs, files, code, status — #2148). */ interface TransitLegMeta { mode?: string line?: string | null line_color?: string | null line_text_color?: string | null headsign?: string | null agency?: string | null duration?: number stops?: number from?: { name?: string; time?: string | null; track?: string | null } to?: { name?: string; time?: string | null; track?: string | null } } interface TransitJourneyModalProps { reservation: Reservation onClose: () => void /** Partial field update — endpoints + itinerary stay untouched. */ onSave: (fields: { title: string; notes: string | null }) => Promise onDelete: () => Promise onChangeRoute: () => void /** Switch to the full transport editor (travelers, costs, files, code, status). */ onEditDetails?: () => void canEdit: boolean } export default function TransitJourneyModal({ reservation, onClose, onSave, onDelete, onChangeRoute, onEditDetails, canEdit }: TransitJourneyModalProps) { const { t, locale } = useTranslation() const isMobile = useIsPhone() const timeFormat = useSettingsStore(st => st.settings.time_format) || '24h' const budgetItems = useTripStore(st => st.budgetItems) const res = reservation const meta = typeof res.metadata === 'string' ? (() => { try { return JSON.parse(res.metadata || '{}') } catch { return {} } })() : (res.metadata || {}) const transit = meta.transit && Array.isArray(meta.transit.legs) ? meta.transit : null const [title, setTitle] = useState(res.title || '') const [editingTitle, setEditingTitle] = useState(false) const [notes, setNotes] = useState(res.notes || '') // Existing notes open rendered; the write tab is for editing. const [notesTab, setNotesTab] = useState<'write' | 'preview'>(() => (res.notes ? 'preview' : 'write')) const [saving, setSaving] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) const titleInputRef = useRef(null) const notesRef = useRef(null) // Markdown toolbar: wrap the selection / prefix the current lines, then // restore focus and a sensible cursor. const applyMd = (action: { wrap?: [string, string]; linePrefix?: string }) => { const el = notesRef.current if (!el) return const start = el.selectionStart ?? 0 const end = el.selectionEnd ?? 0 const value = notes let next = value let selStart = start let selEnd = end if (action.wrap) { const [pre, post] = action.wrap const selected = value.slice(start, end) next = value.slice(0, start) + pre + selected + post + value.slice(end) selStart = start + pre.length selEnd = selStart + selected.length } else if (action.linePrefix) { const prefix = action.linePrefix const lineStart = value.lastIndexOf('\n', start - 1) + 1 const block = value.slice(lineStart, end) const prefixed = block.split('\n').map(l => prefix + l).join('\n') next = value.slice(0, lineStart) + prefixed + value.slice(end) selStart = start + prefix.length selEnd = end + (prefixed.length - block.length) } setNotes(next) requestAnimationFrame(() => { el.focus() el.setSelectionRange(selStart, selEnd) }) } const MD_TOOLS: { Icon: typeof Bold; label: string; action: { wrap?: [string, string]; linePrefix?: string } }[] = [ { Icon: Bold, label: 'Bold', action: { wrap: ['**', '**'] } }, { Icon: Italic, label: 'Italic', action: { wrap: ['*', '*'] } }, { Icon: Strikethrough, label: 'Strikethrough', action: { wrap: ['~~', '~~'] } }, { Icon: Heading2, label: 'Heading', action: { linePrefix: '## ' } }, { Icon: List, label: 'List', action: { linePrefix: '- ' } }, { Icon: ListChecks, label: 'Checklist', action: { linePrefix: '- [ ] ' } }, { Icon: Link2, label: 'Link', action: { wrap: ['[', '](https://)'] } }, { Icon: Code, label: 'Code', action: { wrap: ['`', '`'] } }, ] useEffect(() => { setTitle(res.title || '') setNotes(res.notes || '') setEditingTitle(false) setNotesTab(res.notes ? 'preview' : 'write') }, [res.id]) useEffect(() => { if (editingTitle) titleInputRef.current?.focus() }, [editingTitle]) const dirty = title !== (res.title || '') || notes !== (res.notes || '') const save = async () => { if (!title.trim()) return setSaving(true) try { await onSave({ title: title.trim(), notes: notes.trim() || null }) onClose() } finally { setSaving(false) } } const { date, time } = splitReservationDateTime(res.reservation_time) const { time: endTime } = splitReservationDateTime(res.reservation_end_time) const dateStr = date ? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }) : '' const statTiles = transit ? [ { Icon: Clock, value: transit.duration > 0 ? fmtTransitDuration(transit.duration, t) : '—', label: t('transit.durationLabel') }, { Icon: ArrowRightLeft, value: String(transit.transfers ?? 0), label: t('transit.transfersLabel') }, { Icon: Footprints, value: transit.walk_seconds > 59 ? t('transit.min', { count: Math.round(transit.walk_seconds / 60) }) : '—', label: t('transit.walkLabel') }, ] : [] if (!isMobile) { const titleId = `transit-journey-${res.id}` const timeText = time ? `${formatTime(time, locale, timeFormat)}${endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}` : '' const header = ( } onTitleClick={canEdit ? () => setEditingTitle(true) : undefined} titleTooltip={t('reservations.rename')} titleInput={editingTitle ? { value: title, onChange: setTitle, label: t('reservations.titleLabel'), autoFocus: true, onBlur: () => setEditingTitle(false), onKeyDown: e => { if (e.key === 'Enter') setEditingTitle(false) // Spent here, so the dialog stays open and only the rename is dropped. if (e.key === 'Escape') { e.preventDefault(); setTitle(res.title || ''); setEditingTitle(false) } }, } : undefined} pills={( <> {dateStr && {dateStr}} {timeText && {timeText}} )} /> ) const footer = ( {canEdit && }>{t('transit.changeRoute')}} {canEdit && onEditDetails && }>{t('transit.editDetails')}} {canEdit && setConfirmDelete(true)} />} {canEdit ? {saving ? t('common.saving') : t('common.save')} : {t('common.close')}} ) const noteTools = canEdit && ( {notesTab === 'write' && ( {MD_TOOLS.map(({ Icon, label, action }) => ( ))} )} value={notesTab} onChange={setNotesTab} label={t('reservations.notes')} options={[{ value: 'write', label: t('common.edit') }, { value: 'preview', label: t('common.preview') }]} /> ) return ( <> {transit && (
{statTiles.map(({ value, label }) => (
{value}
{label}
))}
)} {transit && transit.legs.length > 0 && ( )} {canEdit && notesTab === 'write' ? (