mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
0dd07befa7
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.
161 lines
7.1 KiB
TypeScript
161 lines
7.1 KiB
TypeScript
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>
|
||
)
|
||
}
|