diff --git a/client/src/components/Map/poiCategories.ts b/client/src/components/Map/poiCategories.ts
index b72cbee3e..ebf83128b 100644
--- a/client/src/components/Map/poiCategories.ts
+++ b/client/src/components/Map/poiCategories.ts
@@ -2,7 +2,7 @@ import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fue
// The road-trip kinds take their colour from the one place that defines it, so the
// search result, the map pin and the road-trip rail cannot drift apart.
import { SERVICE_COLORS } from '../Roadtrip/roadtripModel'
-import { STOP_KINDS } from '../Roadtrip/stopKinds'
+import { CORRIDOR_CATEGORIES, HOTEL_COLOR } from '../Roadtrip/stopKinds'
// The POI categories shown in the map "explore" pill. The `key` is the contract
// with the server (CATEGORY_OSM_FILTERS in mapsService.ts) — the OSM tag mapping
@@ -19,7 +19,9 @@ export const POI_CATEGORIES: PoiCategory[] = [
{ key: 'restaurant', labelKey: 'poi.cat.restaurants', Icon: Utensils, color: '#EF4444' },
{ key: 'cafe', labelKey: 'poi.cat.cafes', Icon: Coffee, color: '#B45309' },
{ key: 'bar', labelKey: 'poi.cat.bars', Icon: Wine, color: '#A855F7' },
- { key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: '#2563EB' },
+ // The colour lives with the road-trip categories, because the corridor search offers
+ // the same one and two definitions of a blue is how they drift apart.
+ { key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR },
{ key: 'sights', labelKey: 'poi.cat.sights', Icon: Camera, color: '#EC4899' },
{ key: 'museum', labelKey: 'poi.cat.museums', Icon: Landmark, color: '#6366F1' },
{ key: 'nature', labelKey: 'poi.cat.nature', Icon: Trees, color: '#16A34A' },
@@ -32,7 +34,7 @@ export const POI_CATEGORIES: PoiCategory[] = [
* lookup below all the same, so a hit drawn on the map gets its own icon and colour
* instead of the nameless grey dot an unknown category falls back to.
*/
-export const ROADTRIP_POI_CATEGORIES: PoiCategory[] = STOP_KINDS.map(
+export const ROADTRIP_POI_CATEGORIES: PoiCategory[] = CORRIDOR_CATEGORIES.map(
({ key, labelKey, Icon, color }) => ({ key, labelKey, Icon, color }),
)
diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx
index 01785ae95..3ced31657 100644
--- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx
+++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx
@@ -5,12 +5,13 @@ import {
} from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
+import EmptyState from '../shared/EmptyState'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import CustomSelect from '../shared/CustomSelect'
import RoadtripCategoryPicker from './RoadtripCategoryPicker'
import { serviceColor } from './roadtripModel'
-import { STOP_KIND_BY_KEY } from './stopKinds'
+import { CORRIDOR_CATEGORY_BY_KEY } from './stopKinds'
import { FS } from './typeScale'
import { CORRIDOR_CATEGORY_KEYS, CORRIDOR_WIDTHS_KM, type RoadtripCorridor } from './useRoadtripCorridor'
import type { CorridorPoi } from './useCorridorPois'
@@ -28,7 +29,7 @@ interface RoadtripCorridorPanelProps {
}
/** Label and icon per category, from the one table every road-trip surface reads. */
-const CATEGORY_META = STOP_KIND_BY_KEY
+const CATEGORY_META = CORRIDOR_CATEGORY_BY_KEY
/** The small capitalised word over a group of controls, matching the rail's own captions. */
const EYEBROW = 'font-geist font-semibold uppercase tracking-[0.15em] text-content-faint'
@@ -339,16 +340,23 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
{grouped.length === 0 ? (
-
-
-
- {search.loading
+ // The same empty state the rest of TREK uses, with the mascot acting out the
+ // scene. `search` while it is running, `idle` before anyone has asked, and a
+ // confused look when a filter left nothing standing — the picture says which
+ // of the three it is before the sentence is read.
+
-
+ : t('roadtrip.poi.empty')
+ }
+ />
) : (
<>
{corridor.nameFilter.trim() ? (
diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx
index 4bc6c5a37..8610c8659 100644
--- a/client/src/components/Roadtrip/RoadtripSidebar.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx
@@ -352,9 +352,12 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
* the trip is actually for. Its own icon on one flat disc: three kinds of pause that all
* mean "we are still driving", and the icon is what tells them apart.
*/
-function ServiceStop({ stop, entry, selected, onSelect, onEditStay }: {
+function ServiceStop({ stop, entry, driveFindings, selected, onSelect, onEditStay }: {
stop: RoadtripStop
entry: ScheduleEntry | undefined
+ /** Findings about the drive that ARRIVES here. A charging halt is a stop like any other
+ * as far as the tank is concerned, so it carries them the same way a numbered one does. */
+ driveFindings?: ScheduleWarning[]
selected: boolean
onSelect?: () => void
/** Opens the dialog for how long this pause takes. Absent means the rail is read-only. */
@@ -401,7 +404,11 @@ function ServiceStop({ stop, entry, selected, onSelect, onEditStay }: {
>
{stop.name}
-
+
+
+ {spurWorthLabelling(stop.offRoadMeters) ? : null}
+ {(driveFindings ?? []).map(w => )}
+
{entry?.arrival ? : null}
@@ -789,6 +796,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
w.index === i)}
selected={selectedAssignmentId === stop.assignmentId}
onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined}
onEditStay={onEditStay ? () => onEditStay({ placeId: stop.placeId, name: stop.name, minutes: stop.dwellMinutes, arrival: day.schedule.entries[i]?.arrival ?? null }) : undefined}
diff --git a/client/src/components/Roadtrip/RoadtripStopPopup.tsx b/client/src/components/Roadtrip/RoadtripStopPopup.tsx
index 92c96ace3..f5742c842 100644
--- a/client/src/components/Roadtrip/RoadtripStopPopup.tsx
+++ b/client/src/components/Roadtrip/RoadtripStopPopup.tsx
@@ -1,6 +1,7 @@
import React, { useState } from 'react'
-import { Fuel, Zap, ParkingSquare, Tent, Utensils, Camera, Hourglass, AlertTriangle, type LucideIcon } from 'lucide-react'
+import { ParkingSquare, Hourglass, AlertTriangle, BedDouble } from 'lucide-react'
import Modal from '../shared/Modal'
+import CustomSelect from '../shared/CustomSelect'
import { useTranslation } from '../../i18n/TranslationContext'
import { formatDurationShort, SERVICE_COLORS } from './roadtripModel'
import { STOP_KINDS, STOP_KIND_BY_KEY } from './stopKinds'
@@ -30,6 +31,16 @@ export interface RoadtripStopDraft {
/** Where in the day's chain it goes, worked out from how far along the drive it sits. */
position: number
dayNumber: number
+ /**
+ * Set only for a hit somebody could sleep at, and it is what turns the popup into two
+ * modes rather than one. Absent, the dialog asks the two questions it always asked.
+ */
+ overnight?: {
+ /** The trip's days from this one on, in travel order — the check-out options. */
+ days: { id: number; number: number; date: string | null }[]
+ /** The day after this one, or this one when it is the last. */
+ defaultEndDayId: number
+ }
}
interface RoadtripStopPopupProps {
@@ -38,7 +49,11 @@ interface RoadtripStopPopupProps {
duplicateName?: string | null
onClose: () => void
onSave: (input: { stopType: RoadtripStopType | null; dwellMinutes: number }) => Promise | void
- /** Opens the full place form instead, carrying what has been filled in so far. */
+ /**
+ * Books the hit as a night instead of a pause: the place, its day, and a row in
+ * day_accommodations, in one go. Only ever called when the draft carries `overnight`.
+ */
+ onSaveNight?: (input: { endDayId: number; checkIn: string; checkOut: string }) => Promise | void
/** Hands over what has been picked here, so the full form opens on the same answer. */
onMoreDetails: (stop: { stopType: RoadtripStopType | null; dwellMinutes: number }) => void
}
@@ -53,22 +68,39 @@ interface RoadtripStopPopupProps {
* how long) and says where it will land, with a way out to the full form for the rest.
*/
export default function RoadtripStopPopup({
- draft, duplicateName, onClose, onSave, onMoreDetails,
+ draft, duplicateName, onClose, onSave, onSaveNight, onMoreDetails,
}: RoadtripStopPopupProps): React.ReactElement | null {
const { t } = useTranslation()
const suggested = STOP_KINDS.find(k => k.key === draft?.poi.category)
const [stopType, setStopType] = useState(suggested?.key ?? null)
const [dwell, setDwell] = useState(suggested?.defaultMinutes ?? 30)
+ // A hotel is a night by default and a campsite a pause, which is what each already
+ // means everywhere else — but both offer the other, because a campsite is somewhere
+ // people sleep and a hotel is somewhere people stop for lunch.
+ const [night, setNight] = useState(draft?.poi.category === 'hotel')
+ const [endDayId, setEndDayId] = useState(draft?.overnight?.defaultEndDayId ?? null)
+ const [checkIn, setCheckIn] = useState('')
+ const [checkOut, setCheckOut] = useState('')
const [saving, setSaving] = useState(false)
if (!draft) return null
+ const overnight = draft.overnight
+ const asNight = !!overnight && !!onSaveNight && night
const kind = STOP_KINDS.find(k => k.key === stopType)
const submit = async (): Promise => {
setSaving(true)
try {
- await onSave({ stopType, dwellMinutes: dwell })
+ if (asNight) {
+ await onSaveNight?.({
+ endDayId: endDayId ?? overnight!.defaultEndDayId,
+ checkIn,
+ checkOut,
+ })
+ } else {
+ await onSave({ stopType, dwellMinutes: dwell })
+ }
} finally {
setSaving(false)
}
@@ -103,6 +135,103 @@ export default function RoadtripStopPopup({
) : null}
+ {/* Only where sleeping is on the table. A petrol station gets no choice, because
+ there is nothing to choose between. */}
+ {overnight && onSaveNight ? (
+
+ {/* Both optional: leaving them empty is normal, and the server stores null.
+ A hotel found on a map has no idea when its reception opens. */}
+
+
+
+
+ {/* What OSM knows about the house, which is the whole of what we offer: no
+ price, no availability, no link into a booking portal. TREK links to maps
+ and to the place's own site, never to a vendor. */}
+ {draft.poi.website || draft.poi.phone ? (
+