import { useState, useEffect, useRef } from 'react' import Modal from '../shared/Modal' import { Calendar, Camera, X, Clipboard, UserPlus, Bell } from 'lucide-react' import { tripsApi, authApi } from '../../api/client' import CustomSelect from '../shared/CustomSelect' import { useAuthStore } from '../../store/authStore' 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 type { Trip } from '../../types' import type { TripCreateRequest } from '@trek/shared' 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) => Promise<{ trip?: Trip } | void> | void trip: Trip | null onCoverUpdate?: (tripId: number, coverUrl: string | null) => void } export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }: TripFormModalProps) { const isEditing = !!trip const fileRef = useRef(null) const toast = useToast() const { t } = useTranslation() const currentUser = useAuthStore(s => s.user) 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: '', 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 [uploadingCover, setUploadingCover] = 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('') 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 || '', reminder_days: rd, day_count: trip.day_count || 7, }) setCustomReminder(![0, 1, 3, 9].includes(rd)) setCoverPreview(trip.cover_image || null) } else { setFormData({ title: '', description: '', start_date: '', end_date: '', reminder_days: tripRemindersEnabled ? 3 : 0, day_count: 7 }) setCustomReminder(false) setCoverPreview(null) } setPendingCoverFile(null) setSelectedMembers([]) setError('') 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(() => {}) } else { setExistingMembers([]) } }, [trip, isOpen]) useEffect(() => { if (!trip && isOpen) { setFormData(prev => ({ ...prev, reminder_days: tripRemindersEnabled ? 3 : 0 })) } }, [tripRemindersEnabled]) 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 } } setIsLoading(true) try { const result = await onSave({ title: formData.title.trim(), description: formData.description.trim() || null, start_date: formData.start_date || null, end_date: formData.end_date || null, reminder_days: formData.reminder_days, ...(!formData.start_date && !formData.end_date ? { day_count: Number(formData.day_count) } : {}), }) 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')) } } onClose() } catch (err: unknown) { setError(err instanceof Error ? err.message : t('places.saveError')) } finally { setIsLoading(false) } } const handleCoverSelect = async (file) => { if (!file) return // HEIC/HEIF from iOS can't be rendered or stored as-is — convert to JPEG first const normalized = await normalizeImageFile(file) if (isEditing && trip?.id) { // Existing trip: upload immediately uploadCoverNow(normalized) } else { // New trip: stage for upload after creation setPendingCoverFile(normalized) setCoverPreview(URL.createObjectURL(normalized)) } } const handleCoverChange = (e) => { handleCoverSelect((e.target as HTMLInputElement).files?.[0]) e.target.value = '' } const uploadCoverNow = async (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 handleRemoveCover = async () => { if (pendingCoverFile) { setPendingCoverFile(null) setCoverPreview(null) return } if (!trip?.id) return 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 }) const inputCls = "w-full px-3 py-2.5 border border-slate-200 rounded-lg text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-300 focus:border-transparent text-sm" return ( } >
{error && (
{error}
)} {/* Cover image — gated by trip_cover_upload permission */} {canUploadCover &&
{coverPreview ? (
) : ( )}
}
canEditTrip && update('title', e.target.value)} required readOnly={!canEditTrip} placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />