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.
This commit is contained in:
Maurice
2026-09-27 21:00:19 +02:00
parent acd0d6511e
commit 0dd07befa7
314 changed files with 17106 additions and 10400 deletions
@@ -1,5 +1,4 @@
import { useRef } from 'react'
import { createPortal } from 'react-dom'
import { useId, useRef } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
@@ -8,6 +7,11 @@ import NoteFormatToolbar from '../shared/NoteFormatToolbar'
import NoteColorPicker from '../shared/NoteColorPicker'
import { noteSurface } from './noteSurface'
import { markdownLinkComponents } from '../shared/markdownLink'
import { Tooltip } from '../shared/Tooltip'
import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { EditorField, TEXTAREA } from '../shared/dialogParts'
import { EYEBROW } from './bookings/bookingParts'
import { tintOf } from './planParts'
interface NoteModalUi {
mode: 'add' | 'edit'
@@ -23,179 +27,165 @@ type NoteUiMap = Record<string, NoteModalUi | undefined>
interface DayPlanSidebarNoteModalProps {
noteUi: NoteUiMap
setNoteUi: (updater: (prev: NoteUiMap) => NoteUiMap) => void
/**
* The hook's handle on the title field. The field sits in the dialog's head
* band now and takes the focus itself when the dialog opens, so the hook's
* delayed focus call finds nothing to do.
*/
noteInputRef: React.RefObject<HTMLInputElement>
cancelNote: (dayId: number) => void
saveNote: (dayId: number) => void
/** Asks the planner to confirm and then delete the note being edited (#2249). */
onRequestDelete: (dayId: number, noteId: number) => void
t: (key: string, params?: Record<string, unknown>) => string
t: (key: string, params?: Record<string, string | number>) => string
}
type NoteDialogProps = Omit<DayPlanSidebarNoteModalProps, 'noteUi' | 'noteInputRef'> & { dayId: string; ui: NoteModalUi | undefined }
const BODY_MAX = 2000
/**
* Add/edit dialog for a day note (#1629).
* Add/edit dialog for a day note (#1629), one per day whose note is open.
*
* Two columns on anything wider than a phone: how the note looks on the left
* (icon, colour, and a preview of the card it will become), what it says on the
* right. The preview is the reason the colour picker is worth having — a swatch
* row tells you nothing about what a tinted card looks like next to a place.
* The title is typed into the head band, whose tile already shows the icon in
* the note's colour. Below it two columns on anything wider than a phone: how
* the note looks on the left (icon, colour, and a preview of the card it will
* become), what it says on the right. The preview is the reason the colour
* picker is worth having: a swatch row tells you nothing about what a tinted
* card looks like next to a place.
*/
export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cancelNote, saveNote, onRequestDelete, t }: DayPlanSidebarNoteModalProps) {
export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, cancelNote, saveNote, onRequestDelete, t }: DayPlanSidebarNoteModalProps) {
return (
<>
{Object.entries(noteUi).map(([dayId, ui]) => ui && createPortal(
<NoteDialog key={dayId} dayId={dayId} ui={ui} setNoteUi={setNoteUi} noteInputRef={noteInputRef}
cancelNote={cancelNote} saveNote={saveNote} onRequestDelete={onRequestDelete} t={t} />,
document.body
{Object.entries(noteUi).map(([dayId, ui]) => (
<NoteDialog key={dayId} dayId={dayId} ui={ui} setNoteUi={setNoteUi}
cancelNote={cancelNote} saveNote={saveNote} onRequestDelete={onRequestDelete} t={t} />
))}
</>
)
}
function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote, onRequestDelete, t }: Omit<DayPlanSidebarNoteModalProps, 'noteUi'> & {
dayId: string
ui: NoteModalUi
}) {
function NoteDialog({ dayId, ui, ...rest }: NoteDialogProps) {
return ui ? <OpenNoteDialog dayId={dayId} ui={ui} {...rest} /> : null
}
function OpenNoteDialog({ dayId, ui, setNoteUi, cancelNote, saveNote, onRequestDelete, t }: NoteDialogProps & { ui: NoteModalUi }) {
const bodyRef = useRef<HTMLTextAreaElement | null>(null)
const labelId = useId()
const bodyId = `${labelId}-body`
const day = Number(dayId)
const patch = (fields: Partial<NoteModalUi>) =>
setNoteUi(prev => ({ ...prev, [dayId]: { ...(prev[dayId] as NoteModalUi), ...fields } }))
const color = ui.color ?? null
const surface = noteSurface(color)
const PreviewIcon = getNoteIcon(ui.icon)
const NoteIcon = getNoteIcon(ui.icon)
const canSave = Boolean(ui.text?.trim())
const bodyLen = ui.time?.length || 0
const header = (
<DialogHeader
tile={<DialogTile><NoteIcon size={20} strokeWidth={1.9} color={color ?? 'var(--text-muted)'} /></DialogTile>}
tint={color ? tintOf(color, 12) : NEUTRAL_TINT}
labelId={labelId}
onClose={() => cancelNote(day)}
eyebrow={ui.mode === 'add' ? t('dayplan.noteAdd') : t('dayplan.noteEdit')}
titleInput={{
value: ui.text,
onChange: text => patch({ text }),
label: t('dayplan.noteTitle'),
placeholder: t('dayplan.noteTitle'),
autoFocus: true,
required: true,
onKeyDown: e => { if (e.key === 'Enter') saveNote(day) },
}}
/>
)
// Delete lives here rather than on the row: the row's floating pencil/trash
// pill covered the reorder chevrons and, on a coarse pointer, never went
// away (#2249). The planner still confirms before it deletes.
const footer = (
<DialogFooter>
{ui.mode === 'edit' && ui.noteId != null && <DeleteButton onClick={() => onRequestDelete(day, ui.noteId!)} />}
<FooterSpacer />
<DialogButton onClick={() => cancelNote(day)}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" onClick={() => saveNote(day)} disabled={!canSave}>
{ui.mode === 'add' ? t('common.add') : t('common.save')}
</DialogButton>
</DialogFooter>
)
return (
<div className="bg-[rgba(0,0,0,0.3)]" style={{
position: 'fixed', inset: 0, zIndex: 10000,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
backdropFilter: 'blur(3px)',
}} role="presentation" onClick={() => cancelNote(Number(dayId))}>
<div className="bg-surface-card note-modal" style={{
width: '100%', maxWidth: 720, maxHeight: '90vh', overflowY: 'auto', borderRadius: 16,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
display: 'flex', flexDirection: 'column', gap: 16,
}} role="presentation" onClick={e => e.stopPropagation()}>
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>
{ui.mode === 'add' ? t('dayplan.noteAdd') : t('dayplan.noteEdit')}
<DialogShell onClose={() => cancelNote(day)} labelledBy={labelId} width="editor" header={header} footer={footer}>
<div className="grid grid-cols-1 items-start gap-5 sm:grid-cols-[210px_minmax(0,1fr)]">
{/* Left: how it looks. */}
<div className="flex min-w-0 flex-col gap-4">
<EditorField label={t('dayplan.noteIcon')}>
<div role="group" aria-label={t('dayplan.noteIcon')} className="flex flex-wrap gap-[5px]">
{NOTE_ICONS.map(({ id, Icon }) => {
const on = ui.icon === id
// The icons have no names of their own in the catalogue, so the id is what the tooltip says.
return (
<Tooltip key={id} label={id}>
<button type="button" onClick={() => patch({ icon: id })} aria-pressed={on} aria-label={id}
className={`grid h-[38px] w-[38px] place-items-center rounded-[10px] border-2 ${on ? 'border-content bg-surface-hover' : 'border-edge-faint hover:bg-surface-hover'}`}>
<Icon size={16} strokeWidth={1.8} color={on ? (color ?? 'var(--text-primary)') : 'var(--text-muted)'} />
</button>
</Tooltip>
)
})}
</div>
</EditorField>
<EditorField label={t('notes.color.label')}>
<NoteColorPicker value={color} onChange={c => patch({ color: c })} />
</EditorField>
<EditorField label={t('notes.preview')}>
{/* The same shape the card takes in the day plan. */}
<div className="flex items-center gap-2 rounded-[10px] border px-2 py-[7px]" style={{ borderColor: surface.border, background: surface.background }}>
<span className="grid h-7 w-7 flex-none place-items-center rounded-full" style={{ background: surface.iconBackground }}>
<NoteIcon size={13} strokeWidth={1.8} color={surface.iconColor} />
</span>
<span className="min-w-0 break-words font-medium text-content" style={fs(12.5, 'body')}>
{ui.text?.trim() || t('dayplan.noteTitle')}
</span>
</div>
</EditorField>
</div>
<div className="note-modal-cols" style={{ display: 'grid', gridTemplateColumns: '210px 1fr', gap: 20, alignItems: 'start' }}>
{/* Left — how it looks. */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, minWidth: 0 }}>
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em', marginBottom: 7 }}>
{t('dayplan.noteIcon')}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{NOTE_ICONS.map(({ id, Icon }) => (
<button key={id} type="button" onClick={() => patch({ icon: id })} title={id}
aria-pressed={ui.icon === id}
className={ui.icon === id ? 'bg-surface-hover' : 'bg-transparent'}
style={{ width: 38, height: 38, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
<Icon size={16} strokeWidth={1.8} color={ui.icon === id ? (color ?? 'var(--text-primary)') : 'var(--text-muted)'} />
</button>
))}
</div>
{/* Right: what it says. */}
<div className="flex min-w-0 flex-col gap-3">
<div className="min-w-0">
<div className="mb-[5px] flex flex-wrap items-center justify-between gap-2">
<label htmlFor={bodyId} className={EYEBROW} style={fs(9.5)}>{t('dayplan.noteSubtitle')}</label>
<NoteFormatToolbar textareaRef={bodyRef} onChange={v => patch({ time: v })} compact />
</div>
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em', marginBottom: 7 }}>
{t('notes.color.label')}
</div>
<NoteColorPicker value={color} onChange={c => patch({ color: c })} />
</div>
<div>
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em', marginBottom: 7 }}>
{t('notes.preview')}
</div>
{/* The same shape the card takes in the day plan. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 8px', borderRadius: 6, border: `1px solid ${surface.border}`, background: surface.background }}>
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'grid', placeItems: 'center', borderRadius: '50%', background: surface.iconBackground }}>
<PreviewIcon size={13} strokeWidth={1.8} color={surface.iconColor} />
</div>
<span className="text-content" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 500, minWidth: 0, wordBreak: 'break-word' }}>
{ui.text?.trim() || t('dayplan.noteTitle')}
</span>
</div>
<textarea
id={bodyId}
ref={bodyRef}
value={ui.time}
maxLength={BODY_MAX}
rows={6}
onChange={e => patch({ time: e.target.value })}
placeholder={t('notes.bodyPlaceholder')}
className={`${TEXTAREA} resize-y`}
/>
<div className="mt-1 flex justify-between gap-2" style={fs(10.5)}>
<span className="text-content-faint">{t('notes.markdownHint')}</span>
<span className={`tabular-nums ${bodyLen >= BODY_MAX - 100 ? 'text-warning' : 'text-content-faint'}`}>{bodyLen}/{BODY_MAX}</span>
</div>
</div>
{/* Right — what it says. */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, minWidth: 0 }}>
<div>
<label className="text-content-faint" htmlFor="note-title" style={{ display: 'block', fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em', marginBottom: 7 }}>
{t('dayplan.noteTitle')} *
</label>
<input
id="note-title"
ref={noteInputRef}
type="text"
value={ui.text}
onChange={e => patch({ text: e.target.value })}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }}
placeholder={t('dayplan.noteTitle')}
required
className="text-content bg-surface-input"
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '9px 11px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box' }}
/>
{ui.time?.trim() && (
<div className="collab-note-md-full max-h-[200px] overflow-y-auto rounded-[10px] border px-3 py-2.5 leading-[1.55] text-content-muted"
style={{ ...fs(12, 'body'), borderColor: surface.border, background: surface.background, wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{ui.time}</Markdown>
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 7, flexWrap: 'wrap' }}>
<label className="text-content-faint" htmlFor="note-body" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.09em' }}>
{t('dayplan.noteSubtitle')}
</label>
<NoteFormatToolbar textareaRef={bodyRef} onChange={v => patch({ time: v })} compact />
</div>
<textarea
id="note-body"
ref={bodyRef}
value={ui.time}
maxLength={BODY_MAX}
rows={6}
onChange={e => patch({ time: e.target.value })}
onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
placeholder={t('notes.bodyPlaceholder')}
className="text-content bg-surface-input"
style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '9px 11px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', resize: 'vertical', lineHeight: 1.5 }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginTop: 4 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}>{t('notes.markdownHint')}</span>
<span className={bodyLen >= BODY_MAX - 100 ? 'text-[#d97706]' : 'text-content-faint'} style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))' }}>{bodyLen}/{BODY_MAX}</span>
</div>
</div>
{ui.time?.trim() && (
<div className="collab-note-md-full text-content-muted" style={{ borderRadius: 10, border: `1px solid ${surface.border}`, background: surface.background, padding: '10px 12px', fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.55, wordBreak: 'break-word', overflowWrap: 'anywhere', maxHeight: 200, overflowY: 'auto' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{ui.time}</Markdown>
</div>
)}
</div>
</div>
{/* Delete lives here rather than on the row: the row's floating pencil/trash
pill covered the reorder chevrons and, on a coarse pointer, never went
away (#2249). The planner still confirms before it deletes. */}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' }}>
{ui.mode === 'edit' && ui.noteId != null && (
<button type="button" onClick={() => onRequestDelete(Number(dayId), ui.noteId!)}
className="text-danger"
style={{ marginRight: 'auto', fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 15px', cursor: 'pointer', fontFamily: 'inherit' }}>
{t('common.delete')}
</button>
)}
<button type="button" onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 15px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button type="button" onClick={() => saveNote(Number(dayId))} disabled={!canSave}
className={!canSave ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'}
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', border: 'none', borderRadius: 8, padding: '7px 17px', cursor: !canSave ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
{ui.mode === 'add' ? t('common.add') : t('common.save')}
</button>
</div>
</div>
</div>
</DialogShell>
)
}