import { useState, useEffect, useRef } from 'react' import Modal from '../shared/Modal' import { Calendar, Camera, Search, X, UserPlus, Bell } from 'lucide-react' import { tripsApi, authApi } from '../../api/client' import CustomSelect from '../shared/CustomSelect' import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' import { useCanDo } from '../../store/permissionsStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { CustomDatePicker } from '../shared/CustomDateTimePicker' import { normalizeImageFile } from '../../utils/convertHeic' import { getApiErrorMessage, type Trip } from '../../types' import { MAX_TRIP_DAYS, tripSpanDays, type TripCreateRequest } from '@trek/shared' import { NumericInput } from '../shared/NumericInput' import { currenciesWith, SYMBOLS } from '../Budget/BudgetPanel.constants' import TripDateReview, { dateReviewIsWide } from './TripDateReview' import { useTripRangeGuard, type RangeCheck } from '../../hooks/useTripRangeGuard' import type { ShiftMode } from '../../utils/dayImpactLines' type DateShiftMode = ShiftMode type TripPayload = TripCreateRequest & { date_shift_mode?: DateShiftMode } /** * A save held back for a look first: the start of a dated trip moved and the * bookings need a direction (#1288), or the new range removes days with * something on them. The payload waits here until the traveller confirms. */ interface PendingReview { payload: TripPayload askShift: boolean removal: RangeCheck } interface TripFormModalProps { isOpen: boolean onClose: () => void // Create returns the new trip (so we can attach members / upload the cover); // update resolves without a payload. onSave: (data: TripCreateRequest & { date_shift_mode?: DateShiftMode }) => Promise<{ trip?: Trip } | void> | void trip: Trip | null onCoverUpdate?: (tripId: number, coverUrl: string | null) => void } interface CoverSearchPhoto { id: string url: string thumb: string description?: string | null photographer?: string | null link?: string | null } export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }: TripFormModalProps) { const isEditing = !!trip const fileRef = useRef(null) const coverSearchSeq = useRef(0) // The staged cover lives on as an object URL until it is replaced or the modal goes. const previewUrlRef = useRef(null) const toast = useToast() const { t } = useTranslation() const currentUser = useAuthStore(s => s.user) const defaultCurrency = useSettingsStore(s => s.settings.default_currency) || 'EUR' const tripRemindersEnabled = useAuthStore(s => s.tripRemindersEnabled) const setTripRemindersEnabled = useAuthStore(s => s.setTripRemindersEnabled) const can = useCanDo() const canUploadCover = !isEditing || can('trip_cover_upload', trip) const canEditTrip = !isEditing || can('trip_edit', trip) const [formData, setFormData] = useState({ title: '', description: '', start_date: '', end_date: '', currency: 'EUR', reminder_days: 0 as number, day_count: 7 as number | '', }) const [customReminder, setCustomReminder] = useState(false) const [error, setError] = useState('') const [isLoading, setIsLoading] = useState(false) const [coverPreview, setCoverPreview] = useState(null) const [pendingCoverFile, setPendingCoverFile] = useState(null) const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState(null) const [uploadingCover, setUploadingCover] = useState(false) const [coverSearchQuery, setCoverSearchQuery] = useState('') const [coverSearchResults, setCoverSearchResults] = useState([]) const [coverSearchError, setCoverSearchError] = useState('') const [searchingCover, setSearchingCover] = useState(false) // Drives the drop zone's hover look. Used to be four handlers writing // element.style directly, which is how the indigo dragover colour survived // the move to a configurable accent. const [coverDragActive, setCoverDragActive] = useState(false) const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([]) const [selectedMembers, setSelectedMembers] = useState([]) const [existingMembers, setExistingMembers] = useState<{ id: number; username: string }[]>([]) const [memberSelectValue, setMemberSelectValue] = useState('') const [pendingReview, setPendingReview] = useState(null) const [dateShiftMode, setDateShiftMode] = useState('keep_bookings') const rangeGuard = useTripRangeGuard() const busy = isLoading || rangeGuard.checking useEffect(() => { if (trip) { const rd = trip.reminder_days ?? 3 setFormData({ title: trip.title || '', description: trip.description || '', start_date: trip.start_date || '', end_date: trip.end_date || '', currency: trip.currency || 'EUR', reminder_days: rd, day_count: trip.day_count || 7, }) setCustomReminder(![0, 1, 3, 9].includes(rd)) setCoverPreview(trip.cover_image || null) setCoverSearchQuery('') } else { setFormData({ title: '', description: '', start_date: '', end_date: '', currency: defaultCurrency, reminder_days: tripRemindersEnabled ? 3 : 0, day_count: 7 }) setCustomReminder(false) setCoverPreview(null) setCoverSearchQuery('') } if (previewUrlRef.current) { URL.revokeObjectURL(previewUrlRef.current) previewUrlRef.current = null } setPendingCoverFile(null) setPendingUnsplashUrl(null) setCoverSearchResults([]) setCoverSearchError('') setSelectedMembers([]) setPendingReview(null) setDateShiftMode('keep_bookings') setError('') setExistingMembers([]) // The planner keeps this modal mounted while it is closed, so nothing may be // fetched until it is actually open. if (isOpen) { authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => { if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled) }).catch(() => {}) authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {}) if (trip) { tripsApi.getMembers(trip.id).then(d => setExistingMembers(d.members || [])).catch(() => {}) } } }, [trip, isOpen]) useEffect(() => { if (!trip && isOpen) { setFormData(prev => ({ ...prev, reminder_days: tripRemindersEnabled ? 3 : 0 })) } }, [tripRemindersEnabled]) // A staged cover that never got uploaded would otherwise pin the full image for // as long as the tab lives. useEffect(() => () => { if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current) }, []) const handleSubmit = async (e) => { e.preventDefault() setError('') if (!formData.title.trim()) { setError(t('dashboard.titleRequired')); return } if (formData.start_date && formData.end_date) { const span = tripSpanDays(formData.start_date, formData.end_date) if (span < 1) { setError(t('dashboard.endDateError')); return } // Only a range being set is held to the limit, as on the server: a trip that // already carries a longer one can still be renamed. const datesTouched = !trip || formData.start_date !== (trip.start_date || '') || formData.end_date !== (trip.end_date || '') if (datesTouched && span > MAX_TRIP_DAYS) { setError(t('dashboard.tripTooLong', { days: MAX_TRIP_DAYS })); return } } if (!formData.start_date && !formData.end_date) { const dc = Number(formData.day_count) if (formData.day_count === '' || !Number.isInteger(dc) || dc < 1 || dc > MAX_TRIP_DAYS) { setError(t('dashboard.dayCountRequired')); return } } // The day count goes along only when it says something new. The trip's own // count goes stale in the planner (adding a day does not reload the trip), // and sending the stale number trims the day just added off a trip that was // only renamed. On a trip left without dates the server keeps its days anyway. const dayCount = Number(formData.day_count) const sendDayCount = !formData.start_date && !formData.end_date && (!isEditing || dayCount !== trip?.day_count) const payload: TripPayload = { title: formData.title.trim(), description: formData.description.trim() || null, start_date: formData.start_date || null, end_date: formData.end_date || null, currency: formData.currency, reminder_days: formData.reminder_days, ...(sendDayCount ? { day_count: dayCount } : {}), } // Moving the start of a dated trip shifts the whole day grid, so let the user // choose how bookings follow before anything is saved (#1288). End-date-only // changes don't shift days and dateless transitions have nothing to shift. const askShift = !!(isEditing && trip?.start_date && trip?.end_date && payload.start_date && payload.end_date && payload.start_date !== trip.start_date) // And say which days the new range removes, with what is on them, before // they are gone: the server takes the last days by position. const removal = isEditing && trip ? await rangeGuard.check(trip, payload) : null if (askShift || removal) { setDateShiftMode('keep_bookings') setPendingReview({ payload, askShift, removal }) return } await performSave(payload) } const performSave = async (payload: TripPayload) => { setIsLoading(true) try { const result = await onSave(payload) const createdTrip = result ? result.trip : undefined // Add selected members for newly created trips if (selectedMembers.length > 0 && createdTrip?.id) { let memberAddFailed = false for (const userId of selectedMembers) { const user = allUsers.find(u => u.id === userId) if (user) { try { await tripsApi.addMember(createdTrip.id, user.username) } catch { memberAddFailed = true } } } if (memberAddFailed) toast.error(t('trips.memberAddError')) } // Upload pending cover for newly created trips if (pendingCoverFile && createdTrip?.id) { try { const fd = new FormData() fd.append('cover', pendingCoverFile) const data = await tripsApi.uploadCover(createdTrip.id, fd) onCoverUpdate?.(createdTrip.id, data.cover_image) } catch { // Cover upload failed but trip was created — surface it without blocking the create toast.error(t('dashboard.coverUploadError')) } } else if (pendingUnsplashUrl && createdTrip?.id) { try { await tripsApi.update(createdTrip.id, { cover_image: pendingUnsplashUrl }) onCoverUpdate?.(createdTrip.id, pendingUnsplashUrl) } catch { toast.error(t('dashboard.coverSaveError')) } } onClose() } catch (err: unknown) { setError(err instanceof Error ? err.message : t('places.saveError')) } finally { setIsLoading(false) } } const handleCoverSelect = async (file: File | null | undefined) => { if (!file) return // HEIC/HEIF from iOS can't be rendered or stored as-is — convert to JPEG first const normalized = await normalizeImageFile(file) setPendingUnsplashUrl(null) if (isEditing && trip?.id) { // Existing trip: upload immediately uploadCoverNow(normalized) } else { // New trip: stage for upload after creation setPendingCoverFile(normalized) if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current) previewUrlRef.current = URL.createObjectURL(normalized) setCoverPreview(previewUrlRef.current) } } const handleCoverChange = (e: React.ChangeEvent) => { handleCoverSelect(e.target.files?.[0]) e.target.value = '' } const uploadCoverNow = async (file: File) => { setUploadingCover(true) try { const fd = new FormData() fd.append('cover', file) const data = await tripsApi.uploadCover(trip.id, fd) setCoverPreview(data.cover_image) onCoverUpdate?.(trip.id, data.cover_image) toast.success(t('dashboard.coverSaved')) } catch { toast.error(t('dashboard.coverUploadError')) } finally { setUploadingCover(false) } } const handleCoverSearch = async () => { const query = coverSearchQuery.trim() || formData.title.trim() if (!query) { setCoverSearchError(t('dashboard.unsplashQueryRequired')) return } // Guard against out-of-order responses: only the latest search applies its // results, so a slow earlier query can't overwrite a newer one. #1277 review const seq = ++coverSearchSeq.current setSearchingCover(true) setCoverSearchError('') try { const data = await tripsApi.searchCoverImages(query) if (seq !== coverSearchSeq.current) return const photos = data.photos || [] setCoverSearchResults(photos) if (photos.length === 0) setCoverSearchError(t('dashboard.unsplashNoResults')) } catch (err: unknown) { if (seq !== coverSearchSeq.current) return setCoverSearchError(getApiErrorMessage(err, t('dashboard.coverSearchError'))) } finally { if (seq === coverSearchSeq.current) setSearchingCover(false) } } const handleUnsplashSelect = async (photo: CoverSearchPhoto) => { if (!photo.url) return setPendingCoverFile(null) if (isEditing && trip?.id) { setUploadingCover(true) try { await tripsApi.update(trip.id, { cover_image: photo.url }) setCoverPreview(photo.url) onCoverUpdate?.(trip.id, photo.url) toast.success(t('dashboard.coverSaved')) } catch (err: unknown) { toast.error(getApiErrorMessage(err, t('dashboard.coverSaveError'))) } finally { setUploadingCover(false) } } else { setPendingUnsplashUrl(photo.url) setCoverPreview(photo.url) } } const handleRemoveCover = async () => { if (pendingCoverFile || pendingUnsplashUrl) { setPendingCoverFile(null) setPendingUnsplashUrl(null) setCoverPreview(null) return } // Nothing pending left, so the preview is a saved trip's stored cover. try { await tripsApi.update(trip.id, { cover_image: null }) setCoverPreview(null) onCoverUpdate?.(trip.id, null) } catch { toast.error(t('dashboard.coverRemoveError')) } } // Paste support for cover image const handlePaste = (e: React.ClipboardEvent) => { if (!canUploadCover) return const items = e.clipboardData?.items if (!items) return for (const item of Array.from(items)) { if (item.type.startsWith('image/')) { e.preventDefault() const file = item.getAsFile() if (file) handleCoverSelect(file) return } } } const update = (field, value) => setFormData(prev => { const next = { ...prev, [field]: value } if (field === 'start_date' && value) { if (!prev.end_date || prev.end_date < value) { next.end_date = value } else if (prev.start_date) { const oldStart = new Date(prev.start_date + 'T00:00:00Z') const oldEnd = new Date(prev.end_date + 'T00:00:00Z') const duration = Math.round((oldEnd.getTime() - oldStart.getTime()) / 86400000) const newEnd = new Date(value + 'T00:00:00Z') newEnd.setDate(newEnd.getDate() + duration) next.end_date = newEnd.toISOString().split('T')[0] } } return next }) /* The dashboard's shapes, in the app's own tokens. `.trek-dash` deliberately scopes its palette to that page, so none of its variables are reachable from here — what carries over is the geometry: generous radii, grouped panels instead of a single stack of labelled fields, and overline captions rather than sentence-case labels. */ const inputCls = "w-full px-3.5 py-2.5 border border-edge rounded-xl bg-surface-input text-content placeholder:text-content-faint focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent text-body transition-shadow" const labelCls = "flex items-center gap-1 text-caption font-semibold uppercase tracking-[0.14em] text-content-faint mb-2" const ghostBtnCls = "px-4 py-2.5 text-body font-medium text-content-secondary hover:text-content border border-edge rounded-xl hover:bg-surface-hover transition-colors" const primaryBtnCls = "px-5 py-2.5 text-body font-medium bg-accent hover:bg-accent-hover disabled:opacity-50 text-accent-text rounded-xl shadow-card transition-colors flex items-center gap-2" const dangerBtnCls = "px-5 py-2.5 text-body font-medium whitespace-nowrap bg-danger hover:opacity-90 disabled:opacity-50 text-white rounded-xl shadow-card transition-opacity flex items-center gap-2" /* Two columns once there is room for them: the form had grown to eight stacked blocks and the create button sat a full screen below the title. Left is what the trip looks like, right is when and with whom. One column below md. */ const columnCls = "flex flex-col gap-4" const panelCls = "rounded-2xl border border-edge bg-surface-secondary p-4 space-y-3.5" return ( { if (!busy) setPendingReview(null) } : onClose} title={pendingReview ? t(pendingReview.askShift ? 'dashboard.dateShiftTitle' : 'dashboard.shrinkTitle') : isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')} /* The review step is a few radio buttons and a short list; alone, either takes the width of the day dialog, where the same list stands. With both, they stand side by side at the form's own width. */ size={pendingReview && !dateReviewIsWide(pendingReview.askShift, pendingReview.removal) ? 'lg' : '2xl'} footer={
{pendingReview ? ( <> {/* Removing days with something on them is the one save here that loses work, so it asks in the danger tone and says so. */} ) : ( <> )}
} > {pendingReview && ( )}
{error && (
{error}
)}
{/* Left column — what the trip is: its picture, its name, what it is about. */}
{/* Cover image — gated by trip_cover_upload permission */} {canUploadCover &&
{coverPreview ? (
{/* Chrome sitting on top of a photo, so it is deliberately dark in both themes rather than following the surface tokens. */}
) : ( )}
setCoverSearchQuery(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleCoverSearch() } }} placeholder={t('dashboard.unsplashSearchPlaceholder')} className={inputCls} />
{coverSearchError &&

{coverSearchError}

} {coverSearchResults.length > 0 && (
{coverSearchResults.map(photo => ( ))}
)}
}
canEditTrip && update('title', e.target.value)} required readOnly={!canEditTrip} placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />