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 type { TripCreateRequest } from '@trek/shared' import { NumericInput } from '../shared/NumericInput' import { currenciesWith, SYMBOLS } from '../Budget/BudgetPanel.constants' type DateShiftMode = 'keep_bookings' | 'shift_all' 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('') // Set when a start-date change on a dated trip needs the user to pick how the // itinerary follows the new dates (#1288); holds the payload awaiting that choice. const [pendingDateShift, setPendingDateShift] = useState<(TripCreateRequest & { date_shift_mode?: DateShiftMode }) | null>(null) const [dateShiftMode, setDateShiftMode] = useState('keep_bookings') 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([]) setPendingDateShift(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 && new Date(formData.end_date) < new Date(formData.start_date)) { setError(t('dashboard.endDateError')); return } if (!formData.start_date && !formData.end_date) { const dc = Number(formData.day_count) if (formData.day_count === '' || !Number.isInteger(dc) || dc < 1 || dc > 365) { setError(t('dashboard.dayCountRequired')); return } } const payload: TripCreateRequest & { date_shift_mode?: DateShiftMode } = { 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, ...(!formData.start_date && !formData.end_date ? { day_count: Number(formData.day_count) } : {}), } // 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. if (isEditing && trip?.start_date && trip?.end_date && payload.start_date && payload.end_date && payload.start_date !== trip.start_date) { setDateShiftMode('keep_bookings') setPendingDateShift(payload) return } await performSave(payload) } const performSave = async (payload: TripCreateRequest & { date_shift_mode?: DateShiftMode }) => { 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" /* 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 ( {pendingDateShift ? ( <> ) : ( <> )} } > {pendingDateShift && (
{error && (
{error}
)}

{t('dashboard.dateShiftIntro')}

{([ { mode: 'keep_bookings' as DateShiftMode, label: t('dashboard.dateShiftKeepBookings'), desc: t('dashboard.dateShiftKeepBookingsDesc') }, { mode: 'shift_all' as DateShiftMode, label: t('dashboard.dateShiftAll'), desc: t('dashboard.dateShiftAllDesc') }, ]).map(({ mode, label, desc }) => ( ))}

{t('dashboard.dateShiftHint')}

)}
{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} />