mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
0dd07befa7
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.
224 lines
9.9 KiB
TypeScript
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>
|
|
)
|
|
}
|