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 ? ( +
+ {[ + { on: false, label: t('roadtrip.stay.mode.pause'), Icon: Hourglass }, + { on: true, label: t('roadtrip.stay.mode.night'), Icon: BedDouble }, + ].map(({ on, label, Icon }) => ( + + ))} +
+ ) : null} + + {asNight ? ( +
+
+ + {t('roadtrip.stay.checkOutDay')} + +
+ setEndDayId(Number(value))} + options={overnight!.days.map(d => ({ + value: String(d.id), + label: d.date + ? `${t('roadtrip.stay.dayNumber', { number: d.number })} · ${d.date}` + : t('roadtrip.stay.dayNumber', { number: d.number }), + }))} + size="sm" + /> +
+
+ {/* 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 ? ( +
+ {draft.poi.website ? ( + + {t('places.website')} + + ) : null} + {draft.poi.phone ? ( + + {draft.poi.phone} + + ) : null} +
+ ) : null} +
+ ) : ( + <>
{t('roadtrip.stop.kind')} @@ -158,6 +287,8 @@ export default function RoadtripStopPopup({ ))}
+ + )}