Files
TREK/client/src/components/Planner/DayPlanSidebarTransportDetailModal.tsx
T
Maurice 0dd07befa7 feat(planner): bring the plan tab and every trip dialog into the new look
The Plan tab was the last trip tab still in the old design. It now reads
like Bookings, Costs, Lists, Files and Collab, without losing a function:

- Day plan: each day is a card with a head band (day tile with weather,
  stay and rental pills, a "+" menu for place, stay, transport, transit
  and note), rows with "..." menus, white badges and a white route bar.
- Places column: add, search and filters in three rows, a show dropdown,
  category and rating filters, and a selection bar at the foot.
- Place inspector, day details and the stay editor on the shared dialog
  frame; the place form in the editor look with the name in the head band.
- Clicking a booking or transport anywhere on the plan opens the booking
  detail popup first; Edit leads to the editor. The popup is one host
  shared with the Bookings and Transports tabs.
- Files and Collab dialogs, notes, polls, links and chat moved to the same
  frame; shared DialogShell and dialogParts hold the building blocks.
- The map hover card shows the place photo and follows the theme.

Every new and reworded string is translated into all locales, and a few
older English leftovers (help center, cost split, import hints) with them.
Phone screens keep their own look.
2026-09-27 21:00:19 +02:00

224 lines
9.9 KiB
TypeScript

import { useId, type ReactNode } from 'react'
import { CalendarDays, Clock, ExternalLink, FileText, Pencil } from 'lucide-react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { markdownLinkComponents } from '../shared/markdownLink'
import { BlurredCode } from '../shared/BookingCode'
import { DialogButton, DialogFooter, DialogSection, DialogShell, FooterSpacer, PILL } from '../shared/DialogShell'
import { useTripStore } from '../../store/tripStore'
import { formatTime, splitReservationDateTime } from '../../utils/formatters'
import { parseReservationMetadata } from '../../utils/flightLegs'
import { BookingDialogHeader } from './bookings/BookingDialogShell'
import { BOX, Eyebrow, Field, filesFor, fs, toneColor, toneOf } from './bookings/bookingParts'
import { TransitLegs, transitSummary } from './bookings/transitParts'
import type { Reservation } from '../../types'
type Translate = (key: string, params?: Record<string, string | number>) => string
interface DayPlanSidebarTransportDetailModalProps {
transportDetail: Reservation | null
setTransportDetail: (v: Reservation | null) => void
onNavigateToFiles?: () => void
/** Opens the edit form for this reservation (shown as a footer action). */
onEdit?: (res: Reservation) => void
t: Translate
locale: string
timeFormat: string
}
/** One labelled value of the grid; a booking code is drawn through the blur preference. */
interface DetailCell {
label: string
value: string
code?: boolean
}
/**
* The booking a rental pill, a transit row or a map endpoint opens from the
* plan: the head band tinted by its status like the booking dialogs, the
* booking's facts one labelled field each, a transit journey leg by leg, the
* notes and the files, and the edit in the bar at the foot.
*/
export function DayPlanSidebarTransportDetailModal({ transportDetail, ...rest }: DayPlanSidebarTransportDetailModalProps) {
if (!transportDetail) return null
return <TransportDetailDialog res={transportDetail} {...rest} />
}
function TransportDetailDialog({ res, setTransportDetail, onNavigateToFiles, onEdit, t, locale, timeFormat }: Omit<DayPlanSidebarTransportDetailModalProps, 'transportDetail'> & { res: Reservation }) {
const titleId = useId()
const allFiles = useTripStore(s => s.files)
const close = () => setTransportDetail(null)
const meta = parseReservationMetadata(res)
const transit = transitSummary(meta)
const files = filesFor(res, allFiles || [])
const when = whenOf(res, locale, timeFormat)
const cells = detailCells(res, meta, t)
const confirmed = res.status === 'confirmed'
const stats: { value: string; label: string }[] = []
if (transit && transit.legs.length > 0) {
if (transit.duration) stats.push({ value: durationText(transit.duration, t), label: t('transit.durationLabel') })
stats.push({ value: transit.transfers > 0 ? String(transit.transfers) : t('transit.direct'), label: t('transit.transfersLabel') })
// Under a minute of walking is not worth a tile.
if (transit.walk > 59) stats.push({ value: t('transit.min', { count: Math.round(transit.walk / 60) }), label: t('transit.walkLabel') })
}
const header = (
<BookingDialogHeader
tone={toneOf(res)}
type={res.type}
labelId={titleId}
onClose={close}
title={res.title}
pills={(
<>
<span className={PILL}>
<span className="h-2 w-2 flex-none rounded-full" style={{ background: toneColor(confirmed ? 'confirmed' : 'pending') }} />
{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}
</span>
{(when.day || when.time) && (
<span className={PILL}>
{when.day
? <CalendarDays size={13} strokeWidth={2.2} className="text-content-faint" />
: <Clock size={13} strokeWidth={2.2} className="text-content-faint" />}
{when.day}
{when.time && <span className={when.day ? 'font-medium text-content-muted' : ''}>{when.time}</span>}
</span>
)}
</>
)}
/>
)
const footer = (
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={close}>{t('common.close')}</DialogButton>
{onEdit && (
<DialogButton variant="primary" onClick={() => onEdit(res)} icon={<Pencil size={14} strokeWidth={2} />}>{t('common.edit')}</DialogButton>
)}
</DialogFooter>
)
return (
<DialogShell onClose={close} labelledBy={titleId} header={header} footer={footer}>
{cells.length > 0 && (
<div className="grid grid-cols-2 gap-2 max-sm:grid-cols-1">
{cells.map((c, i) => (
<Field key={i} label={c.label}>
{c.code ? <BlurredCode className="font-geist tabular-nums">{c.value}</BlurredCode> : c.value}
</Field>
))}
</div>
)}
{res.location && <DetailField label={t('reservations.locationAddress')}>{res.location}</DetailField>}
{stats.length > 0 && (
<div className="flex gap-2">
{stats.map(s => (
<div key={s.label} className="min-w-0 flex-1 rounded-[12px] bg-surface-tertiary px-3 py-2.5 text-center">
<div className="truncate font-bold tabular-nums tracking-[-0.01em] text-content" style={fs(18, 'subtitle')}>{s.value}</div>
<div className="mt-0.5 truncate font-geist text-content-faint" style={fs(10.5)}>{s.label}</div>
</div>
))}
</div>
)}
{/* Public-transit itinerary (#1065): the legs the transit search stored. */}
{transit && transit.legs.length > 0 && (
<DialogSection label={t('transit.itinerary')}>
<TransitLegs legs={transit.legs} />
</DialogSection>
)}
{res.notes && (
<DialogSection label={t('reservations.notes')}>
<div className={`${BOX} collab-note-md px-3.5 py-3 text-content-secondary`} style={{ ...fs(13, 'body'), wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{res.notes}</Markdown>
</div>
</DialogSection>
)}
{files.length > 0 && (
<DialogSection label={t('files.title')}>
<div className="flex flex-col gap-1.5">
{files.map(f => (
<button key={f.id} type="button" onClick={() => { close(); onNavigateToFiles?.() }}
className={`${BOX} flex items-center gap-2.5 px-3.5 py-2.5 text-left hover:bg-surface-hover`}>
<FileText size={14} strokeWidth={2} className="flex-none text-content-muted" />
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(13, 'body')}>{f.original_name}</span>
<ExternalLink size={12} strokeWidth={2} className="flex-none text-content-faint" />
</button>
))}
</div>
</DialogSection>
)}
</DialogShell>
)
}
/** The facts of a flight or a train, the booking code and the code of each segment. */
function detailCells(res: Reservation, meta: Record<string, unknown>, t: Translate): DetailCell[] {
const cells: DetailCell[] = []
const add = (label: string, value: unknown, code = false) => {
if (value) cells.push({ label, value: String(value), code })
}
if (res.type === 'flight') {
add(t('reservations.meta.airline'), meta.airline)
add(t('reservations.meta.flightNumber'), meta.flight_number)
add(t('reservations.meta.from'), meta.departure_airport)
add(t('reservations.meta.to'), meta.arrival_airport)
add(t('reservations.meta.seat'), meta.seat)
} else if (res.type === 'train') {
add(t('reservations.meta.trainNumber'), meta.train_number)
add(t('reservations.meta.platform'), meta.platform)
add(t('reservations.meta.seat'), meta.seat)
}
add(t('reservations.confirmationCode'), res.confirmation_number, true)
// A stopover booking can carry its own reference per segment (#1943); the
// flat fields above only ever describe the first leg. Drawn as codes, so the
// blur preference covers them like the booking's own.
const legs = Array.isArray(meta.legs) ? meta.legs as { from?: string; to?: string; confirmation_number?: string }[] : []
if (legs.length > 1) {
for (const leg of legs) {
if (!leg?.confirmation_number) continue
const segment = [leg.from, leg.to].filter(Boolean).join(' → ')
add(segment || t('reservations.confirmationCode'), leg.confirmation_number, true)
}
}
return cells
}
/** The booking's day and its time or time range, each empty when the booking has none. */
function whenOf(res: Reservation, locale: string, timeFormat: string): { day: string; time: string } {
const { date, time } = splitReservationDateTime(res.reservation_time)
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
const day = date
? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
: ''
const start = time ? formatTime(time, locale, timeFormat) : ''
const end = endTime ? formatTime(endTime, locale, timeFormat) : ''
return { day, time: start && end ? `${start} → ${end}` : start }
}
/** A journey's length in the reader's language: minutes up to an hour, then hours and minutes. */
function durationText(seconds: number, t: Translate): string {
const mins = Math.round(seconds / 60)
if (mins < 60) return t('transit.min', { count: mins })
const hours = Math.floor(mins / 60)
const minutes = mins % 60
return minutes > 0 ? t('dawarich.duration.hoursMinutes', { hours, minutes }) : t('dawarich.duration.hours', { hours })
}
/** A labelled value too long for a grid cell, such as an address. */
function DetailField({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="min-w-0">
<Eyebrow className="mb-[3px]">{label}</Eyebrow>
<div className={`${BOX} break-words px-3.5 py-2.5 font-medium text-content`} style={fs(13, 'body')}>{children}</div>
</div>
)
}