mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(tours): hold the client warning ratchet and the server format baseline flat after the rebase onto dev
This commit is contained in:
@@ -94,6 +94,16 @@ function clickOn(el: Element) {
|
||||
})
|
||||
}
|
||||
|
||||
/** The Leaflet marker that renders `el`; throws when the map has none. */
|
||||
function markerOf(map: L.Map, el: Element): L.Marker {
|
||||
let found: L.Marker | null = null
|
||||
map.eachLayer(layer => {
|
||||
if (layer instanceof L.Marker && layer.getElement() === el) found = layer
|
||||
})
|
||||
if (!found) throw new Error('no marker renders this element')
|
||||
return found
|
||||
}
|
||||
|
||||
function pointer(type: string, x: number, y: number): Event {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperties(event, {
|
||||
@@ -149,11 +159,7 @@ describe('MapView on a real Leaflet map', () => {
|
||||
const map = maps[maps.length - 1]
|
||||
await waitFor(() => expect(view.container.querySelector('.tour-planner-waypoint-marker')).not.toBeNull())
|
||||
const pin = view.container.querySelector('.tour-planner-waypoint-marker') as HTMLElement
|
||||
let marker: L.Marker | null = null
|
||||
map.eachLayer(layer => {
|
||||
if (layer instanceof L.Marker && layer.getElement() === pin) marker = layer
|
||||
})
|
||||
expect(marker).not.toBeNull()
|
||||
const marker = markerOf(map, pin)
|
||||
const start = map.latLngToContainerPoint([waypoint.lat, waypoint.lng])
|
||||
const end = start.add([30, 20])
|
||||
const expected = map.containerPointToLatLng(end)
|
||||
@@ -164,8 +170,8 @@ describe('MapView on a real Leaflet map', () => {
|
||||
pin.dispatchEvent(mouse('mousedown', start.x, start.y))
|
||||
pin.dispatchEvent(pointer('pointermove', end.x, end.y))
|
||||
})
|
||||
expect(marker!.getLatLng().lat).toBeCloseTo(expected.lat, 6)
|
||||
expect(marker!.getLatLng().lng).toBeCloseTo(expected.lng, 6)
|
||||
expect(marker.getLatLng().lat).toBeCloseTo(expected.lat, 6)
|
||||
expect(marker.getLatLng().lng).toBeCloseTo(expected.lng, 6)
|
||||
expect(onMove).not.toHaveBeenCalled()
|
||||
expect(waypoints[0]).toEqual(waypoint)
|
||||
|
||||
@@ -184,8 +190,8 @@ describe('MapView on a real Leaflet map', () => {
|
||||
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 34.69, lng: 135.84, sampleIndex: 0 }}
|
||||
/>
|
||||
)
|
||||
expect(marker!.getLatLng().lat).toBeCloseTo(expected.lat, 6)
|
||||
expect(marker!.getLatLng().lng).toBeCloseTo(expected.lng, 6)
|
||||
expect(marker.getLatLng().lat).toBeCloseTo(expected.lat, 6)
|
||||
expect(marker.getLatLng().lng).toBeCloseTo(expected.lng, 6)
|
||||
|
||||
act(() => {
|
||||
pin.dispatchEvent(pointer('pointerup', end.x, end.y))
|
||||
@@ -238,10 +244,7 @@ describe('MapView on a real Leaflet map', () => {
|
||||
const map = maps[maps.length - 1]
|
||||
await waitFor(() => expect(view.container.querySelector('.tour-planner-waypoint-marker')).not.toBeNull())
|
||||
const pin = view.container.querySelector('.tour-planner-waypoint-marker') as HTMLElement
|
||||
let marker: L.Marker | null = null
|
||||
map.eachLayer(layer => {
|
||||
if (layer instanceof L.Marker && layer.getElement() === pin) marker = layer
|
||||
})
|
||||
const marker = markerOf(map, pin)
|
||||
const start = map.latLngToContainerPoint([waypoint.lat, waypoint.lng])
|
||||
const end = start.add([30, 20])
|
||||
|
||||
@@ -250,15 +253,15 @@ describe('MapView on a real Leaflet map', () => {
|
||||
pin.dispatchEvent(pointer('pointermove', end.x, end.y))
|
||||
pin.dispatchEvent(pointer('pointercancel', end.x, end.y))
|
||||
})
|
||||
expect(marker!.getLatLng().lat).toBeCloseTo(waypoint.lat, 6)
|
||||
expect(marker!.getLatLng().lng).toBeCloseTo(waypoint.lng, 6)
|
||||
expect(marker.getLatLng().lat).toBeCloseTo(waypoint.lat, 6)
|
||||
expect(marker.getLatLng().lng).toBeCloseTo(waypoint.lng, 6)
|
||||
expect(onMove).not.toHaveBeenCalled()
|
||||
|
||||
clickOn(pin)
|
||||
expect(onSelect).toHaveBeenCalledOnce()
|
||||
expect(onSelect).toHaveBeenCalledWith(waypoint.id)
|
||||
expect(onMapClick).not.toHaveBeenCalled()
|
||||
act(() => map.fire('click', { latlng: L.latLng(34.7, 135.85) }))
|
||||
act(() => { map.fire('click', { latlng: L.latLng(34.7, 135.85) }) })
|
||||
expect(onMapClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo, type ComponentType } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useMemo, useCallback, createElement, memo, type ComponentType } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
|
||||
@@ -784,7 +784,7 @@ function PlannerWaypointMarker({
|
||||
const map = useMap()
|
||||
const [marker, setMarker] = useState<L.Marker | null>(null)
|
||||
const handlersRef = useRef({ onClick, onMove })
|
||||
handlersRef.current = { onClick, onMove }
|
||||
useLayoutEffect(() => { handlersRef.current = { onClick, onMove } }, [onClick, onMove])
|
||||
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng])
|
||||
const icon = useMemo(() => plannerWaypointIcon(index, selected, !!onMove), [index, onMove, selected])
|
||||
const eventHandlers = useMemo(() => ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag'
|
||||
@@ -711,7 +711,9 @@ export function MapViewGL({
|
||||
const viaPinsRef = useRef<PlacePin[]>([])
|
||||
const plannerWaypointPinsRef = useRef<PlacePin[]>([])
|
||||
const plannerWaypointHandlersRef = useRef({ onPlannerWaypointClick, onPlannerWaypointMove })
|
||||
plannerWaypointHandlersRef.current = { onPlannerWaypointClick, onPlannerWaypointMove }
|
||||
useLayoutEffect(() => {
|
||||
plannerWaypointHandlersRef.current = { onPlannerWaypointClick, onPlannerWaypointMove }
|
||||
}, [onPlannerWaypointClick, onPlannerWaypointMove])
|
||||
const plannerWaypointsDraggable = !!onPlannerWaypointMove
|
||||
const profileFocusPinRef = useRef<PlacePin | null>(null)
|
||||
/** The drive-time pills on the offered routes; same treatment. */
|
||||
|
||||
@@ -117,24 +117,12 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel
|
||||
// than written into `results`, so a search answer that lands after the field was
|
||||
// shortened cannot replace them, and a place already picked does not reopen them.
|
||||
const typed = query.trim()
|
||||
const showPicks = !value && typed.length < 3 && !!places?.length
|
||||
const rows = showPicks
|
||||
? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase()))
|
||||
: results
|
||||
const showNoResults =
|
||||
showSearchStatus && !showPicks && searchedQuery === typed && rows.length === 0 && !loading && !searchError
|
||||
const showError = showSearchStatus && searchError && searchedQuery === typed
|
||||
const listOpen = open && ((loading && !showPicks) || rows.length > 0 || showNoResults || showError)
|
||||
const { showPicks, rows } = pickRows(value, typed, places, results)
|
||||
const { showNoResults, showError, listOpen } = searchStatus({
|
||||
showSearchStatus, showPicks, searchedQuery, typed, rowCount: rows.length, loading, searchError, open,
|
||||
})
|
||||
const accessibleLabel = ariaLabel ?? placeholder ?? t('reservations.searchLocation')
|
||||
const statusLine = showError ? (
|
||||
<div role="alert" className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
{t('common.error')}
|
||||
</div>
|
||||
) : showNoResults ? (
|
||||
<div role="status" className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
{t('help.center.searchEmpty', { query: typed })}
|
||||
</div>
|
||||
) : null
|
||||
const statusLine = renderStatusLine(showError, showNoResults, typed, t)
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Escape closes the list even while it only shows a status line.
|
||||
@@ -156,14 +144,7 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel
|
||||
onOpen={() => setOpen(true)}
|
||||
onKeyDown={onKeyDown}
|
||||
onClear={value ? clear : undefined}
|
||||
inputAria={showSearchStatus ? {
|
||||
role: 'combobox',
|
||||
'aria-label': accessibleLabel,
|
||||
'aria-autocomplete': 'list',
|
||||
'aria-expanded': listOpen,
|
||||
'aria-controls': listOpen ? listId : undefined,
|
||||
'aria-activedescendant': listOpen && highlight >= 0 ? `${listId}-option-${highlight}` : undefined,
|
||||
} : undefined}
|
||||
inputAria={showSearchStatus ? comboboxAria(accessibleLabel, listOpen, listId, highlight) : undefined}
|
||||
/>
|
||||
|
||||
{open && (
|
||||
@@ -190,3 +171,63 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function pickRows<T>(value: LocationPoint | null, typed: string, places: LocationPoint[] | undefined, results: T[]) {
|
||||
const showPicks = !value && typed.length < 3 && !!places?.length
|
||||
const rows: (T | LocationPoint)[] = showPicks
|
||||
? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase()))
|
||||
: results
|
||||
return { showPicks, rows }
|
||||
}
|
||||
|
||||
interface SearchStatusInput {
|
||||
showSearchStatus: boolean
|
||||
showPicks: boolean
|
||||
searchedQuery: string | null
|
||||
typed: string
|
||||
rowCount: number
|
||||
loading: boolean
|
||||
searchError: boolean
|
||||
open: boolean
|
||||
}
|
||||
|
||||
function searchStatus({ showSearchStatus, showPicks, searchedQuery, typed, rowCount, loading, searchError, open }: SearchStatusInput) {
|
||||
const showNoResults =
|
||||
showSearchStatus && !showPicks && searchedQuery === typed && rowCount === 0 && !loading && !searchError
|
||||
const showError = showSearchStatus && searchError && searchedQuery === typed
|
||||
const listOpen = open && ((loading && !showPicks) || rowCount > 0 || showNoResults || showError)
|
||||
return { showNoResults, showError, listOpen }
|
||||
}
|
||||
|
||||
function renderStatusLine(
|
||||
showError: boolean,
|
||||
showNoResults: boolean,
|
||||
typed: string,
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
) {
|
||||
return showError ? (
|
||||
<div role="alert" className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
{t('common.error')}
|
||||
</div>
|
||||
) : showNoResults ? (
|
||||
<div role="status" className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
{t('help.center.searchEmpty', { query: typed })}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
function comboboxAria(
|
||||
accessibleLabel: string,
|
||||
listOpen: boolean,
|
||||
listId: string,
|
||||
highlight: number,
|
||||
): React.AriaAttributes & { role?: React.AriaRole } {
|
||||
return {
|
||||
role: 'combobox',
|
||||
'aria-label': accessibleLabel,
|
||||
'aria-autocomplete': 'list',
|
||||
'aria-expanded': listOpen,
|
||||
'aria-controls': listOpen ? listId : undefined,
|
||||
'aria-activedescendant': listOpen && highlight >= 0 ? `${listId}-option-${highlight}` : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { ArrowUp, Clock, Footprints, Pause, Ruler, type LucideIcon } from 'lucide-react';
|
||||
import { useId, useState } from 'react';
|
||||
import { createElement, useId, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { formatDistance, formatElevation } from '../../utils/units';
|
||||
@@ -10,7 +10,6 @@ import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation'
|
||||
|
||||
export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = tourDayRowIcon(type);
|
||||
const known = ['hike', 'bike', 'walk', 'kayak'].includes(type);
|
||||
return (
|
||||
<span
|
||||
@@ -19,7 +18,7 @@ export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: numbe
|
||||
className="grid flex-none place-items-center rounded-full"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<Icon size={Math.round(size * 0.6)} aria-hidden="true" />
|
||||
{createElement(tourDayRowIcon(type), { size: Math.round(size * 0.6), 'aria-hidden': 'true' })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,7 +137,6 @@ export default function TourDetailDialog({
|
||||
const navigationTargets = getNavigationTargets(place)
|
||||
const navigationLabel = navigationTargets.length === 1 ? navigationTargetLabel(navigationTargets[0], t) : t('inspector.navigation')
|
||||
const source = tourSource(tour)
|
||||
const tourTimes = tourPlannedTimes(tour)
|
||||
const description = tour.description !== undefined ? tour.description : place.description
|
||||
const website = tourWebsitePresentation(tour.website !== undefined ? tour.website : place.website)
|
||||
const colorLabel = place.route_color ? place.route_color.toUpperCase() : t('inspector.trackColorAuto')
|
||||
@@ -220,27 +219,7 @@ export default function TourDetailDialog({
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<SoftPill icon={<Mountain size={11} strokeWidth={2} className="flex-none" style={{ color: trackColor }} />}>{t(`tourTypes.${tour.tour_type}`)}</SoftPill>
|
||||
{tour.difficulty && <SoftPill>{t('tours.detail.difficulty')}: {tour.difficulty}</SoftPill>}
|
||||
{tourTimes.walkingMinutes != null && (
|
||||
<TimePill>
|
||||
<span aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}>
|
||||
{t('tours.planner.inspector.duration')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
{tourTimes.breakMinutes != null && (
|
||||
<TimePill>
|
||||
<span aria-label={`${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(tourTimes.breakMinutes)}`}>
|
||||
{t('tours.planner.breaksAdditional')}: {formatPlannedTourDuration(tourTimes.breakMinutes)}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
{tourTimes.plannedTotalMinutes != null && (
|
||||
<TimePill>
|
||||
<span aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}${tourTimes.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}>
|
||||
{t('tours.planner.plannedTotalDuration')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}{tourTimes.manuallyOverridden ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
<TourTimePills tour={tour} />
|
||||
<SoftPill>{hikeSourceBadgeLabel(tour, t)}</SoftPill>
|
||||
{tour.caution && (
|
||||
<Tooltip label={t('tours.caution.tooltip')}>
|
||||
@@ -396,6 +375,37 @@ export default function TourDetailDialog({
|
||||
)
|
||||
}
|
||||
|
||||
/** Walking time, breaks and the planned total, each as its own pill; missing values are skipped. */
|
||||
function TourTimePills({ tour }: { tour: TourListItem }) {
|
||||
const { t } = useTranslation()
|
||||
const tourTimes = tourPlannedTimes(tour)
|
||||
return (
|
||||
<>
|
||||
{tourTimes.walkingMinutes != null && (
|
||||
<TimePill>
|
||||
<span aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}>
|
||||
{t('tours.planner.inspector.duration')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
{tourTimes.breakMinutes != null && (
|
||||
<TimePill>
|
||||
<span aria-label={`${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(tourTimes.breakMinutes)}`}>
|
||||
{t('tours.planner.breaksAdditional')}: {formatPlannedTourDuration(tourTimes.breakMinutes)}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
{tourTimes.plannedTotalMinutes != null && (
|
||||
<TimePill>
|
||||
<span aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}${tourTimes.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}>
|
||||
{t('tours.planner.plannedTotalDuration')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}{tourTimes.manuallyOverridden ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Markdown in a framed box, like the inspector's; long words wrap instead of widening the card. */
|
||||
function MarkdownBox({ children }: { children: string }) {
|
||||
return (
|
||||
|
||||
@@ -92,8 +92,6 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
|
||||
const distanceUnit = useSettingsStore(state => state.settings.distance_unit)
|
||||
const [confirmDiscard, setConfirmDiscard] = useState(false)
|
||||
const [pendingDifficulty, setPendingDifficulty] = useState<1 | 2 | 3 | 4 | 5 | 6 | null>(null)
|
||||
const [draggedWaypointId, setDraggedWaypointId] = useState<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<{ id: string; placement: 'before' | 'after' } | null>(null);
|
||||
const alpineAcknowledged = useRef(false)
|
||||
const isSaving = planner.isSaving
|
||||
const statusKey = isSaving ? STATUS_KEY.saving : STATUS_KEY[planner.status]
|
||||
@@ -103,6 +101,10 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
|
||||
if (planner.hasUnsavedChanges) setConfirmDiscard(true)
|
||||
else planner.returnToNeutral()
|
||||
}
|
||||
const changeDifficulty = (value: 1 | 2 | 3 | 4 | 5 | 6) => {
|
||||
if (value >= 4 && !alpineAcknowledged.current) setPendingDifficulty(value)
|
||||
else planner.setMaxHikingDifficulty(value)
|
||||
}
|
||||
|
||||
if (planner.mode.type === 'neutral') {
|
||||
return (
|
||||
@@ -169,7 +171,6 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
|
||||
}
|
||||
|
||||
const isSavedEdit = planner.mode.type === 'edit-saved'
|
||||
const lastIndex = planner.waypoints.length - 1
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -192,279 +193,9 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
|
||||
<TourNotice role="status" icon={<RotateCcw size={13} strokeWidth={2} className="text-content-faint" />}>{t('tours.planner.restored')}</TourNotice>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="min-w-0">
|
||||
<label className={LABEL} htmlFor="tour-planner-name">{t('tours.planner.name')}</label>
|
||||
<input
|
||||
id="tour-planner-name"
|
||||
disabled={!canEdit || isSaving}
|
||||
value={planner.name}
|
||||
onChange={event => { if (canEdit && !isSaving) planner.setName(event.target.value) }}
|
||||
maxLength={255}
|
||||
placeholder={t('tours.planner.namePlaceholder')}
|
||||
className={INPUT}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<label className={LABEL} htmlFor="tour-planner-description">{t('places.formDescription')}</label>
|
||||
<textarea
|
||||
id="tour-planner-description"
|
||||
disabled={!canEdit || isSaving}
|
||||
value={planner.description}
|
||||
onChange={event => { if (canEdit && !isSaving) planner.setDescription(event.target.value) }}
|
||||
maxLength={2000}
|
||||
rows={3}
|
||||
className={`${INPUT} min-h-[72px] resize-y`}
|
||||
/>
|
||||
</div>
|
||||
<TourSection label={t('tours.planner.plannedTotalDuration')}>
|
||||
<div data-testid="tour-planner-time-grid" className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-[5px] flex min-w-0 items-center gap-1">
|
||||
<label className={`${LABEL} mb-0`} htmlFor="tour-planner-breaks">{t('tours.planner.breaksAdditional')}</label>
|
||||
<DurationInfo
|
||||
label={t('tours.planner.breaksAdditionalInfoLabel')}
|
||||
description={t('tours.planner.breaksAdditionalInfo')}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
id="tour-planner-breaks"
|
||||
aria-label={t('tours.planner.breaksAdditional')}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={1440}
|
||||
step={1}
|
||||
disabled={!canEdit || isSaving}
|
||||
value={planner.breakAdditionalMinutes ?? ''}
|
||||
onChange={event => { if (canEdit && !isSaving) planner.setBreakAdditionalMinutes(event.target.value === '' ? null : Number(event.target.value)) }}
|
||||
aria-invalid={planner.breakAdditionalInvalid || undefined}
|
||||
aria-describedby={`tour-planner-breaks-hint${planner.breakAdditionalInvalid ? ' tour-planner-breaks-error' : ''}`}
|
||||
className={INPUT}
|
||||
/>
|
||||
<span id="tour-planner-breaks-hint" className="mt-1 block text-content-faint" style={fs(10.5)}>
|
||||
{t('tours.planner.breaksAdditionalHint')}
|
||||
</span>
|
||||
{planner.breakAdditionalInvalid && (
|
||||
<span id="tour-planner-breaks-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
|
||||
{t('tours.planner.timeValueInvalid')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-[5px] flex min-w-0 items-center gap-1">
|
||||
<label className={`${LABEL} mb-0`} htmlFor="tour-planner-planned-duration">
|
||||
{t('tours.planner.plannedTotalDuration')}
|
||||
{planner.plannedDurationMinutes != null && <span className="ms-1 normal-case text-info">· {t('tours.planner.plannedTotalManual')}</span>}
|
||||
</label>
|
||||
<DurationInfo
|
||||
label={t('tours.planner.plannedTotalInfoLabel')}
|
||||
description={t('tours.planner.plannedTotalInfo')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<input
|
||||
id="tour-planner-planned-duration"
|
||||
aria-label={t('tours.planner.plannedTotalDuration')}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={1440}
|
||||
step={1}
|
||||
disabled={!canEdit || isSaving}
|
||||
value={planner.plannedDurationMinutes ?? planner.plannedTotalMinutes ?? ''}
|
||||
onChange={event => { if (canEdit && !isSaving) planner.setPlannedDurationMinutes(event.target.value === '' ? null : Number(event.target.value)) }}
|
||||
aria-invalid={planner.plannedDurationInvalid || undefined}
|
||||
aria-describedby={planner.plannedDurationInvalid ? 'tour-planner-planned-duration-error' : undefined}
|
||||
className={`${INPUT} min-w-0 flex-none`}
|
||||
style={{ width: '6rem' }}
|
||||
/>
|
||||
{planner.plannedDurationMinutes == null && <span className="text-content-faint" style={fs(10.5)}>{t('tours.planner.plannedTotalAutomatic')}</span>}
|
||||
</div>
|
||||
{planner.plannedDurationInvalid && (
|
||||
<span id="tour-planner-planned-duration-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
|
||||
{t('tours.planner.timeValueInvalid')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TourSection>
|
||||
<div className="min-w-0">
|
||||
<label className={LABEL} htmlFor="tour-planner-website">{t('places.formWebsite')}</label>
|
||||
<input
|
||||
id="tour-planner-website"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
disabled={!canEdit || isSaving}
|
||||
value={planner.website}
|
||||
onChange={event => { if (canEdit && !isSaving) planner.setWebsite(event.target.value) }}
|
||||
maxLength={500}
|
||||
placeholder={t('collab.notes.websitePlaceholder')}
|
||||
aria-invalid={planner.websiteInvalid || undefined}
|
||||
aria-describedby={planner.websiteInvalid ? 'tour-planner-website-error' : undefined}
|
||||
className={INPUT}
|
||||
/>
|
||||
{planner.websiteInvalid && (
|
||||
<span id="tour-planner-website-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
|
||||
{t('dawarich.error.invalid_url')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<label className={LABEL} htmlFor="tour-planner-difficulty">{t('tours.planner.maxDifficulty')}</label>
|
||||
<CustomSelect
|
||||
id="tour-planner-difficulty"
|
||||
disabled={!canEdit || isSaving}
|
||||
ariaLabel={t('tours.planner.maxDifficulty')}
|
||||
value={planner.maxHikingDifficulty}
|
||||
onChange={nextValue => {
|
||||
if (!canEdit || isSaving) return
|
||||
const value = Number(nextValue) as 1 | 2 | 3 | 4 | 5 | 6
|
||||
if (value >= 4 && !alpineAcknowledged.current) setPendingDifficulty(value)
|
||||
else planner.setMaxHikingDifficulty(value)
|
||||
}}
|
||||
options={[1, 2, 3, 4, 5, 6].map(value => ({ value, label: t(`tours.planner.difficulty.t${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
{planner.maxHikingDifficulty === 3 && (
|
||||
<TourNotice tone="warning" role="status" icon={<AlertTriangle size={13} strokeWidth={2} />}>{t('tours.planner.difficulty.t3Warning')}</TourNotice>
|
||||
)}
|
||||
</div>
|
||||
<TourDetailsFields planner={planner} locked={!canEdit || isSaving} onDifficultyChange={changeDifficulty} />
|
||||
|
||||
<TourSection label={t('tours.planner.waypoints')}>
|
||||
{planner.waypoints.length === 0 ? (
|
||||
<div data-testid="tour-planner-empty-state" className="flex items-start gap-2 rounded-[12px] border border-dashed border-edge px-3 py-3 leading-snug text-content-muted" style={fs(12, 'body')}>
|
||||
<MapPin size={14} strokeWidth={2} className="mt-px flex-none text-content-faint" />
|
||||
<p className="m-0">{t('tours.planner.startingPoint')} {t('tours.planner.firstUseDetails')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ol data-touch-drag className="m-0 flex list-none flex-col gap-px p-0">
|
||||
{planner.waypoints.map((point, index) => {
|
||||
const selected = planner.selectedWaypointId === point.id;
|
||||
const isDropTarget = dropTarget?.id === point.id && draggedWaypointId !== point.id;
|
||||
return (
|
||||
<li
|
||||
key={point.id}
|
||||
data-testid="tour-planner-waypoint-row"
|
||||
onDragStart={(event) => {
|
||||
if (!canEdit || isSaving) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', point.id);
|
||||
}
|
||||
setDraggedWaypointId(point.id);
|
||||
setDropTarget(null);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (!canEdit || isSaving || !draggedWaypointId || draggedWaypointId === point.id) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const placement = event.clientY >= bounds.top + bounds.height / 2 ? 'after' : 'before';
|
||||
setDropTarget((current) =>
|
||||
current?.id === point.id && current.placement === placement
|
||||
? current
|
||||
: { id: point.id, placement }
|
||||
);
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
const draggedId = draggedWaypointId || event.dataTransfer.getData('text/plain');
|
||||
if (
|
||||
canEdit &&
|
||||
!isSaving &&
|
||||
draggedId &&
|
||||
draggedId !== point.id &&
|
||||
dropTarget?.id === point.id
|
||||
) {
|
||||
planner.reorderWaypoint(draggedId, point.id, dropTarget.placement);
|
||||
}
|
||||
setDraggedWaypointId(null);
|
||||
setDropTarget(null);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDraggedWaypointId(null);
|
||||
setDropTarget(null);
|
||||
}}
|
||||
className={`relative rounded-[12px] ${draggedWaypointId === point.id ? 'opacity-45' : ''} ${isDropTarget ? 'bg-surface-hover' : ''}`}
|
||||
>
|
||||
{isDropTarget && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute inset-x-2 z-10 h-0.5 rounded bg-accent ${dropTarget.placement === 'before' ? 'top-0' : 'bottom-0'}`}
|
||||
/>
|
||||
)}
|
||||
<div className="group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-1.5">
|
||||
<span
|
||||
draggable={canEdit && !isSaving}
|
||||
aria-hidden="true"
|
||||
data-testid="tour-planner-waypoint-drag-handle"
|
||||
className={`grid h-6 w-5 flex-none place-items-center text-content-faint ${canEdit && !isSaving ? 'cursor-grab active:cursor-grabbing' : 'opacity-35'}`}
|
||||
>
|
||||
<GripVertical size={14} strokeWidth={1.8} />
|
||||
</span>
|
||||
<span
|
||||
aria-label={t('tours.planner.waypointLabel', { n: index + 1 })}
|
||||
data-waypoint-number={index + 1}
|
||||
className={`grid h-7 w-7 flex-none place-items-center rounded-[9px] font-geist font-semibold tabular-nums shadow-sm ${selected ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => planner.setSelectedWaypointId(point.id)}
|
||||
className="flex min-w-0 flex-1 flex-col text-start outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)]"
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
>
|
||||
<span className="block font-semibold leading-tight text-content" style={fs(12.5, 'body')}>
|
||||
{roleLabel(point.role, t)}
|
||||
</span>
|
||||
<span
|
||||
className="mt-0.5 block truncate font-geist tabular-nums text-content-faint"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{point.lat.toFixed(5)}, {point.lng.toFixed(5)}
|
||||
</span>
|
||||
</button>
|
||||
{canEdit && (
|
||||
<span
|
||||
className={`flex flex-none items-center gap-0.5 transition-opacity ${selected ? '' : 'opacity-50 group-focus-within:opacity-100 group-hover:opacity-100'}`}
|
||||
>
|
||||
<WaypointAction
|
||||
label={t('tours.planner.moveUp')}
|
||||
disabled={isSaving || index === 0}
|
||||
onAct={() => planner.moveWaypoint(point.id, -1)}
|
||||
>
|
||||
<ArrowUp size={13} strokeWidth={2.2} />
|
||||
</WaypointAction>
|
||||
<WaypointAction
|
||||
label={t('tours.planner.moveDown')}
|
||||
disabled={isSaving || index === lastIndex}
|
||||
onAct={() => planner.moveWaypoint(point.id, 1)}
|
||||
>
|
||||
<ArrowDown size={13} strokeWidth={2.2} />
|
||||
</WaypointAction>
|
||||
<WaypointAction
|
||||
label={t('tours.planner.remove')}
|
||||
disabled={isSaving}
|
||||
danger
|
||||
onAct={() => planner.removeWaypoint(point.id)}
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
</WaypointAction>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</TourSection>
|
||||
<WaypointList planner={planner} canEdit={canEdit} isSaving={isSaving} />
|
||||
|
||||
{(statusKey || planner.error) && (
|
||||
<TourNotice
|
||||
@@ -527,6 +258,307 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
|
||||
)
|
||||
}
|
||||
|
||||
interface TourFieldProps {
|
||||
planner: TourPlannerController
|
||||
/** True while the viewer may not edit or a save is in flight. */
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
/** Breaks on top of the moving time, and the planned total that defaults to their sum. */
|
||||
function TourTimeGrid({ planner, locked }: TourFieldProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div data-testid="tour-planner-time-grid" className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-[5px] flex min-w-0 items-center gap-1">
|
||||
<label className={`${LABEL} mb-0`} htmlFor="tour-planner-breaks">{t('tours.planner.breaksAdditional')}</label>
|
||||
<DurationInfo
|
||||
label={t('tours.planner.breaksAdditionalInfoLabel')}
|
||||
description={t('tours.planner.breaksAdditionalInfo')}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
id="tour-planner-breaks"
|
||||
aria-label={t('tours.planner.breaksAdditional')}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={1440}
|
||||
step={1}
|
||||
disabled={locked}
|
||||
value={planner.breakAdditionalMinutes ?? ''}
|
||||
onChange={event => { if (!locked) planner.setBreakAdditionalMinutes(event.target.value === '' ? null : Number(event.target.value)) }}
|
||||
aria-invalid={planner.breakAdditionalInvalid || undefined}
|
||||
aria-describedby={`tour-planner-breaks-hint${planner.breakAdditionalInvalid ? ' tour-planner-breaks-error' : ''}`}
|
||||
className={INPUT}
|
||||
/>
|
||||
<span id="tour-planner-breaks-hint" className="mt-1 block text-content-faint" style={fs(10.5)}>
|
||||
{t('tours.planner.breaksAdditionalHint')}
|
||||
</span>
|
||||
{planner.breakAdditionalInvalid && (
|
||||
<span id="tour-planner-breaks-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
|
||||
{t('tours.planner.timeValueInvalid')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="mb-[5px] flex min-w-0 items-center gap-1">
|
||||
<label className={`${LABEL} mb-0`} htmlFor="tour-planner-planned-duration">
|
||||
{t('tours.planner.plannedTotalDuration')}
|
||||
{planner.plannedDurationMinutes != null && <span className="ms-1 normal-case text-info">· {t('tours.planner.plannedTotalManual')}</span>}
|
||||
</label>
|
||||
<DurationInfo
|
||||
label={t('tours.planner.plannedTotalInfoLabel')}
|
||||
description={t('tours.planner.plannedTotalInfo')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<input
|
||||
id="tour-planner-planned-duration"
|
||||
aria-label={t('tours.planner.plannedTotalDuration')}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={1440}
|
||||
step={1}
|
||||
disabled={locked}
|
||||
value={planner.plannedDurationMinutes ?? planner.plannedTotalMinutes ?? ''}
|
||||
onChange={event => { if (!locked) planner.setPlannedDurationMinutes(event.target.value === '' ? null : Number(event.target.value)) }}
|
||||
aria-invalid={planner.plannedDurationInvalid || undefined}
|
||||
aria-describedby={planner.plannedDurationInvalid ? 'tour-planner-planned-duration-error' : undefined}
|
||||
className={`${INPUT} min-w-0 flex-none`}
|
||||
style={{ width: '6rem' }}
|
||||
/>
|
||||
{planner.plannedDurationMinutes == null && <span className="text-content-faint" style={fs(10.5)}>{t('tours.planner.plannedTotalAutomatic')}</span>}
|
||||
</div>
|
||||
{planner.plannedDurationInvalid && (
|
||||
<span id="tour-planner-planned-duration-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
|
||||
{t('tours.planner.timeValueInvalid')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Name, description, timings, website and the difficulty cap of the tour being planned. */
|
||||
function TourDetailsFields({ planner, locked, onDifficultyChange }: TourFieldProps & { onDifficultyChange: (value: 1 | 2 | 3 | 4 | 5 | 6) => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="min-w-0">
|
||||
<label className={LABEL} htmlFor="tour-planner-name">{t('tours.planner.name')}</label>
|
||||
<input
|
||||
id="tour-planner-name"
|
||||
disabled={locked}
|
||||
value={planner.name}
|
||||
onChange={event => { if (!locked) planner.setName(event.target.value) }}
|
||||
maxLength={255}
|
||||
placeholder={t('tours.planner.namePlaceholder')}
|
||||
className={INPUT}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<label className={LABEL} htmlFor="tour-planner-description">{t('places.formDescription')}</label>
|
||||
<textarea
|
||||
id="tour-planner-description"
|
||||
disabled={locked}
|
||||
value={planner.description}
|
||||
onChange={event => { if (!locked) planner.setDescription(event.target.value) }}
|
||||
maxLength={2000}
|
||||
rows={3}
|
||||
className={`${INPUT} min-h-[72px] resize-y`}
|
||||
/>
|
||||
</div>
|
||||
<TourSection label={t('tours.planner.plannedTotalDuration')}>
|
||||
<TourTimeGrid planner={planner} locked={locked} />
|
||||
</TourSection>
|
||||
<div className="min-w-0">
|
||||
<label className={LABEL} htmlFor="tour-planner-website">{t('places.formWebsite')}</label>
|
||||
<input
|
||||
id="tour-planner-website"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
disabled={locked}
|
||||
value={planner.website}
|
||||
onChange={event => { if (!locked) planner.setWebsite(event.target.value) }}
|
||||
maxLength={500}
|
||||
placeholder={t('collab.notes.websitePlaceholder')}
|
||||
aria-invalid={planner.websiteInvalid || undefined}
|
||||
aria-describedby={planner.websiteInvalid ? 'tour-planner-website-error' : undefined}
|
||||
className={INPUT}
|
||||
/>
|
||||
{planner.websiteInvalid && (
|
||||
<span id="tour-planner-website-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
|
||||
{t('dawarich.error.invalid_url')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<label className={LABEL} htmlFor="tour-planner-difficulty">{t('tours.planner.maxDifficulty')}</label>
|
||||
<CustomSelect
|
||||
id="tour-planner-difficulty"
|
||||
disabled={locked}
|
||||
ariaLabel={t('tours.planner.maxDifficulty')}
|
||||
value={planner.maxHikingDifficulty}
|
||||
onChange={nextValue => {
|
||||
if (!locked) onDifficultyChange(Number(nextValue) as 1 | 2 | 3 | 4 | 5 | 6)
|
||||
}}
|
||||
options={[1, 2, 3, 4, 5, 6].map(value => ({ value, label: t(`tours.planner.difficulty.t${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
{planner.maxHikingDifficulty === 3 && (
|
||||
<TourNotice tone="warning" role="status" icon={<AlertTriangle size={13} strokeWidth={2} />}>{t('tours.planner.difficulty.t3Warning')}</TourNotice>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The ordered waypoints, reorderable by drag or by the arrow controls on each row. */
|
||||
function WaypointList({ planner, canEdit, isSaving }: { planner: TourPlannerController; canEdit: boolean; isSaving: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const [draggedWaypointId, setDraggedWaypointId] = useState<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<{ id: string; placement: 'before' | 'after' } | null>(null);
|
||||
const lastIndex = planner.waypoints.length - 1
|
||||
return (
|
||||
<TourSection label={t('tours.planner.waypoints')}>
|
||||
{planner.waypoints.length === 0 ? (
|
||||
<div data-testid="tour-planner-empty-state" className="flex items-start gap-2 rounded-[12px] border border-dashed border-edge px-3 py-3 leading-snug text-content-muted" style={fs(12, 'body')}>
|
||||
<MapPin size={14} strokeWidth={2} className="mt-px flex-none text-content-faint" />
|
||||
<p className="m-0">{t('tours.planner.startingPoint')} {t('tours.planner.firstUseDetails')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ol data-touch-drag className="m-0 flex list-none flex-col gap-px p-0">
|
||||
{planner.waypoints.map((point, index) => {
|
||||
const selected = planner.selectedWaypointId === point.id;
|
||||
const isDropTarget = dropTarget?.id === point.id && draggedWaypointId !== point.id;
|
||||
return (
|
||||
<li
|
||||
key={point.id}
|
||||
data-testid="tour-planner-waypoint-row"
|
||||
onDragStart={(event) => {
|
||||
if (!canEdit || isSaving) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', point.id);
|
||||
}
|
||||
setDraggedWaypointId(point.id);
|
||||
setDropTarget(null);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (!canEdit || isSaving || !draggedWaypointId || draggedWaypointId === point.id) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const placement = event.clientY >= bounds.top + bounds.height / 2 ? 'after' : 'before';
|
||||
setDropTarget((current) =>
|
||||
current?.id === point.id && current.placement === placement
|
||||
? current
|
||||
: { id: point.id, placement }
|
||||
);
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
const draggedId = draggedWaypointId || event.dataTransfer.getData('text/plain');
|
||||
if (
|
||||
canEdit &&
|
||||
!isSaving &&
|
||||
draggedId &&
|
||||
draggedId !== point.id &&
|
||||
dropTarget?.id === point.id
|
||||
) {
|
||||
planner.reorderWaypoint(draggedId, point.id, dropTarget.placement);
|
||||
}
|
||||
setDraggedWaypointId(null);
|
||||
setDropTarget(null);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDraggedWaypointId(null);
|
||||
setDropTarget(null);
|
||||
}}
|
||||
className={`relative rounded-[12px] ${draggedWaypointId === point.id ? 'opacity-45' : ''} ${isDropTarget ? 'bg-surface-hover' : ''}`}
|
||||
>
|
||||
{isDropTarget && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute inset-x-2 z-10 h-0.5 rounded bg-accent ${dropTarget.placement === 'before' ? 'top-0' : 'bottom-0'}`}
|
||||
/>
|
||||
)}
|
||||
<div className="group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-1.5">
|
||||
<span
|
||||
draggable={canEdit && !isSaving}
|
||||
aria-hidden="true"
|
||||
data-testid="tour-planner-waypoint-drag-handle"
|
||||
className={`grid h-6 w-5 flex-none place-items-center text-content-faint ${canEdit && !isSaving ? 'cursor-grab active:cursor-grabbing' : 'opacity-35'}`}
|
||||
>
|
||||
<GripVertical size={14} strokeWidth={1.8} />
|
||||
</span>
|
||||
<span
|
||||
aria-label={t('tours.planner.waypointLabel', { n: index + 1 })}
|
||||
data-waypoint-number={index + 1}
|
||||
className={`grid h-7 w-7 flex-none place-items-center rounded-[9px] font-geist font-semibold tabular-nums shadow-sm ${selected ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => planner.setSelectedWaypointId(point.id)}
|
||||
className="flex min-w-0 flex-1 flex-col text-start outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)]"
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
>
|
||||
<span className="block font-semibold leading-tight text-content" style={fs(12.5, 'body')}>
|
||||
{roleLabel(point.role, t)}
|
||||
</span>
|
||||
<span
|
||||
className="mt-0.5 block truncate font-geist tabular-nums text-content-faint"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{point.lat.toFixed(5)}, {point.lng.toFixed(5)}
|
||||
</span>
|
||||
</button>
|
||||
{canEdit && (
|
||||
<span
|
||||
className={`flex flex-none items-center gap-0.5 transition-opacity ${selected ? '' : 'opacity-50 group-focus-within:opacity-100 group-hover:opacity-100'}`}
|
||||
>
|
||||
<WaypointAction
|
||||
label={t('tours.planner.moveUp')}
|
||||
disabled={isSaving || index === 0}
|
||||
onAct={() => planner.moveWaypoint(point.id, -1)}
|
||||
>
|
||||
<ArrowUp size={13} strokeWidth={2.2} />
|
||||
</WaypointAction>
|
||||
<WaypointAction
|
||||
label={t('tours.planner.moveDown')}
|
||||
disabled={isSaving || index === lastIndex}
|
||||
onAct={() => planner.moveWaypoint(point.id, 1)}
|
||||
>
|
||||
<ArrowDown size={13} strokeWidth={2.2} />
|
||||
</WaypointAction>
|
||||
<WaypointAction
|
||||
label={t('tours.planner.remove')}
|
||||
disabled={isSaving}
|
||||
danger
|
||||
onAct={() => planner.removeWaypoint(point.id)}
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
</WaypointAction>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</TourSection>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/** A quiet icon control on a waypoint row; a span, because the row itself is the button. */
|
||||
function WaypointAction({ label, disabled, danger = false, onAct, children }: { label: string; disabled: boolean; danger?: boolean; onAct: () => void; children: ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -333,13 +333,13 @@ describe('useTourPlanner', () => {
|
||||
expect(result.current.plannedTotalMinutes).toBe(90)
|
||||
|
||||
const waypointId = result.current.waypoints[1].id
|
||||
act(() => result.current.setWaypointPosition(waypointId, 48.2, 11.2))
|
||||
act(() => { result.current.setWaypointPosition(waypointId, 48.2, 11.2) })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
|
||||
expect(result.current.walkingDurationMinutes).toBe(90)
|
||||
expect(result.current.plannedTotalMinutes).toBe(120)
|
||||
|
||||
act(() => result.current.setPlannedDurationMinutes(110))
|
||||
act(() => result.current.setWaypointPosition(waypointId, 48.3, 11.3))
|
||||
act(() => { result.current.setWaypointPosition(waypointId, 48.3, 11.3) })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
|
||||
expect(result.current.walkingDurationMinutes).toBe(120)
|
||||
expect(result.current.plannedTotalMinutes).toBe(110)
|
||||
@@ -437,7 +437,9 @@ describe('useTourPlanner', () => {
|
||||
act(() => first.result.current.reorderWaypoint(ids[2], ids[0], 'before'));
|
||||
expect(first.result.current.waypoints.map((point) => point.id)).toEqual([ids[2], ids[0], ids[1]]);
|
||||
expect(first.result.current.waypoints.map((point) => point.role)).toEqual(['start', 'via', 'end']);
|
||||
const stored = JSON.parse(localStorage.getItem('tour-draft-78')!) as { waypoints: { id: string }[] };
|
||||
const storedRaw = localStorage.getItem('tour-draft-78');
|
||||
if (storedRaw === null) throw new Error('expected a stored tour draft');
|
||||
const stored = JSON.parse(storedRaw) as { waypoints: { id: string }[] };
|
||||
expect(stored.waypoints.map((point) => point.id)).toEqual([ids[2], ids[0], ids[1]]);
|
||||
|
||||
first.unmount();
|
||||
|
||||
@@ -64,22 +64,51 @@ function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]
|
||||
return tourWaypointKey(a) === tourWaypointKey(b)
|
||||
}
|
||||
|
||||
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6]
|
||||
|
||||
function optionalString(value: unknown): boolean {
|
||||
return value === undefined || typeof value === 'string'
|
||||
}
|
||||
|
||||
function optionalMinutes(value: number | null | undefined): boolean {
|
||||
return value === undefined || value === null
|
||||
|| (Number.isInteger(value) && value >= 0 && value <= MAX_PLANNED_TOUR_DURATION_MINUTES)
|
||||
}
|
||||
|
||||
function optionalPlaceId(value: number | null | undefined): boolean {
|
||||
return value == null || (Number.isInteger(value) && value > 0)
|
||||
}
|
||||
|
||||
function optionalDifficulty(value: number | null | undefined): boolean {
|
||||
return value == null || (Number.isInteger(value) && value >= 1 && value <= 6)
|
||||
}
|
||||
|
||||
function validDraftWaypoint(point: TourPlannerWaypoint): boolean {
|
||||
return !!point && typeof point.id === 'string'
|
||||
&& Number.isFinite(point.lat) && Math.abs(point.lat) <= 90
|
||||
&& Number.isFinite(point.lng) && Math.abs(point.lng) <= 180
|
||||
}
|
||||
|
||||
function trimmedOrNull(value: string): string | null {
|
||||
return value.trim() || null
|
||||
}
|
||||
|
||||
function parsedWebsiteOrNull(value: string) {
|
||||
return value.trim() ? tourWebsiteSchema.parse(value) : null
|
||||
}
|
||||
|
||||
function validDraft(value: unknown): value is DraftPayload {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const draft = value as Partial<DraftPayload>
|
||||
return (draft.version === 1 || draft.version === 2 || draft.version === 3 || draft.version === 4 || draft.version === 5 || draft.version === 6) && typeof draft.name === 'string'
|
||||
&& (draft.description === undefined || typeof draft.description === 'string')
|
||||
&& (draft.website === undefined || typeof draft.website === 'string')
|
||||
&& (draft.plannedDurationMinutes === undefined || draft.plannedDurationMinutes === null
|
||||
|| (Number.isInteger(draft.plannedDurationMinutes) && draft.plannedDurationMinutes >= 0 && draft.plannedDurationMinutes <= MAX_PLANNED_TOUR_DURATION_MINUTES))
|
||||
&& (draft.breakAdditionalMinutes === undefined || draft.breakAdditionalMinutes === null
|
||||
|| (Number.isInteger(draft.breakAdditionalMinutes) && draft.breakAdditionalMinutes >= 0 && draft.breakAdditionalMinutes <= MAX_PLANNED_TOUR_DURATION_MINUTES))
|
||||
&& (draft.editingPlaceId == null || (Number.isInteger(draft.editingPlaceId) && draft.editingPlaceId > 0))
|
||||
&& (draft.maxHikingDifficulty == null || (Number.isInteger(draft.maxHikingDifficulty) && draft.maxHikingDifficulty >= 1 && draft.maxHikingDifficulty <= 6))
|
||||
return draft.version !== undefined && DRAFT_VERSIONS.includes(draft.version) && typeof draft.name === 'string'
|
||||
&& optionalString(draft.description)
|
||||
&& optionalString(draft.website)
|
||||
&& optionalMinutes(draft.plannedDurationMinutes)
|
||||
&& optionalMinutes(draft.breakAdditionalMinutes)
|
||||
&& optionalPlaceId(draft.editingPlaceId)
|
||||
&& optionalDifficulty(draft.maxHikingDifficulty)
|
||||
&& Array.isArray(draft.waypoints)
|
||||
&& draft.waypoints.every(point => point && typeof point.id === 'string'
|
||||
&& Number.isFinite(point.lat) && Math.abs(point.lat) <= 90
|
||||
&& Number.isFinite(point.lng) && Math.abs(point.lng) <= 180)
|
||||
&& draft.waypoints.every(validDraftWaypoint)
|
||||
}
|
||||
|
||||
export interface UseTourPlannerOptions extends TourPermissionProps {
|
||||
@@ -673,8 +702,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const submittedPlaceId = editingPlaceId
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
website: website.trim() ? tourWebsiteSchema.parse(website) : null,
|
||||
description: trimmedOrNull(description),
|
||||
website: parsedWebsiteOrNull(website),
|
||||
tour_type: 'hike',
|
||||
route_geometry: enrichedGeometry.map(point => [point[0], point[1], point[2]]),
|
||||
waypoints: waypoints.map((point, sequence) => ({
|
||||
|
||||
@@ -622,10 +622,14 @@ describe('TripPlannerPage — shell', () => {
|
||||
expect(screen.getByTestId('map-view')).toBe(mapElement);
|
||||
expect(plannerActions.addWaypoint).not.toHaveBeenCalled();
|
||||
|
||||
act(() => props('map').onMapClick({ latlng: { lat: 35.02, lng: 135.77 } }));
|
||||
act(() => {
|
||||
props('map').onMapClick({ latlng: { lat: 35.02, lng: 135.77 } });
|
||||
});
|
||||
expect(plannerActions.addWaypoint).toHaveBeenCalledOnce();
|
||||
expect(plannerActions.addWaypoint).toHaveBeenCalledWith(35.02, 135.77);
|
||||
act(() => props('map').onPlannerWaypointMove('via-1', 35.03, 135.78));
|
||||
act(() => {
|
||||
props('map').onPlannerWaypointMove('via-1', 35.03, 135.78);
|
||||
});
|
||||
expect(plannerActions.setWaypointPosition).toHaveBeenCalledOnce();
|
||||
expect(plannerActions.setWaypointPosition).toHaveBeenCalledWith('via-1', 35.03, 135.78);
|
||||
expect(createAssignment).not.toHaveBeenCalled();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// FE-TP-LOCSEARCH-001 to FE-TP-LOCSEARCH-004
|
||||
//
|
||||
// The tour planner's map location search: a picked result only moves the camera,
|
||||
// and the planner's own focus takes over again once it bumps its focus key.
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useMapLocationSearch } from './useMapLocationSearch';
|
||||
|
||||
const PLANNER_POINTS: [number, number][] = [
|
||||
[48, 11],
|
||||
[48.1, 11.1],
|
||||
];
|
||||
const PICK = { name: 'Zugspitze', lat: 47.42, lng: 10.98 };
|
||||
|
||||
describe('useMapLocationSearch', () => {
|
||||
it('FE-TP-LOCSEARCH-001: without a pick the planner frames its own points', () => {
|
||||
const { result } = renderHook(() => useMapLocationSearch(3, PLANNER_POINTS));
|
||||
|
||||
expect(result.current.mapFocusPoints).toBe(PLANNER_POINTS);
|
||||
expect(result.current.mapFocusKey).toBe('tour:3');
|
||||
expect(result.current.locationSearch.value).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TP-LOCSEARCH-002: a pick frames the picked point under a fresh key each time', () => {
|
||||
const { result } = renderHook(() => useMapLocationSearch(3, PLANNER_POINTS));
|
||||
|
||||
act(() => result.current.locationSearch.onChange(PICK));
|
||||
expect(result.current.locationSearch.value).toEqual(PICK);
|
||||
expect(result.current.mapFocusPoints).toEqual([[47.42, 10.98]]);
|
||||
expect(result.current.mapFocusKey).toBe('tour:3:search:1');
|
||||
|
||||
act(() => result.current.locationSearch.onChange(PICK));
|
||||
expect(result.current.mapFocusKey).toBe('tour:3:search:2');
|
||||
});
|
||||
|
||||
it('FE-TP-LOCSEARCH-003: clearing the field keeps the camera where the pick put it', () => {
|
||||
const { result } = renderHook(() => useMapLocationSearch(3, PLANNER_POINTS));
|
||||
|
||||
act(() => result.current.locationSearch.onChange(PICK));
|
||||
act(() => result.current.locationSearch.onChange(null));
|
||||
|
||||
expect(result.current.locationSearch.value).toBeNull();
|
||||
expect(result.current.mapFocusKey).toBe('tour:3:search:1');
|
||||
});
|
||||
|
||||
it('FE-TP-LOCSEARCH-004: a new planner focus key wins over an older pick', () => {
|
||||
const { result, rerender } = renderHook(({ key }) => useMapLocationSearch(key, PLANNER_POINTS), {
|
||||
initialProps: { key: 3 },
|
||||
});
|
||||
|
||||
act(() => result.current.locationSearch.onChange(PICK));
|
||||
rerender({ key: 4 });
|
||||
|
||||
expect(result.current.mapFocusPoints).toBe(PLANNER_POINTS);
|
||||
expect(result.current.mapFocusKey).toBe('tour:4');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import type { LocationPoint } from '../../components/Planner/LocationSelect';
|
||||
|
||||
interface SearchCameraIntent {
|
||||
plannerFocusKey: number;
|
||||
sequence: number;
|
||||
point: [number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* The TOUR-PLANNER location search: a picked result only moves the camera.
|
||||
* The search focus wins until the planner bumps its own focus key.
|
||||
*/
|
||||
export function useMapLocationSearch(plannerFocusKey: number, focusPoints: [number, number][]) {
|
||||
const [locationSearchValue, setLocationSearchValue] = useState<LocationPoint | null>(null);
|
||||
const [searchCameraIntent, setSearchCameraIntent] = useState<SearchCameraIntent | null>(null);
|
||||
const searchCameraSequenceRef = useRef(0);
|
||||
const onLocationSearchChange = useCallback(
|
||||
(location: LocationPoint | null) => {
|
||||
setLocationSearchValue(location);
|
||||
if (!location) return;
|
||||
searchCameraSequenceRef.current += 1;
|
||||
setSearchCameraIntent({
|
||||
plannerFocusKey,
|
||||
sequence: searchCameraSequenceRef.current,
|
||||
point: [location.lat, location.lng],
|
||||
});
|
||||
},
|
||||
[plannerFocusKey]
|
||||
);
|
||||
|
||||
const searchFocusIsCurrent = searchCameraIntent?.plannerFocusKey === plannerFocusKey;
|
||||
const mapFocusPoints = searchFocusIsCurrent ? [searchCameraIntent.point] : focusPoints;
|
||||
const mapFocusKey = searchFocusIsCurrent
|
||||
? `tour:${plannerFocusKey}:search:${searchCameraIntent.sequence}`
|
||||
: `tour:${plannerFocusKey}`;
|
||||
|
||||
return {
|
||||
mapFocusPoints,
|
||||
mapFocusKey,
|
||||
locationSearch: { value: locationSearchValue, onChange: onLocationSearchChange },
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import type { LocationPoint } from '../../components/Planner/LocationSelect';
|
||||
import { usePoiExplore, type Bbox } from '../../components/Map/usePoiExplore'
|
||||
import { placeActions } from '../../components/Planner/usePlaceActions'
|
||||
import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner'
|
||||
import { usePlanMapViewport } from '../../hooks/usePlanMapViewport'
|
||||
import { useMapLocationSearch } from './useMapLocationSearch'
|
||||
import { useTripPlanner } from './useTripPlanner'
|
||||
|
||||
export function useTripPlannerPage() {
|
||||
@@ -40,27 +40,6 @@ export function useTripPlannerPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const [locationSearchValue, setLocationSearchValue] = useState<LocationPoint | null>(null);
|
||||
const [searchCameraIntent, setSearchCameraIntent] = useState<{
|
||||
plannerFocusKey: number;
|
||||
sequence: number;
|
||||
point: [number, number];
|
||||
} | null>(null);
|
||||
const searchCameraSequenceRef = useRef(0);
|
||||
const onLocationSearchChange = useCallback(
|
||||
(location: LocationPoint | null) => {
|
||||
setLocationSearchValue(location);
|
||||
if (!location) return;
|
||||
searchCameraSequenceRef.current += 1;
|
||||
setSearchCameraIntent({
|
||||
plannerFocusKey: tourPlanner.mapFocusKey,
|
||||
sequence: searchCameraSequenceRef.current,
|
||||
point: [location.lat, location.lng],
|
||||
});
|
||||
},
|
||||
[tourPlanner.mapFocusKey]
|
||||
);
|
||||
|
||||
const tourDetailOpenerRef = useRef<{ placeId: number; element: HTMLElement } | null>(null)
|
||||
const lastPlanMapViewportRef = useRef<Bbox | null>(null)
|
||||
const [plannerViewportSnapshot, setPlannerViewportSnapshot] = useState<Bbox | null>(lastPlanMapViewportRef.current)
|
||||
@@ -93,11 +72,7 @@ export function useTripPlannerPage() {
|
||||
return plannerFallbackFocusPoints
|
||||
}, [tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.waypoints, plannerFallbackFocusPoints])
|
||||
|
||||
const searchFocusIsCurrent = searchCameraIntent?.plannerFocusKey === tourPlanner.mapFocusKey;
|
||||
const mapFocusPoints = searchFocusIsCurrent ? [searchCameraIntent.point] : focusPoints;
|
||||
const mapFocusKey = searchFocusIsCurrent
|
||||
? `tour:${tourPlanner.mapFocusKey}:search:${searchCameraIntent.sequence}`
|
||||
: `tour:${tourPlanner.mapFocusKey}`;
|
||||
const { mapFocusPoints, mapFocusKey, locationSearch } = useMapLocationSearch(tourPlanner.mapFocusKey, focusPoints)
|
||||
|
||||
const route = useMemo<[number, number][][] | null>(() => {
|
||||
if (tourPlanner.mode.type === 'view-gpx') {
|
||||
@@ -140,6 +115,6 @@ export function useTripPlannerPage() {
|
||||
tourPlanner,
|
||||
tourDetails: { openerRef: tourDetailOpenerRef, onSelectTour },
|
||||
tourMap: { route, focusPoints: mapFocusPoints, focusKey: mapFocusKey, captureViewport },
|
||||
locationSearch: { value: locationSearchValue, onChange: onLocationSearchChange },
|
||||
locationSearch,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
[
|
||||
"src/db/repositories/DayAssignments.repository.ts",
|
||||
"src/db/repositories/Places.repository.ts",
|
||||
"src/db/repositories/Reservations.repository.ts",
|
||||
"src/db/repositories/Trips.repository.ts",
|
||||
"src/db/repositories/Users.repository.ts",
|
||||
@@ -22,7 +21,6 @@
|
||||
"src/nest/reservations/reservations.service.ts",
|
||||
"src/nest/trips/trips.service.ts",
|
||||
"src/nest/vacay/vacay.service.ts",
|
||||
"tests/unit/db/repositories/Places.repository.test.ts",
|
||||
"tests/unit/nest/atlas.service.test.ts",
|
||||
"tests/unit/nest/budget.service.db.test.ts",
|
||||
"tests/unit/nest/calendar.service.test.ts",
|
||||
|
||||
Reference in New Issue
Block a user