fix(places): say where a result came from, and stop asking questions in the form

Six changes to the place picker and the card behind it, all of them about a
panel saying less and meaning more.

The TREK place index has no switch in the admin panel any more. It decides
whether a search leaves the instance at all, which is a property of the
deployment rather than a taste: an operator pins TREK_PLACES_ENABLED=false in
their compose file, and it cannot be turned off from a browser by whoever holds
an admin account that day. The switch never shipped, so nothing to migrate.

The standing "Using OpenStreetMap" notice above the search box is gone. It was
a sentence about configuration in the middle of a form about a place, and it
answered a question nobody asked while leaving the real one open. Each result
now carries a small mark instead: TREK, OpenStreetMap or Google. It sits on the
row rather than above the list, because a list can be two indexes interleaved
and a heading could only name one of them.

The saved-places filters open across the full width of the row. Two of them sit
side by side, only one can be open at a time, and list names were being cut at
about ten characters for no reason.

Three empty states use the mascot the rest of TREK uses. The place details panel
no longer tells the reader to go and ask an administrator for a Google key, the
saved-places list and the road trip rail no longer explain themselves in two
lines of grey text. That needed one new mood, `sad`: `sleepy` reads as falling
asleep, which is a different thing from finding nothing.

The Kind of stop field is out of the place form. It is a road trip concept, and
the road trip view already sets it from the stop's own number, where the choice
is next to what it changes.
This commit is contained in:
Maurice
2026-09-08 17:06:09 +02:00
committed by Maurice
parent 1761f2bcd5
commit 4a88dbcbcf
92 changed files with 283 additions and 557 deletions
@@ -5,6 +5,7 @@ import { collectionsApi } from '../../api/collections'
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
import type { TranslationFn } from '../../types'
import EmptyState from '../shared/EmptyState'
interface LocationBias {
low: { lat: number; lng: number }
@@ -53,7 +54,16 @@ async function loadSavedPlaces(ids: number[]): Promise<CollectionPlace[]> {
return merged
}
/** Compact click-away dropdown (Tailwind — this panel lives outside .trek-dash). */
/**
* Compact click-away dropdown (Tailwind, this panel lives outside .trek-dash).
*
* The panel is positioned against the FILTER ROW rather than against this
* button, so it opens across the full width of the row instead of the half its
* own trigger occupies. The two filters sit side by side and only one can be
* open at a time, so the wider panel costs nothing and stops list names being
* truncated at roughly ten characters. The row carries the `relative` this
* needs; there is no other call site.
*/
function FilterDropdown({ current, options, onSelect, lead }: {
current: string | number
options: Opt[]
@@ -72,7 +82,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
}, [open])
const cur = options.find(o => o.key === current) ?? options[0]
return (
<div className="relative min-w-0 flex-1" ref={ref}>
<div className="min-w-0 flex-1" ref={ref}>
<button type="button" onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}
className={`w-full flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border bg-surface-input text-[12px] font-medium text-content-secondary transition-colors ${open ? 'border-accent' : 'border-edge hover:bg-surface-hover'}`}>
<span className="shrink-0 text-content-faint">{cur.icon ?? lead}</span>
@@ -80,7 +90,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
<ChevronDown size={13} className="shrink-0 text-content-faint" />
</button>
{open && (
<div role="listbox" className="absolute z-30 left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
<div role="listbox" className="absolute z-30 top-full left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
{options.map(o => (
<button key={o.key} type="button" role="option" aria-selected={o.key === current} onClick={() => { onSelect(o.key); setOpen(false) }}
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg text-[12.5px] text-left transition-colors hover:bg-surface-hover ${o.key === current ? 'text-content font-semibold' : 'text-content-secondary'}`}>
@@ -189,7 +199,7 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
/>
</div>
{lists.length > 0 && (
<div className="flex gap-2">
<div className="relative flex gap-2">
<FilterDropdown current={listFilter} options={listOpts} onSelect={k => setListFilter(k as number | 'all')} lead={<Layers size={13} />} />
<FilterDropdown current={statusFilter} options={statusOpts} onSelect={k => setStatusFilter(k as CollectionStatus | 'all')} lead={<Bookmark size={13} />} />
</div>
@@ -201,7 +211,13 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
<Loader2 size={18} className="animate-spin" />
</div>
) : visible.length === 0 ? (
<p className="text-center text-[12px] text-content-faint py-10 px-3">{t('collections.picker.empty')}</p>
<EmptyState
scene="search"
title={t('collections.picker.empty')}
size={84}
surface="var(--bg-secondary)"
compact
/>
) : (
<div className="flex flex-col gap-1">
{page.map(place => (
@@ -467,41 +467,33 @@ describe('PlaceDetailsColumn — hours and rating', () => {
})
})
/**
* FE-PDC-027..029 — the nudge towards a Google key.
* FE-PDC-027 — nothing came back for this place.
*
* Deliberately narrow: it appears only when the free sources came up empty AND
* no key is configured. On an instance with a key there is nothing to suggest,
* and on a place the free sources described it would read as an advert.
* It used to be a line of grey text plus, when no Google key was set, a panel
* telling the reader to go and ask an administrator for one. The mascot says the
* same thing in the shape every other empty state in TREK uses, and whose key is
* missing is not this panel's business.
*/
describe('PlaceDetailsColumn — no Google key', () => {
const empty = () => placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
describe('PlaceDetailsColumn — nothing found', () => {
it('FE-PDC-027: shows the empty state once, and no advice about API keys', async () => {
placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
renderColumn()
it('FE-PDC-027: suggests a key when nothing was found and none is set', async () => {
empty()
renderColumn({ hasMapsKey: false })
expect(await screen.findByText('places.details.noKeyTitle')).toBeInTheDocument()
expect(screen.getByText('places.details.noKeyHint')).toBeInTheDocument()
})
it('FE-PDC-028: stays quiet when a key is already configured', async () => {
empty()
renderColumn({ hasMapsKey: true })
await screen.findByText('places.details.nothing')
expect(await screen.findByText('places.details.nothing')).toBeInTheDocument()
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
expect(screen.queryByText('places.details.noKeyHint')).not.toBeInTheDocument()
})
it('FE-PDC-029: stays quiet when the free sources did find something', async () => {
it('FE-PDC-028: stays quiet when the free sources did find something', async () => {
placeEnrichment.mockResolvedValue({
photos: [],
facts: [],
description: { text: 'Ein Museum.', source: 'wikipedia', sourceUrl: null, license: 'CC BY-SA 4.0' },
})
renderColumn({ hasMapsKey: false })
renderColumn()
await screen.findByText('Ein Museum.')
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
expect(screen.queryByText('places.details.nothing')).not.toBeInTheDocument()
})
})
/**
@@ -10,13 +10,11 @@ import {
ChevronUp,
Clock,
ExternalLink,
ImageOff,
Landmark,
Leaf,
Loader2,
ScrollText,
ShoppingBag,
Sparkles,
Sprout,
Star,
Sun,
@@ -27,6 +25,7 @@ import { mapsApi } from '../../api/client'
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
import { safeHttpUrl } from '../../utils/safeUrl'
import EmptyState from '../shared/EmptyState'
import type { TranslationFn } from '../../types'
/** The place the column is describing. Null while nothing is selected. */
@@ -53,7 +52,6 @@ interface PlaceDetailsColumnProps {
/** For grouping the rating count's digits. */
locale?: string
/** False on an instance with no Google key, which is most of them. */
hasMapsKey?: boolean
t: TranslationFn
}
@@ -119,7 +117,6 @@ export default function PlaceDetailsColumn({
language,
timeFormat = '24h',
locale = 'en-US',
hasMapsKey = false,
t,
}: PlaceDetailsColumnProps): React.ReactElement {
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
@@ -223,17 +220,20 @@ export default function PlaceDetailsColumn({
<p className="text-caption text-content-muted">{t('places.details.disabled')}</p>
)}
{/* The mascot rather than a line of grey text and, under it, a paragraph
telling the reader to go and ask an administrator for a Google key.
Nothing came back for this place, which is worth saying once and
plainly; whose key is missing is not this panel's business, and on a
place the free sources describe fine it read as an advert. */}
{isEmpty && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-caption text-content-muted">
<ImageOff className="w-4 h-4 shrink-0" />
{t('places.details.nothing')}
</div>
{/* Only when both are true. With a key configured there is nothing
to suggest, and on a place the free sources DID describe the
suggestion would be an advert. */}
{!hasMapsKey && <NoKeyHint t={t} />}
</div>
<EmptyState
scene="search"
mood="sad"
title={t('places.details.nothing')}
size={92}
surface="var(--bg-secondary)"
compact
/>
)}
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
@@ -462,19 +462,6 @@ function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElem
* It also names who to ask: the key is an instance-wide setting, so on most
* installs the person reading this cannot act on it themselves.
*/
function NoKeyHint({ t }: { t: TranslationFn }): React.ReactElement {
return (
<div className="rounded-xl border border-accent/25 bg-accent-subtle p-3">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-accent-text">
<Sparkles className="h-3 w-3" />
</span>
<p className="text-caption font-semibold text-content">{t('places.details.noKeyTitle')}</p>
</div>
<p className="mt-2 text-caption leading-relaxed text-content-secondary">{t('places.details.noKeyHint')}</p>
</div>
)
}
/**
* The star rating, as stars.
@@ -349,10 +349,55 @@ describe('PlaceFormModal', () => {
delete window.__addToast;
});
it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => {
// hasMapsKey is false by default in beforeEach
it('FE-PLANNER-PLACEFORM-022: no standing notice about which index is in use', () => {
// It used to say "Using OpenStreetMap" above the box whenever no Google key
// was set, which was a sentence about configuration in the middle of a form
// about a place. Each result names its own source now, which is the question
// people actually had.
render(<PlaceFormModal {...defaultProps} />);
expect(screen.getByText(/OpenStreetMap/i)).toBeInTheDocument();
expect(screen.queryByText(/Using OpenStreetMap/i)).not.toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-022b: a suggestion says which index answered', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/maps/autocomplete', () =>
HttpResponse.json({
suggestions: [{ placeId: 'gers:abc', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
source: 'trek-places',
}),
),
);
render(<PlaceFormModal {...defaultProps} />);
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
expect(await screen.findByText('TREK')).toBeInTheDocument();
});
it('FE-PLANNER-PLACEFORM-022c: an interleaved result list marks each row with its own index', async () => {
// The whole point of putting it on the row: with both indexes answering,
// a label above the list could only name one of them. The index marks its
// own rows, so the unmarked one is OpenStreetMap by elimination.
const user = userEvent.setup();
server.use(
http.post('/api/maps/search', () =>
HttpResponse.json({
places: [
{ name: 'Eiffel Tower', address: 'Paris', lat: '48.85', lng: '2.29', source: 'trek-places' },
{ name: 'Champ de Mars', address: 'Paris', lat: '48.85', lng: '2.29' },
],
source: 'trek-places+openstreetmap',
}),
),
);
render(<PlaceFormModal {...defaultProps} />);
await user.type(screen.getByPlaceholderText('Search places...'), 'Eiffel');
await user.keyboard('{Enter}');
expect(await screen.findByText('TREK')).toBeInTheDocument();
expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument();
});
// ── Category ─────────────────────────────────────────────────────────────────
@@ -1,7 +1,6 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import Modal from '../shared/Modal'
import type { RoadtripStopType } from '@trek/shared'
import { STOP_KINDS } from '../Roadtrip/stopKinds'
import CustomSelect from '../shared/CustomSelect'
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
import { mapsApi } from '../../api/client'
@@ -56,6 +55,49 @@ interface PlaceFormModalProps {
}
/**
* Which index a search result came from, as a short mark beside it.
*
* A place list can be two indexes interleaved, so the source belongs on the row
* rather than above the list: with the TREK index and OpenStreetMap answering
* together, "one of these came from somewhere" is not an answer anyone can use.
*
* The three names are proper nouns, so they are not translated, and that is also
* why there is no fourth: a source without a name people already know would need
* a string in 23 languages to say less than nothing.
*/
const SOURCE_LABELS: Record<string, string> = {
'trek-places': 'TREK',
openstreetmap: 'OpenStreetMap',
nominatim: 'OpenStreetMap',
google: 'Google',
}
/**
* The label for one row.
*
* A place carries its own source when the index that produced it says so, which
* is what makes an interleaved list readable. Everything else falls back to what
* answered the call: Google never marks its places, and a merged list marks only
* the index side, so an unmarked row in one is OpenStreetMap by elimination.
*/
function sourceLabelFor(place: unknown, listSource: string): string | null {
const own = (place as { source?: unknown } | null)?.source
if (typeof own === 'string' && own) return SOURCE_LABELS[own] ?? null
if (listSource.includes('openstreetmap')) return SOURCE_LABELS.openstreetmap
return SOURCE_LABELS[listSource] ?? null
}
/** The mark itself. Quiet on purpose: it answers a question, it does not advertise. */
function SourceBadge({ label }: { label: string | null }) {
if (!label) return null
return (
<span className="shrink-0 rounded-md border border-edge bg-surface-secondary px-1.5 py-0.5 text-[10px] font-medium text-content-faint">
{label}
</span>
)
}
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
* render over the form fields. */
@@ -92,10 +134,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
} = props
// Hidden while the addon is off, because the kinds only mean anything to the road trip
// rail: on an instance without it they would be six labels that change nothing.
const roadtripEnabled = useAddonStore(s => s.isEnabled('roadtrip'))
const [form, setForm] = useState(DEFAULT_FORM)
const [mapsSearch, setMapsSearch] = useState('')
const [mapsResults, setMapsResults] = useState([])
/** What answered the last full search. Only a fallback: a merged list carries the source per place. */
const [searchSource, setSearchSource] = useState<string>('')
/**
* What produced the list currently on screen, kept for the shadow log: the
* query as typed and the provider the envelope named. A ref rather than
@@ -119,6 +162,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const fileRef = useRef(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
// Which index answered the last keystroke. One call is served by one source,
// so the whole list carries it; the search below is per place, because that
// list can be two indexes interleaved.
const [acSource, setAcSource] = useState<string>('')
const [acHighlight, setAcHighlight] = useState(-1)
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const acAbortRef = useRef<AbortController | null>(null)
@@ -255,6 +302,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal, placesSessionRef.current.current())
acMetaRef.current = { query, source: result.source || 'unknown' }
setAcSuggestions(result.suggestions || [])
setAcSource(result.source || '')
setAcHighlight(-1)
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return
@@ -315,6 +363,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint)
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' }
setMapsResults(result.places || [])
setSearchSource(result.source || '')
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
} finally {
@@ -569,7 +618,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
dayAssignments,
isMobile,
collectionsEnabled,
roadtripEnabled,
form,
setForm,
mapsSearch,
@@ -589,6 +637,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
fileRef,
acSuggestions,
setAcSuggestions,
acSource,
searchSource,
acHighlight,
setAcHighlight,
acDebounceRef,
@@ -642,7 +692,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
dayAssignments,
isMobile,
collectionsEnabled,
roadtripEnabled,
form,
setForm,
mapsSearch,
@@ -662,6 +711,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
fileRef,
acSuggestions,
setAcSuggestions,
acSource,
searchSource,
acHighlight,
setAcHighlight,
acDebounceRef,
@@ -743,18 +794,12 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
language={language}
timeFormat={S.timeFormat}
locale={S.locale}
hasMapsKey={S.hasMapsKey}
t={t}
/>
)}
<form onSubmit={handleSubmit} className={twoColumn || showDetails ? 'flex-1 min-w-0 space-y-3' : 'space-y-3'} onPaste={handlePaste}>
{/* Place Search */}
<div className="bg-surface-secondary rounded-xl p-3 border border-edge">
{!hasMapsKey && (
<p className="mb-2 text-xs text-content-faint">
{t('places.osmActive')}
</p>
)}
<div className="relative">
<div className="flex gap-2">
<input
@@ -795,10 +840,15 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
idx === acHighlight ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'
}`}
>
<div className="font-medium text-sm">{s.mainText}</div>
{s.secondaryText && (
<div className="text-xs text-content-muted truncate">{s.secondaryText}</div>
)}
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="font-medium text-sm truncate">{s.mainText}</div>
{s.secondaryText && (
<div className="text-xs text-content-muted truncate">{s.secondaryText}</div>
)}
</div>
<SourceBadge label={sourceLabelFor(s, acSource)} />
</div>
</button>
))}
</div>
@@ -815,8 +865,13 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
onClick={() => handleSelectMapsResult(result, { mode: 'search', rank: idx, count: mapsResults.length })}
className="w-full text-left px-3 py-2 hover:bg-surface-hover border-b border-edge-faint last:border-0"
>
<div className="font-medium text-sm">{result.name}</div>
<div className="text-xs text-content-muted truncate">{result.address}</div>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="font-medium text-sm truncate">{result.name}</div>
<div className="text-xs text-content-muted truncate">{result.address}</div>
</div>
<SourceBadge label={sourceLabelFor(result, searchSource)} />
</div>
</button>
))}
</div>
@@ -914,30 +969,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
</div>
</div>
{/* What kind of stop this is on a drive.
Under the category rather than beside it, because the two answer different
questions: a category is the traveller's own label, editable and shared across
the whole instance, while this is a fact about the place. Refuelling is not a
taste. Shown always, not only in road trip mode: a fuel stop added on a Tuesday
is still a fuel stop when the mode is switched on later, and this is the only
place in the web UI where the kind can be taken off again. */}
{roadtripEnabled ? (
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('roadtrip.stop.kind')}</label>
<CustomSelect
value={form.stop_type ?? ''}
onChange={value => handleChange('stop_type', String(value) as RoadtripStopType | '')}
placeholder={t('roadtrip.stop.none')}
options={[
{ value: '', label: t('roadtrip.stop.none') },
...STOP_KINDS.map(k => ({ value: k.key, label: t(k.labelKey) })),
]}
size="sm"
/>
<p className="mt-1 text-caption text-content-faint">{t('roadtrip.stop.kindHelp')}</p>
</div>
) : null}
{/* Category */}
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.formCategory')}</label>
@@ -168,11 +168,14 @@ describe('RoadtripSidebar', () => {
expect(screen.getByText('Hamburg').closest('button')).not.toHaveAttribute('aria-current')
})
it('FE-ROADTRIP-SIDEBAR-009: a trip with nothing to drive explains itself', () => {
it('FE-ROADTRIP-SIDEBAR-009: a trip with nothing to drive shows the empty state, not a zeroed rail', () => {
// Nothing to total up, so no "0 km" standing over an empty list. The mascot
// is the shape every other empty state in TREK uses.
wrap(<RoadtripSidebar routes={routes({ days: [], totalDistance: 0, totalDuration: 0, totalStops: 0 })} />)
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument()
expect(screen.getByText('No route yet')).toBeInTheDocument()
expect(screen.queryByText('Hamburg')).not.toBeInTheDocument()
expect(screen.queryByText(/0 km/)).not.toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-010: each day carries its own distance and time', () => {
@@ -1,6 +1,6 @@
import React, { useState } from 'react'
import {
MapPin, CarFront, Footprints, Bike, Zap, AlertTriangle, Moon,
CarFront, Footprints, Bike, Zap, AlertTriangle, Moon,
ParkingSquare, Shuffle, Fuel, Clock, Spline,
type LucideIcon,
} from 'lucide-react'
@@ -18,6 +18,7 @@ import type { QuietDay, RoadtripDay, RoadtripRoutes, RoadtripStop } from './useR
import type { RouteVia } from '../../types'
import { FS } from './typeScale'
import type { RouteSegment } from '../../types'
import EmptyState from '../shared/EmptyState'
interface RoadtripSidebarProps {
/** Legs and totals for the whole trip, computed once in the planner hook. */
@@ -1045,11 +1046,17 @@ export default function RoadtripSidebar({
if (routes.days.length === 0) {
return (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="px-5 py-8 text-center">
<MapPin size={18} className="mx-auto mb-2 text-content-faint" aria-hidden />
<h3 className="font-semibold text-content" style={{ fontSize: FS.name }}>{t('roadtrip.empty.title')}</h3>
<p className="mt-1 text-content-muted" style={{ fontSize: FS.meta }}>{t('roadtrip.empty.body')}</p>
</div>
{/* The mascot rather than a pin and two lines of instructions. The rail
is empty because the trip has no days with places yet, which is a
state, not a task list: the same look every other empty state in TREK
has. */}
<EmptyState
scene="transport"
mood="sad"
title={t('roadtrip.empty.title')}
size={96}
surface="var(--bg-secondary)"
/>
</div>
)
}