mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
980 lines
45 KiB
TypeScript
980 lines
45 KiB
TypeScript
import { useState, useEffect, useRef, useMemo, useCallback, useId, type ReactNode } from 'react'
|
|
import type { PlaceClientOrigin, RoadtripStopType, TourListItem } from '@trek/shared'
|
|
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
|
|
import { useAuthStore } from '../../store/authStore'
|
|
import { useAddonStore } from '../../store/addonStore'
|
|
import { useCanDo } from '../../store/permissionsStore'
|
|
import { useTripStore } from '../../store/tripStore'
|
|
import { useSettingsStore } from '../../store/settingsStore'
|
|
import CollectionPicker from '../Collections/CollectionPicker'
|
|
import PlaceDetailsColumn from './PlaceDetailsColumn'
|
|
import { PendingFiles, PlaceImageSection } from './PlaceFormMedia'
|
|
import { usePlaceImageField } from './usePlaceImageField'
|
|
import { useToast } from '../shared/Toast'
|
|
import { Tooltip } from '../shared/Tooltip'
|
|
import { Search, RotateCcw, LocateFixed, PencilLine } from 'lucide-react'
|
|
import { useTranslation } from '../../i18n'
|
|
import { PlaceContactFields } from './PlaceContactFields'
|
|
import { weekFromPeriods } from './placeHours'
|
|
import type { PlaceOpeningHours } from '@trek/shared'
|
|
import {
|
|
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload,
|
|
prefillForm, savedCategoryId, type PlaceFormData, type PlacePrefill,
|
|
} from './PlaceFormModal.helpers'
|
|
import { useNewCategory, usePlaceForm } from './usePlaceForm'
|
|
import { usePlaceSearch } from './usePlaceSearch'
|
|
import { guessCategoryId } from '@trek/shared'
|
|
import { offersGoogleRetry } from '../../utils/placeSource'
|
|
import { safeHexColor } from '../../utils/safeColor'
|
|
import { useLocationBias } from '../../hooks/useLocationBias'
|
|
import { BookingCostsSection } from './BookingCostsSection'
|
|
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
|
import type { Place, Category, Assignment, BudgetItem } from '../../types'
|
|
import { NumericInput } from '../shared/NumericInput'
|
|
import { formatDistance } from '../../utils/units'
|
|
import ServiceStopSection from '../Roadtrip/ServiceStopSection'
|
|
import { DEFAULT_SERVICE_KIND, serviceStopChoice, type ServiceStopMode } from '../Roadtrip/manualStop'
|
|
import { STOP_KIND_BY_KEY } from '../Roadtrip/stopKinds'
|
|
import { getCategoryIcon } from '../shared/categoryIcons'
|
|
import {
|
|
DialogShell, DialogHeader, DialogTile, DialogSection, DialogFooter, FooterSpacer, DialogButton, NEUTRAL_TINT, fs,
|
|
} from '../shared/DialogShell'
|
|
import { EditorField, GRID_2, INPUT, LABEL, PANEL, TEXTAREA } from '../shared/dialogParts'
|
|
import { SoftPill, tintOf } from './planParts'
|
|
import { TimeSection } from './PlaceFormTimeSection'
|
|
import { HeaderPills } from './PlaceFormHeaderPills'
|
|
import { PlaceRefreshPill } from './PlaceRefreshPill'
|
|
import { usePlaceRefresh } from './usePlaceRefresh'
|
|
|
|
// The submit payload mirrors the form, but lat/lng are parsed to numbers and
|
|
// category_id is normalised, plus any files chosen before the place existed.
|
|
export interface PlaceSubmitData extends Omit<PlaceFormData, 'lat' | 'lng' | 'category_id'> {
|
|
lat: number | null
|
|
lng: number | null
|
|
category_id: string | null
|
|
_pendingFiles?: File[]
|
|
/**
|
|
* Where a road-trip service stop belongs on the drive, worked out from the
|
|
* coordinates being saved. Travels the same way `_pendingFiles` does: the planner
|
|
* reads it, strips it, and assigns the new place at that position.
|
|
*/
|
|
_serviceStop?: { dayId: number; position: number; offRouteKm: number } | null
|
|
}
|
|
|
|
interface PlaceFormModalProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<{ id: number } | void> | void
|
|
place: Place | null
|
|
prefillCoords?: (PlacePrefill & { stop_type?: RoadtripStopType | null; duration_minutes?: number; category?: string }) | null
|
|
tripId: number
|
|
categories: Category[]
|
|
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
|
|
assignmentId: number | null
|
|
dayAssignments?: Assignment[]
|
|
/** Mobile keeps the untouched single-column form; desktop adds the saved-place
|
|
* picker column when the Collections addon is enabled. Sourced from the trip
|
|
* planner's useIsPhone(). */
|
|
isMobile?: boolean
|
|
/** Opens the Costs editor for this place's linked expense (#1298) — the same
|
|
* seam the booking and transport modals use. */
|
|
onOpenExpense?: (req: BookingExpenseRequest) => void
|
|
/**
|
|
* Turns this into the form a road trip's service stop is added on: the category
|
|
* control becomes the kind of stop, the costs section goes, and a row asks which leg
|
|
* of the drive it belongs on. Absent, nothing about the form changes.
|
|
*/
|
|
serviceStop?: ServiceStopMode | null
|
|
/** Road trip mode is on, where a visit's End is when the drive leaves it. */
|
|
roadtripActive?: boolean
|
|
/** TIME-02: the tour this place is, whose end the time fields work out from its start; absent for a plain place. */
|
|
tour?: TourListItem | null
|
|
}
|
|
|
|
|
|
/** The mark itself. Quiet on purpose: it answers a question, it does not advertise. */
|
|
function SourceBadge({ label }: { label: string | null }) {
|
|
if (!label) return null
|
|
return <SoftPill>{label}</SoftPill>
|
|
}
|
|
|
|
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
|
|
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
|
|
* render over the form fields. */
|
|
function usePlaceFormModal(props: PlaceFormModalProps) {
|
|
const {
|
|
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
|
|
onCategoryCreated, assignmentId, dayAssignments = [], isMobile = false,
|
|
onOpenExpense, serviceStop = null,
|
|
} = props
|
|
// Hidden while the addon is off, because the kinds only mean anything to the road trip
|
|
// rail: on an instance without it they would be six labels that change nothing.
|
|
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric'
|
|
const toast = useToast()
|
|
const { t, locale } = useTranslation()
|
|
const { placesEnrichEnabled } = useAuthStore()
|
|
const can = useCanDo()
|
|
const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h'
|
|
const tripObj = useTripStore((s) => s.trip)
|
|
const canUploadFiles = can('file_upload', tripObj)
|
|
const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections'))
|
|
const isBudgetEnabled = useAddonStore((s) => s.isEnabled('budget'))
|
|
const deleteBudgetItem = useTripStore((s) => s.deleteBudgetItem)
|
|
const {
|
|
form, setForm, autoFilledRef, pendingFiles, addFilesFromInput, removeFile, handlePaste, isSaving, setIsSaving,
|
|
duplicateWarning, detailsSelection, setDetailsSelection, openForm, handleChange: changeField, pickImage,
|
|
adoptDescription, warnIfDuplicate, pendingImage, stageUpload, stageAttached, removeImage,
|
|
} = usePlaceForm({ t, toast, canUploadFiles })
|
|
const image = usePlaceImageField({
|
|
place, imageUrl: form.image_url, pendingImage, canUploadFiles, stageUpload, stageAttached, removeImage,
|
|
})
|
|
// The index the picked place came from, as its row named it, shown as a pill
|
|
// in the head band. Null for a place typed by hand or taken from the saved list.
|
|
const [pickedSource, setPickedSource] = useState<string | null>(null)
|
|
// Whether the category was preselected from a search result rather than chosen, so
|
|
// the next pick may replace it and a hand choice is never overwritten (#2282).
|
|
const categoryGuessedRef = useRef(false)
|
|
const guessedCategory = (result: Record<string, unknown>): string => {
|
|
const saved = savedCategoryId(result, categories || []) // a collection's place keeps its own (#3074)
|
|
const id = saved ? null : guessCategoryId(result, categories || [])
|
|
categoryGuessedRef.current = id != null
|
|
return saved ?? (id != null ? String(id) : '')
|
|
}
|
|
// The hours the details column looked up, as a week the contact block can take over (#2472).
|
|
const [detailsHours, setDetailsHours] = useState<PlaceOpeningHours | null>(null)
|
|
/**
|
|
* The leg of the drive the traveller picked, or empty while the projection's own
|
|
* answer stands. Empty rather than seeded, because there is nothing to project onto
|
|
* until a place has been chosen and the answer has to follow the coordinates.
|
|
*/
|
|
const [serviceStopLeg, setServiceStopLeg] = useState('')
|
|
const fileRef = useRef(null)
|
|
// Set right before submit when the user clicked create/edit expense — the
|
|
// place has to exist before an expense can point at it (see ReservationModal).
|
|
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
|
|
const newCategory = useNewCategory({
|
|
variant: 'dialog',
|
|
create: category => onCategoryCreated?.(category),
|
|
onCreated: categoryId => setForm(prev => ({ ...prev, category_id: categoryId })),
|
|
t,
|
|
toast,
|
|
})
|
|
|
|
useEffect(() => {
|
|
categoryGuessedRef.current = false
|
|
let next: PlaceFormData
|
|
if (place) {
|
|
const assignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null
|
|
next = placeEditForm(place, assignment, {
|
|
// Shown in the contact block now (#2472), so an edit carries what is there.
|
|
phone: place.phone || '',
|
|
email: place.email || '',
|
|
opening_hours: place.opening_hours || '',
|
|
// Carried through every edit. Without it, opening a fuel stop to fix a typo
|
|
// submits an empty kind and turns it back into a numbered destination.
|
|
// duration_minutes deliberately stays out: how long a stay takes belongs to the
|
|
// rail's own dialog, and sending it from here would overwrite what was set there.
|
|
stop_type: place.stop_type ?? null,
|
|
})
|
|
} else if (prefillCoords) {
|
|
next = prefillForm(prefillCoords, {
|
|
category_id: guessedCategory(prefillCoords),
|
|
stop_type: prefillCoords.stop_type ?? null,
|
|
duration_minutes: prefillCoords.duration_minutes,
|
|
})
|
|
} else if (serviceStop) {
|
|
// A stop on a drive is a kind and a dwell before it is anything else, so the form
|
|
// opens on one rather than on nothing: a service stop left without a kind is a
|
|
// numbered destination that counts in every total, which is the very thing this
|
|
// path exists to avoid. Read out of the closure rather than watched, because the
|
|
// mode is fixed for the life of one opening and a rebuilt drive must not reset a
|
|
// half-filled form.
|
|
// What the corridor panel was looking for when the button was pressed, because
|
|
// that is the traveller's own answer to what they are adding; only a panel with
|
|
// nothing selected falls back to the constant. The dwell follows the kind, the
|
|
// same way picking one by hand moves it.
|
|
const kind = serviceStop.defaultKind ?? DEFAULT_SERVICE_KIND
|
|
next = {
|
|
...DEFAULT_FORM,
|
|
stop_type: kind,
|
|
duration_minutes: STOP_KIND_BY_KEY[kind].defaultMinutes,
|
|
}
|
|
} else {
|
|
next = DEFAULT_FORM
|
|
}
|
|
// The column follows whatever the dialog was opened with, not only a search
|
|
// pick: a POI tapped on the map and a right-click place arrive as
|
|
// prefillCoords, and editing an existing place arrives as `place`.
|
|
openForm(next, place, prefillCoords)
|
|
setServiceStopLeg('')
|
|
setPickedSource(null)
|
|
// A fresh dialog owns no intention either. The ref is armed by a click on the Costs
|
|
// section and spent by the save that follows it; a save that never happened leaves it
|
|
// armed, and the next opening would consume it for a place nobody linked an expense
|
|
// to. In service-stop mode that opening has no Costs section at all, so the editor
|
|
// would arrive out of nowhere, on a petrol stop, filed as an activity.
|
|
expenseIntentRef.current = null
|
|
// dayAssignments is a fresh array each render; read it at open-time only and
|
|
// re-run on identity changes (place/assignmentId/open), not on every render.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [place, prefillCoords, isOpen, assignmentId])
|
|
|
|
const places = useTripStore((s) => s.places)
|
|
// Where the trip is happening — the hint that tells the search which of a
|
|
// thousand identically named places is meant. Autocomplete wants a box, the
|
|
// search wants a point; both are derived from the same places.
|
|
const { box: locationBias } = useLocationBias()
|
|
|
|
/**
|
|
* The one point every pick flows through, so the detail column hangs here: a
|
|
* search row, a suggestion's place, or a place from the saved-place picker.
|
|
* `label` is the name the picked row's badge carried; a saved place names none.
|
|
*/
|
|
const applyPickedPlace = (result: Record<string, unknown>, label: string | null) => {
|
|
setForm(prev => {
|
|
const merged = mergeResult(prev, result, autoFilledRef.current)
|
|
if (prev.category_id && !categoryGuessedRef.current) return merged
|
|
return { ...merged, category_id: guessedCategory(result) }
|
|
})
|
|
setPickedSource(label)
|
|
// A new pick drops whatever hero image belonged to the previous place.
|
|
const lat = Number(result.lat)
|
|
const lng = Number(result.lng)
|
|
if (Number.isFinite(lat) && Number.isFinite(lng)) {
|
|
setDetailsSelection({
|
|
placeId: (result.google_place_id || result.amap_poi_id || result.osm_id || undefined) as string | undefined,
|
|
lat,
|
|
lng,
|
|
name: (result.name as string) || '',
|
|
// Hand the record along: the server needs the same OSM tags, and
|
|
// looking them up again costs an Overpass round trip it can skip.
|
|
details: result,
|
|
})
|
|
setForm(prev => ({ ...prev, image_url: undefined }))
|
|
}
|
|
}
|
|
|
|
const search = usePlaceSearch({
|
|
variant: 'dialog',
|
|
locationBias,
|
|
t,
|
|
toast,
|
|
// The planner keeps this dialog mounted while it is closed (the shell only
|
|
// stops drawing it), so the search block is reset when it closes, apart from
|
|
// the opening effect above: that one also reruns while the dialog is open, when
|
|
// a right-click's reverse lookup fills prefillCoords, and must not wipe a
|
|
// search the user has started since.
|
|
open: isOpen,
|
|
onPlace: applyPickedPlace,
|
|
onSuggestionName: name => setForm(prev => ({ ...prev, name })),
|
|
onMapLink: resolved => {
|
|
setForm(prev => ({
|
|
...prev,
|
|
name: resolved.name || prev.name,
|
|
address: resolved.address || prev.address,
|
|
lat: String(resolved.lat),
|
|
lng: String(resolved.lng),
|
|
google_ftid: resolved.google_ftid || prev.google_ftid,
|
|
}))
|
|
// The fields are the link's now, not the last picked result's.
|
|
setPickedSource(null)
|
|
},
|
|
})
|
|
const refresh = usePlaceRefresh({ place, form, language: search.language })
|
|
|
|
/**
|
|
* What a stop on a drive might be a second copy of, said while the form is being filled.
|
|
*
|
|
* By the map record and by where it stands, never by its name: two Arals on one
|
|
* motorway are two petrol stations, and a check that called them one refused the save
|
|
* the first time it was pressed. This one refuses nothing: it is a note beside the
|
|
* name, and the reader decides.
|
|
*/
|
|
const serviceStopDuplicate = useMemo(() => {
|
|
if (!serviceStop || place) return null
|
|
return findDuplicatePlace(form, places, { byName: false, byOsmId: true })?.name ?? null
|
|
}, [serviceStop, place, form, places])
|
|
|
|
const handleChange = (field: string, value: string, origin?: PlaceClientOrigin) => {
|
|
if (field === 'category_id') categoryGuessedRef.current = false
|
|
changeField(field, value, origin)
|
|
}
|
|
|
|
// What is around the pin the form holds (#976).
|
|
const pin = formPin(form)
|
|
const handleNearby = () => search.searchNearby(pin)
|
|
|
|
/**
|
|
* The kind of stop, and with it how long that kind usually takes.
|
|
*
|
|
* Picking a kind is also picking a dwell, until the user says otherwise: a charge is
|
|
* not a fuel stop. Only ever called for a kind that is not already on, so a dwell set
|
|
* by hand survives a second click on the same pill.
|
|
*/
|
|
const handleStopKind = useCallback((kind: RoadtripStopType) => {
|
|
setForm(prev => ({ ...prev, stop_type: kind, duration_minutes: STOP_KIND_BY_KEY[kind].defaultMinutes }))
|
|
}, [setForm])
|
|
|
|
const handleStopMinutes = useCallback((minutes: number) => {
|
|
setForm(prev => ({ ...prev, duration_minutes: minutes }))
|
|
}, [setForm])
|
|
|
|
const hasTimeError = !!place && endsBeforeStart(form.place_time, form.end_time)
|
|
|
|
const handleSubmit = async (e?: { preventDefault?: () => void }) => {
|
|
e?.preventDefault?.()
|
|
if (!form.name.trim()) {
|
|
// Nothing was saved, so an expense intent from a previous click is stale —
|
|
// otherwise the next plain Save would open a Costs editor out of nowhere.
|
|
expenseIntentRef.current = null
|
|
toast.error(t('places.nameRequired'))
|
|
return
|
|
}
|
|
// #1152: only for new places, and only on the first attempt — a second click
|
|
// (with the warning already showing) is the explicit "add anyway" confirmation.
|
|
//
|
|
// Never for a stop on a drive. Most of what that check catches is a repeated name,
|
|
// and on a motorway a repeated name is the normal case: the second Aral is four
|
|
// hundred kilometres from the first and is a different petrol station. The popup this
|
|
// path replaced compared the OSM object for exactly that reason and never gated the
|
|
// save on it, only noted it. So the note is shown beside the name while the form is
|
|
// being filled (see serviceStopDuplicate), which is the earlier word anyway, and the
|
|
// press that saves is the press that saves.
|
|
if (!place && !serviceStop && !duplicateWarning && warnIfDuplicate(places)) {
|
|
// Nothing was saved, so an expense intent from a previous click is stale, and
|
|
// the next plain Save would otherwise open a Costs editor out of nowhere.
|
|
expenseIntentRef.current = null
|
|
return
|
|
}
|
|
setIsSaving(true)
|
|
try {
|
|
const ctxAssignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null
|
|
const payload = placeFormPayload(form, pendingFiles, ctxAssignment, (lat, lng) => ({
|
|
// An explicit null is how a stop stops being a fuel stop; the service reads it
|
|
// that way rather than as "leave alone", which is what a missing key means.
|
|
stop_type: form.stop_type || null,
|
|
// Only on the way in, and only with a kind: it is the popup's suggestion for how
|
|
// long that kind of pause takes. On an edit it is left out entirely, because the
|
|
// stay belongs to the rail's dialog and sending it here would overwrite it.
|
|
...(!place && form.stop_type && form.duration_minutes
|
|
? { duration_minutes: form.duration_minutes }
|
|
: {}),
|
|
// Where on the drive it goes, worked out HERE and not when the dialog opened: a
|
|
// stop added by hand has no coordinates at all until a place has been chosen in
|
|
// it, so the answer has to follow what is actually being saved.
|
|
...(serviceStop
|
|
? { _serviceStop: serviceStopChoice(serviceStop, lat, lng, serviceStopLeg).placement }
|
|
: {}),
|
|
}), pendingImage)
|
|
const saved = await onSave(payload)
|
|
// Open the Costs editor for the saved place when the user asked to
|
|
// create/edit its linked expense — gated on an id, so a create that the
|
|
// server refused never opens an editor pointing at nothing (#1298).
|
|
const intent = expenseIntentRef.current
|
|
expenseIntentRef.current = null
|
|
const savedId = (saved && 'id' in saved ? saved.id : null) ?? place?.id ?? null
|
|
if (intent && onOpenExpense && savedId) {
|
|
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
|
|
else onOpenExpense({ prefill: { placeId: savedId, name: form.name.trim(), category: 'activities' } })
|
|
}
|
|
onClose()
|
|
} catch (err: unknown) {
|
|
// The save did not happen, so the intent behind it cannot be honoured: the place
|
|
// the expense would point at does not exist. Left armed it would ride along to
|
|
// whatever the next press saves.
|
|
expenseIntentRef.current = null
|
|
toast.error(err instanceof Error ? err.message : t('places.saveError'))
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; void handleSubmit() }
|
|
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; void handleSubmit() }
|
|
const handleRemoveExpense = async (item: BudgetItem) => {
|
|
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
|
}
|
|
|
|
return {
|
|
isOpen,
|
|
onClose,
|
|
place,
|
|
prefillCoords,
|
|
categories,
|
|
assignmentId,
|
|
dayAssignments,
|
|
isMobile,
|
|
collectionsEnabled,
|
|
form,
|
|
mapsSearch: search.query,
|
|
setMapsSearch: search.setQuery,
|
|
mapsResults: search.results,
|
|
isSearchingMaps: search.searching,
|
|
newCategoryName: newCategory.name,
|
|
setNewCategoryName: newCategory.setName,
|
|
showNewCategory: newCategory.open,
|
|
setShowNewCategory: newCategory.setOpen,
|
|
isSaving,
|
|
pendingFiles,
|
|
emptySearch: search.emptySearch, setEmptySearch: search.setEmptySearch,
|
|
detailsHours, setDetailsHours,
|
|
fileRef,
|
|
acSuggestions: search.suggestions,
|
|
setAcSuggestions: search.setSuggestions,
|
|
acSource: search.acSource,
|
|
searchSource: search.searchSource,
|
|
acHighlight: search.highlight,
|
|
setAcHighlight: search.setHighlight,
|
|
t,
|
|
language: search.language,
|
|
locale,
|
|
refresh,
|
|
timeFormat,
|
|
googleAnswers: search.googleAnswers,
|
|
placesEnrichEnabled,
|
|
canUploadFiles,
|
|
locationBias,
|
|
fetchSuggestions: search.fetchSuggestions,
|
|
sourceLabel: search.sourceLabel,
|
|
handleChange,
|
|
handleMapsSearch: search.runSearch,
|
|
handleNearby,
|
|
pin,
|
|
nearbyList: search.nearbyList,
|
|
distanceUnit,
|
|
handleSelectMapsResult: search.pickPlace,
|
|
handleSelectSuggestion: search.selectSuggestion,
|
|
handleSearchKeyDown: search.handleKeyDown,
|
|
handleCreateCategory: newCategory.submit,
|
|
handleFileAdd: addFilesFromInput,
|
|
handleRemoveFile: removeFile,
|
|
handlePaste,
|
|
pickImage,
|
|
image,
|
|
adoptDescription,
|
|
hasTimeError,
|
|
handleSubmit,
|
|
duplicateWarning,
|
|
pickedSource,
|
|
detailsSelection,
|
|
isBudgetEnabled,
|
|
handleCreateExpense,
|
|
handleEditExpense,
|
|
handleRemoveExpense,
|
|
serviceStop,
|
|
serviceStopDuplicate,
|
|
serviceStopLeg,
|
|
setServiceStopLeg,
|
|
handleStopKind,
|
|
handleStopMinutes,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detail column, form and saved places side by side, inside the scrolling body
|
|
* rather than as it: a row that scrolled itself would stretch the side columns
|
|
* to the visible height only, and they would end halfway down the form.
|
|
*/
|
|
const ROW = 'flex items-stretch gap-5'
|
|
const STACK = 'flex flex-col gap-5'
|
|
const FORM_COLUMN = 'flex min-w-0 flex-1 flex-col gap-4'
|
|
/** A row of the typed-ahead list or of the search results. */
|
|
const PICK_ROW = 'flex w-full items-center gap-2 rounded-[8px] px-2.5 py-2 text-start'
|
|
/** The small accent square beside a field that runs its action, sized by the row it sits in. */
|
|
const SIDE_BUTTON = 'grid w-[38px] flex-none place-items-center rounded-[10px] transition-colors disabled:cursor-default disabled:opacity-60'
|
|
/**
|
|
* ServiceStopSection draws its labels in the old form's look and takes no class
|
|
* for them; this gives them the eyebrow every other field here has.
|
|
*/
|
|
const SERVICE_STOP_LABELS = '[&_label]:mb-[5px] [&_label]:block [&_label]:font-geist [&_label]:text-[length:calc(9.5px*var(--fs-scale-caption,1))] [&_label]:font-bold [&_label]:uppercase [&_label]:tracking-[.08em] [&_label]:text-content-faint'
|
|
|
|
export default function PlaceFormModal(props: PlaceFormModalProps) {
|
|
const S = usePlaceFormModal(props)
|
|
const {
|
|
isOpen, onClose, place, prefillCoords, categories, isMobile, collectionsEnabled, form,
|
|
mapsSearch, setMapsSearch, mapsResults, isSearchingMaps, acSuggestions, setAcSuggestions, acSource,
|
|
searchSource, acHighlight, setAcHighlight, t, language, googleAnswers, placesEnrichEnabled,
|
|
canUploadFiles, fetchSuggestions, sourceLabel, handleChange, handleMapsSearch, handleSelectMapsResult,
|
|
handleNearby, pin, nearbyList, distanceUnit,
|
|
handleSelectSuggestion, handleSearchKeyDown, hasTimeError, handleSubmit, isSaving, duplicateWarning,
|
|
detailsSelection, serviceStop, serviceStopDuplicate,
|
|
} = S
|
|
const titleId = useId()
|
|
const fieldId = useId()
|
|
// Desktop + Collections addon → the saved-place picker on the right. Mobile
|
|
// always keeps the original single-column form untouched.
|
|
const twoColumn = !isMobile && collectionsEnabled
|
|
// The detail column sits on the left on desktop whenever enrichment is on; on
|
|
// mobile it stacks above the form. It stays mounted with the selection null
|
|
// rather than appearing on the first pick — otherwise the dialog would jump
|
|
// sideways mid-typing.
|
|
const showDetails = placesEnrichEnabled
|
|
const inRow = !isMobile && (showDetails || twoColumn)
|
|
// The form keeps about the same width whichever columns flank it: 552 px
|
|
// alone, 572 px beside one 320 px column, 552 px between two.
|
|
const width = inRow && showDetails && twoColumn ? 'xwide' : inRow ? 'wide' : 'detail'
|
|
|
|
// A stop on a drive is not an activity, and the title is the first thing that says
|
|
// which of the two this dialog is asking about. It is the band's eyebrow, and the
|
|
// place's own name is typed into the band under it, as on a booking.
|
|
const title = place ? t('places.editPlace') : serviceStop ? t('roadtrip.stop.addTitle') : t('places.addPlace')
|
|
// The tile wears what the place is: the kind of stop on a drive, otherwise
|
|
// the chosen category, otherwise a plain pin.
|
|
const kind = serviceStop && form.stop_type ? STOP_KIND_BY_KEY[form.stop_type] : undefined
|
|
const category = serviceStop ? undefined : (categories || []).find(c => String(c.id) === form.category_id)
|
|
const tone = kind?.color ?? (category ? safeHexColor(category.color, '') : '')
|
|
const TileIcon = kind?.Icon ?? getCategoryIcon(category?.icon)
|
|
|
|
// The line under the name. A stop on a drive that looks like one already on the
|
|
// trip says so here, while the form is being filled, and never as a condition of
|
|
// saving: the press that saves is the press that saves. Otherwise, until there is
|
|
// a name, that one is required, since the save refuses without it.
|
|
let headerSub: ReactNode
|
|
if (serviceStopDuplicate) {
|
|
headerSub = <span className="text-warning">{t('roadtrip.stop.duplicate', { name: serviceStopDuplicate })}</span>
|
|
} else if (!form.name.trim()) {
|
|
headerSub = `${t('places.formName')} *`
|
|
}
|
|
|
|
const header = (
|
|
<DialogHeader
|
|
tile={<DialogTile><TileIcon size={20} strokeWidth={1.9} style={{ color: tone || 'var(--text-muted)' }} /></DialogTile>}
|
|
tint={tone ? tintOf(tone) : NEUTRAL_TINT}
|
|
labelId={titleId}
|
|
onClose={onClose}
|
|
eyebrow={title}
|
|
// Not autoFocused itself: a new place starts at the search, and otherwise the
|
|
// dialog's own first-field focus lands here on a desktop (#1302), so a place
|
|
// brought in from the map or opened to edit is ready for its name.
|
|
titleInput={{
|
|
value: form.name,
|
|
onChange: value => handleChange('name', value),
|
|
label: `${t('places.formName')} *`,
|
|
placeholder: t('places.formNamePlaceholder'),
|
|
required: true,
|
|
}}
|
|
sub={headerSub}
|
|
subWraps={!!serviceStopDuplicate}
|
|
pills={(
|
|
<HeaderPills
|
|
kind={kind}
|
|
categories={categories}
|
|
categoryId={form.category_id}
|
|
onCategory={value => handleChange('category_id', value)}
|
|
newCategory={{
|
|
open: S.showNewCategory,
|
|
setOpen: S.setShowNewCategory,
|
|
name: S.newCategoryName,
|
|
setName: S.setNewCategoryName,
|
|
create: S.handleCreateCategory,
|
|
inputId: `${fieldId}-new-category`,
|
|
}}
|
|
pickedSource={S.pickedSource}
|
|
refresh={<PlaceRefreshPill {...S.refresh} onApply={handleChange} locale={S.locale} t={t} />}
|
|
busy={isSearchingMaps}
|
|
navPlace={form.lat && form.lng ? {
|
|
name: form.name, address: form.address, lat: Number(form.lat), lng: Number(form.lng),
|
|
google_place_id: form.google_place_id || null, google_ftid: form.google_ftid || null,
|
|
} : null}
|
|
t={t}
|
|
/>
|
|
)}
|
|
/>
|
|
)
|
|
|
|
const footer = (
|
|
<DialogFooter>
|
|
<FooterSpacer />
|
|
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
|
|
{/* The only way to save: Enter in a field never submits this form. */}
|
|
<DialogButton variant="primary" onClick={() => void handleSubmit()} disabled={isSaving || hasTimeError}>
|
|
{isSaving ? t('common.saving') : place ? t('common.update') : duplicateWarning ? t('places.addAnyway') : t('common.add')}
|
|
</DialogButton>
|
|
</DialogFooter>
|
|
)
|
|
|
|
return (
|
|
<DialogShell
|
|
open={isOpen}
|
|
onClose={onClose}
|
|
labelledBy={titleId}
|
|
width={width}
|
|
align="top"
|
|
// A stray click beside the editor asks before the typing is lost (#2253).
|
|
discardGuard={{ form, files: S.pendingFiles.length }}
|
|
onPaste={S.handlePaste}
|
|
header={header}
|
|
footer={footer}
|
|
>
|
|
<div className={inRow ? ROW : STACK}>
|
|
{showDetails && (
|
|
<PlaceDetailsColumn
|
|
selection={detailsSelection}
|
|
selectedImageUrl={form.image_url}
|
|
onPickImage={S.pickImage}
|
|
image={S.image}
|
|
onAdoptDescription={S.adoptDescription}
|
|
onHours={(hours) => S.setDetailsHours(weekFromPeriods(hours.periods))}
|
|
hasDescription={!!form.description.trim()}
|
|
language={language}
|
|
timeFormat={S.timeFormat}
|
|
locale={S.locale}
|
|
variant="dialog"
|
|
// Stacked above the form in a narrow window, it takes the form's width.
|
|
fluid={isMobile}
|
|
t={t}
|
|
/>
|
|
)}
|
|
<form onSubmit={handleSubmit} className={FORM_COLUMN}>
|
|
|
|
{/* Place search: typed-ahead suggestions, a full search, and a pasted map link. */}
|
|
<div className={PANEL}>
|
|
<EditorField label={t('common.search')} htmlFor={`${fieldId}-search`}>
|
|
<div className="relative">
|
|
<div className="flex items-stretch gap-2">
|
|
<input
|
|
id={`${fieldId}-search`}
|
|
type="text"
|
|
// A new place starts at the search; an edit or a place brought
|
|
// in from the map already has what the search would give.
|
|
autoFocus={!place && !prefillCoords}
|
|
value={mapsSearch}
|
|
onChange={e => { setMapsSearch(e.target.value); S.setEmptySearch(null) }}
|
|
onKeyDown={handleSearchKeyDown}
|
|
onBlur={() => setTimeout(() => setAcSuggestions([]), 150)}
|
|
onFocus={() => {
|
|
if (mapsSearch.trim().length >= 2 && acSuggestions.length === 0 && mapsResults.length === 0) {
|
|
fetchSuggestions(mapsSearch.trim())
|
|
}
|
|
}}
|
|
placeholder={t('places.mapsSearchPlaceholder')}
|
|
className={`${INPUT} flex-1`}
|
|
/>
|
|
<Tooltip label={t('common.search')}>
|
|
<button
|
|
type="button"
|
|
onClick={() => { setAcSuggestions([]); void handleMapsSearch() }}
|
|
disabled={isSearchingMaps}
|
|
aria-label={t('common.search')}
|
|
className={`${SIDE_BUTTON} bg-accent text-accent-text hover:opacity-90`}
|
|
style={fs(13, 'body')}
|
|
>
|
|
{isSearchingMaps ? '...' : <Search size={15} strokeWidth={2.2} aria-hidden="true" />}
|
|
</button>
|
|
</Tooltip>
|
|
{pin && (
|
|
<Tooltip label={t('places.nearby')}>
|
|
<button
|
|
type="button"
|
|
onClick={handleNearby}
|
|
disabled={isSearchingMaps}
|
|
aria-label={t('places.nearby')}
|
|
className={`${SIDE_BUTTON} border border-edge bg-surface-card text-content-secondary hover:bg-surface-hover`}
|
|
>
|
|
<LocateFixed size={15} strokeWidth={2.2} aria-hidden="true" />
|
|
</button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
|
|
{/* Autocomplete dropdown. Capped and scrolling, because plugin rows can
|
|
follow the core ones (#2221) and the list must stay inside the dialog;
|
|
the row the arrow keys land on is scrolled into view. */}
|
|
{acSuggestions.length > 0 && (
|
|
<div className="absolute inset-x-0 z-20 mt-1 max-h-96 overflow-y-auto rounded-[12px] border border-edge-faint bg-surface-card p-1 shadow-dropdown">
|
|
{acSuggestions.map((s, idx) => (
|
|
<button
|
|
key={s.placeId}
|
|
ref={idx === acHighlight ? (el) => el?.scrollIntoView?.({ block: 'nearest' }) : undefined}
|
|
type="button"
|
|
onMouseDown={() => handleSelectSuggestion(s)}
|
|
onMouseEnter={() => setAcHighlight(idx)}
|
|
className={`${PICK_ROW} ${idx === acHighlight ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'}`}
|
|
>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate font-medium text-content" style={fs(13, 'body')}>{s.mainText}</span>
|
|
{s.secondaryText && (
|
|
<span className="block truncate text-content-muted" style={fs(11.5)}>{s.secondaryText}</span>
|
|
)}
|
|
</span>
|
|
<SourceBadge label={sourceLabel(s, acSource)} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</EditorField>
|
|
|
|
{/* Nothing found (#2472): say so, and offer the hand-made way in, taking
|
|
the query as the name when there is none yet. */}
|
|
{S.emptySearch && mapsResults.length === 0 && !isSearchingMaps && (
|
|
<div className="flex items-center gap-3 rounded-[12px] border border-dashed border-edge bg-surface-card px-3 py-2.5">
|
|
<span className="grid h-8 w-8 flex-none place-items-center rounded-full bg-surface-tertiary text-content-muted">
|
|
<PencilLine size={15} strokeWidth={2} />
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate font-semibold text-content" style={fs(12.5, 'body')}>{t('places.searchNothing', { query: S.emptySearch })}</span>
|
|
<span className="block text-content-muted" style={fs(11.5)}>{t('places.searchNothingHint')}</span>
|
|
</span>
|
|
<button type="button"
|
|
onClick={() => {
|
|
if (!form.name.trim()) handleChange('name', S.emptySearch!)
|
|
S.setEmptySearch(null)
|
|
document.getElementById(`${fieldId}-contact`)?.scrollIntoView?.({ behavior: 'smooth', block: 'center' })
|
|
}}
|
|
className="flex-none rounded-full bg-accent px-3 py-1.5 font-semibold text-accent-text hover:opacity-90"
|
|
style={fs(12, 'body')}>
|
|
{t('places.addByHand')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Search results (populated after full search) */}
|
|
{mapsResults.length > 0 && (
|
|
<div className="max-h-40 overflow-y-auto rounded-[10px] border border-edge-faint bg-surface-card p-1">
|
|
{mapsResults.map((result, idx) => (
|
|
<button
|
|
key={idx}
|
|
type="button"
|
|
onClick={() => handleSelectMapsResult(result, { mode: 'search', rank: idx, count: mapsResults.length })}
|
|
className={`${PICK_ROW} hover:bg-surface-hover`}
|
|
>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate font-medium text-content" style={fs(13, 'body')}>{result.name}</span>
|
|
{result.address && <span className="block truncate text-content-muted" style={fs(11.5)}>{result.address}</span>}
|
|
</span>
|
|
{nearbyList && typeof result.distance_m === 'number' && (
|
|
<SoftPill>{formatDistance(result.distance_m / 1000, distanceUnit)}</SoftPill>
|
|
)}
|
|
<SourceBadge label={sourceLabel(result, searchSource)} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
{/* The index answers first and Google only when it finds nothing, so a
|
|
list with the wrong place on it never reaches Google by itself. One
|
|
quiet line under the list sends the same query there, on an instance
|
|
where Google holds the key slot and for a list Google did not
|
|
already produce. */}
|
|
{mapsResults.length > 0 && !nearbyList && offersGoogleRetry(searchSource, googleAnswers) && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleMapsSearch('google')}
|
|
disabled={isSearchingMaps}
|
|
className="inline-flex items-center gap-1 self-start text-content-faint transition-colors hover:text-content disabled:opacity-50"
|
|
style={fs(12)}
|
|
>
|
|
<RotateCcw size={11} strokeWidth={2} aria-hidden="true" />
|
|
{t('places.searchGoogleInstead')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Where it is: what a pick, a pasted link or the map filled in, or typed by hand. */}
|
|
<div className={PANEL}>
|
|
<EditorField label={t('places.formAddress')} htmlFor={`${fieldId}-address`}>
|
|
<input
|
|
id={`${fieldId}-address`}
|
|
type="text"
|
|
value={form.address}
|
|
onChange={e => handleChange('address', e.target.value)}
|
|
placeholder={t('places.formAddressPlaceholder')}
|
|
className={INPUT}
|
|
/>
|
|
</EditorField>
|
|
<div role="group" aria-label={t('collections.coordinates')} className={GRID_2}>
|
|
<EditorField label={t('places.formLatLabel')} htmlFor={`${fieldId}-lat`}>
|
|
<NumericInput
|
|
id={`${fieldId}-lat`}
|
|
mode="signed"
|
|
value={form.lat}
|
|
onValueChange={v => handleChange('lat', v)}
|
|
onPaste={e => {
|
|
// "48.85, 2.35" pasted into the latitude fills both halves.
|
|
const pair = parseCoordinatePair(e.clipboardData.getData('text'))
|
|
if (pair) {
|
|
e.preventDefault()
|
|
handleChange('lat', pair[0])
|
|
handleChange('lng', pair[1])
|
|
}
|
|
}}
|
|
placeholder={t('places.formLat')}
|
|
className={INPUT}
|
|
/>
|
|
</EditorField>
|
|
<EditorField label={t('places.formLngLabel')} htmlFor={`${fieldId}-lng`}>
|
|
<NumericInput
|
|
id={`${fieldId}-lng`}
|
|
mode="signed"
|
|
value={form.lng}
|
|
onValueChange={v => handleChange('lng', v)}
|
|
placeholder={t('places.formLng')}
|
|
className={INPUT}
|
|
/>
|
|
</EditorField>
|
|
</div>
|
|
</div>
|
|
|
|
{/* For a stop on a drive: the kind of stop, how long, and where it belongs.
|
|
The category it replaces is gone from the head band too: refuelling is
|
|
not a taste, it is a fact about the place, so it lives in
|
|
`places.stop_type` and not in the trip's own editable category list. */}
|
|
{serviceStop && (
|
|
<div className={`${PANEL} ${SERVICE_STOP_LABELS}`}>
|
|
<ServiceStopSection
|
|
mode={serviceStop}
|
|
stopType={form.stop_type ?? null}
|
|
onStopType={S.handleStopKind}
|
|
minutes={form.duration_minutes ?? 0}
|
|
onMinutes={S.handleStopMinutes}
|
|
leg={S.serviceStopLeg}
|
|
onLeg={S.setServiceStopLeg}
|
|
lat={form.lat ? Number.parseFloat(form.lat) : null}
|
|
lng={form.lng ? Number.parseFloat(form.lng) : null}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
|
|
{/* The day in context: its times and its own note. Both live on the
|
|
day-assignment, not on the pool place (#2163), so they are only shown
|
|
when a single assignment is in context (itinerary edit, or a
|
|
single-assignment pool edit). Hidden when creating, and for unassigned
|
|
or multi-day pool edits where a single time is ambiguous and would not
|
|
persist. */}
|
|
{!!(place && S.assignmentId) && (
|
|
<div className={PANEL}>
|
|
<TimeSection
|
|
form={form}
|
|
handleChange={handleChange}
|
|
assignmentId={S.assignmentId}
|
|
dayAssignments={S.dayAssignments}
|
|
hasTimeError={hasTimeError}
|
|
endIsLeave={!!props.roadtripActive}
|
|
tour={props.tour}
|
|
t={t}
|
|
/>
|
|
<EditorField label={t('places.assignmentNotes')} htmlFor={`${fieldId}-day-notes`}>
|
|
<textarea
|
|
id={`${fieldId}-day-notes`}
|
|
value={form.assignment_notes ?? ''}
|
|
onChange={e => handleChange('assignment_notes', e.target.value)}
|
|
rows={2}
|
|
placeholder={t('places.assignmentNotesPlaceholder')}
|
|
className={`${TEXTAREA} resize-y`}
|
|
/>
|
|
</EditorField>
|
|
</div>
|
|
)}
|
|
|
|
<MarkdownField
|
|
id={`${fieldId}-description`}
|
|
label={t('places.formDescription')}
|
|
value={form.description}
|
|
onChange={v => handleChange('description', v)}
|
|
placeholder={t('places.formDescriptionPlaceholder')}
|
|
/>
|
|
|
|
{/* Notes — Markdown, same as the description, and rendered as such in the
|
|
inspector. The bar is how anyone finds that out. */}
|
|
<MarkdownField
|
|
id={`${fieldId}-notes`}
|
|
label={t('places.formNotes')}
|
|
value={form.notes}
|
|
onChange={v => handleChange('notes', v)}
|
|
placeholder={t('places.formNotesPlaceholder')}
|
|
maxLength={2000}
|
|
/>
|
|
|
|
{!showDetails && <PlaceImageSection image={S.image} onPickUrl={S.pickImage} t={t} />}
|
|
{/* The link and the files side by side, as on a booking. */}
|
|
<div className={canUploadFiles ? GRID_2 : ''}>
|
|
<EditorField label={t('places.formWebsite')} htmlFor={`${fieldId}-website`}>
|
|
<input
|
|
id={`${fieldId}-website`}
|
|
type="url"
|
|
value={form.website}
|
|
onChange={e => handleChange('website', e.target.value)}
|
|
placeholder="https://..."
|
|
className={INPUT}
|
|
/>
|
|
</EditorField>
|
|
{canUploadFiles && (
|
|
<PendingFiles
|
|
files={S.pendingFiles}
|
|
fileRef={S.fileRef}
|
|
onAdd={S.handleFileAdd}
|
|
onRemove={S.handleRemoveFile}
|
|
t={t}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Phone, e-mail and the place's own hours, for what the search did not
|
|
know or a place nobody has listed (#2472). */}
|
|
<PlaceContactFields
|
|
id={`${fieldId}-contact`}
|
|
phone={form.phone ?? ''}
|
|
email={form.email ?? ''}
|
|
openingHours={form.opening_hours ?? ''}
|
|
suggestedHours={S.detailsHours}
|
|
onChange={(field, value, origin) => handleChange(field, value, origin)}
|
|
/>
|
|
|
|
{/* Costs — create / view the expense linked to this place (#1298).
|
|
Same block, same flow as a booking: save first, then the editor.
|
|
|
|
Never for a stop on a drive: a petrol stop is not an activity with a budget
|
|
line, and the fuel it buys is an expense of the trip rather than of a place. */}
|
|
{S.isBudgetEnabled && !serviceStop && (
|
|
<BookingCostsSection
|
|
placeId={place?.id ?? null}
|
|
reservationId={null}
|
|
hintKey="places.createExpenseHint"
|
|
onCreate={S.handleCreateExpense}
|
|
onEdit={S.handleEditExpense}
|
|
onRemove={S.handleRemoveExpense}
|
|
labelClassName={LABEL}
|
|
customTooltips
|
|
whiteButtons
|
|
/>
|
|
)}
|
|
</form>
|
|
{twoColumn && (
|
|
<CollectionPicker bias={S.locationBias} onSelect={handleSelectMapsResult} t={t} />
|
|
)}
|
|
</div>
|
|
</DialogShell>
|
|
)
|
|
}
|
|
|
|
/** A Markdown field: its label, the formatting bar on the same line, the text under both. */
|
|
function MarkdownField({ id, label, value, onChange, placeholder, maxLength }: {
|
|
id: string
|
|
label: string
|
|
value: string
|
|
onChange: (value: string) => void
|
|
placeholder: string
|
|
maxLength?: number
|
|
}) {
|
|
const ref = useRef<HTMLTextAreaElement | null>(null)
|
|
return (
|
|
<DialogSection
|
|
className="min-w-0"
|
|
label={<label htmlFor={id}>{label}</label>}
|
|
action={<NoteFormatToolbar textareaRef={ref} onChange={onChange} compact customTooltips />}
|
|
>
|
|
<textarea
|
|
id={id}
|
|
ref={ref}
|
|
value={value}
|
|
onChange={e => onChange(e.target.value)}
|
|
rows={3}
|
|
maxLength={maxLength}
|
|
placeholder={placeholder}
|
|
className={`${TEXTAREA} resize-y`}
|
|
/>
|
|
</DialogSection>
|
|
)
|
|
}
|