Files
TREK/client/src/components/Roadtrip/ServiceStopSection.tsx
T
Maurice 0dd07befa7 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.
2026-09-27 21:00:19 +02:00

161 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useId, useMemo } from 'react'
import CustomSelect from '../shared/CustomSelect'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import { StopKindChips, StopStayChips } from './StopChips'
import { serviceStopChoice, OFF_ROUTE_NOTE_KM, type ServiceStopMode } from './manualStop'
import type { RoadtripStopType } from '@trek/shared'
/**
* Adding a stop the corridor search never found, inside the place form itself.
*
* The search reads OpenStreetMap, and a good share of the chargers actually standing at
* a motorway junction are not in it, most Tesla Superchargers among them. The way round
* it was to leave road trip mode, add the place under Days, drag it onto the right day,
* come back and mark it a charging stop.
*
* So the form the rest of TREK adds places with does the job, with three changes: the
* category becomes the kind of stop (a pump is not a taste, it is a fact about the
* place), the costs go (a petrol stop is not an activity with a budget line), and this
* row asks where on the drive it belongs. Everything else about the form is the reason
* to use it, its typed-ahead search above all: that search is the one the whole app
* uses, and it is legible and scrollable.
*
* Which leg the stop lands on is worked out from where the place IS, and only once it
* has been chosen: unlike a corridor hit, nothing here has coordinates until then.
*/
/** Matches the form's own field labels, so the section reads as part of it. */
const LABEL = 'block text-sm font-medium text-content-secondary mb-1'
/** The label above already spaces the row, so the pills bring no margin of their own. */
const ROW = 'flex flex-wrap gap-1.5'
/**
* What holds the pieces of one option apart.
*
* Space rather than a sentence, and deliberately not a word or a glyph: the caption
* above already says these are the stretches to add between, so a row only has to name
* the two stops and how far off them the place lies. It also reads the same way in a
* language written right to left, which an arrow would not. An em space, because an
* option label is plain text and a single space would run a stop's name into the next
* one's.
*/
const GAP = ' '
interface ServiceStopSectionProps {
mode: ServiceStopMode
/** The kind chosen, from the form's own state: it is saved as `places.stop_type`. */
stopType: RoadtripStopType | null
/** Called only for a kind that is not already on, so a dwell set by hand survives. */
onStopType: (kind: RoadtripStopType) => void
minutes: number
onMinutes: (value: number) => void
/** The leg the traveller picked, or empty while the projection's answer stands. */
leg: string
onLeg: (value: string) => void
/** Where the place is, as the form has it. Null until one has been chosen. */
lat: number | null
lng: number | null
}
export default function ServiceStopSection({
mode, stopType, onStopType, minutes, onMinutes, leg, onLeg, lat, lng,
}: ServiceStopSectionProps): React.ReactElement {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const choice = useMemo(() => serviceStopChoice(mode, lat, lng, leg), [mode, lat, lng, leg])
const kindId = useId()
const stayId = useId()
const legId = useId()
/**
* The stretches on offer, each naming what it is and how far the place lies from it.
*
* The distance is the whole reason the preselected one is preselected, and until it
* was written down nothing on screen said so: the list looked like a guess to be
* second-guessed rather than a measurement to be overruled.
*/
const options = useMemo(() => [
...choice.legs.map(item => ({
value: item.value,
label: [
item.from,
item.to,
item.offRouteKm === null ? null : t('roadtrip.poi.offRoute', { distance: formatDistance(item.offRouteKm, distanceUnit) }),
].filter(Boolean).join(GAP),
badge: t('roadtrip.day', { number: item.dayNumber }),
})),
...(choice.end
? [{
// The end of the day the panel is on, which stays reachable however the rest of
// the trip has routed. It names its own day, so it carries no badge repeating it.
value: choice.end.value,
label: t('roadtrip.stop.landsOn', { day: choice.end.dayNumber, position: choice.end.position + 1 }),
}]
: []),
], [choice, t, distanceUnit])
return (
<div className="space-y-3">
<div>
<label id={kindId} className={LABEL}>{t('roadtrip.stop.kind')}</label>
<StopKindChips
value={stopType}
onPick={(kind, wasChosen) => { if (!wasChosen) onStopType(kind) }}
className={ROW}
labelledBy={kindId}
white
/>
</div>
<div>
<label id={stayId} className={LABEL}>{t('roadtrip.stop.stay')}</label>
<StopStayChips value={minutes} onPick={onMinutes} className={ROW} labelledBy={stayId} white />
</div>
{/* Where it goes. Preselected from the projection, and changeable.
Nothing to preselect from without a place: a name cannot be measured onto a
road, so the stop is added to the trip's places and the line below says so. */}
{!choice.located ? (
<p className="text-caption text-content-muted" data-testid="service-stop-needs-point">
{t('roadtrip.poi.manualNoCoords')}
</p>
) : choice.legs.length > 0 ? (
// A group named by the caption rather than a label pointing at the trigger: a
// label would replace the trigger's name, which is the stretch chosen, and a
// click on it would open the menu.
<div role="group" aria-labelledby={legId}>
<label id={legId} className={LABEL}>{t('roadtrip.poi.addBetween')}</label>
<CustomSelect
value={choice.legValue}
onChange={value => onLeg(String(value))}
options={options}
size="sm"
menuFit="content"
/>
{/* Said quietly rather than refused. It is the reason the leg above may be the
wrong one, and the only way the reader can know to change it. Measured
against the stretch actually chosen, so overruling the projection moves the
note with it, and a stretch that could not be measured says nothing at all
rather than borrowing a figure from the one the projection landed on. */}
{choice.placement && choice.placement.offRouteKm !== null && choice.placement.offRouteKm > OFF_ROUTE_NOTE_KM ? (
<p className="mt-1 text-caption text-warning">
{t('roadtrip.poi.manualOffRoute', {
distance: formatDistance(choice.placement.offRouteKm, distanceUnit),
})}
</p>
) : null}
</div>
) : choice.end ? (
<div>
<label className={LABEL}>{t('roadtrip.poi.addBetween')}</label>
<p className="text-caption text-content-muted">
{t('roadtrip.poi.manualAppend', { number: choice.end.dayNumber })}
</p>
</div>
) : null}
</div>
)
}