import { addIsoDays, MAX_TRIP_DAYS, tripSpanDays, type TripCreateRequest } from '@trek/shared'; import { CalendarDays, Camera, Image as ImageIcon, Pencil, Plane, Search, Type, X } from 'lucide-react'; import { useEffect, useId, useRef, useState } from 'react'; import { authApi, tripsApi } from '../../api/client'; import { useTripRangeGuard, type RangeCheck } from '../../hooks/useTripRangeGuard'; import { useTranslation } from '../../i18n'; import { tripChangeNeedsConnection } from '../../repo/tripRepo'; import { useAuthStore } from '../../store/authStore'; import { useCanDo } from '../../store/permissionsStore'; import { useSettingsStore } from '../../store/settingsStore'; import { isEffectivelyOffline } from '../../sync/networkMode'; import { type Trip } from '../../types'; import type { ShiftMode } from '../../utils/dayImpactLines'; import { currenciesWith, SYMBOLS } from '../Budget/BudgetPanel.constants'; import { CustomDatePicker } from '../shared/CustomDateTimePicker'; import CustomSelect from '../shared/CustomSelect'; import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, } from '../shared/DialogShell'; import { NumericInput } from '../shared/NumericInput'; import { useToast } from '../shared/Toast'; import { CardHead, INPUT, LABEL, PANEL } from '../shared/dialogParts'; import TripDateReview, { dateReviewIsWide } from './TripDateReview'; import { tripFormError } from './tripFormModel'; import { useTripCoverPicker } from './useTripCoverPicker'; 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; /** * Drawn inside the trip settings rather than as a dialog of its own (#2811): no * frame and no Cancel, the settings around it close it. One column there, since * beside the settings' own side column two would cut the dates and wrap the chips. */ embedded?: boolean; /** Embedded, told while the date question is up, so the dialog around it keeps Escape and its backdrop off. */ onReviewChange?: (reviewing: boolean) => void; } export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate, embedded = false, onReviewChange, }: TripFormModalProps) { const isEditing = !!trip; const labelId = useId(); const fileRef = 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 cover = useTripCoverPicker({ trip, title: formData.title, onCoverUpdate, releasePreviewOnChange: false }); const { coverPreview, uploadingCover, searchQuery: coverSearchQuery, setSearchQuery: setCoverSearchQuery, searchResults: coverSearchResults, searchError: coverSearchError, searching: searchingCover, } = cover; // 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)); cover.resetCover(trip.cover_image || null); } else { setFormData({ title: '', description: '', start_date: '', end_date: '', currency: defaultCurrency, reminder_days: tripRemindersEnabled ? 3 : 0, day_count: 7, }); setCustomReminder(false); cover.resetCover(null); } 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(() => {}); // The members block is not drawn inside the trip settings, whose People area holds them. if (!embedded) { authApi .listUsers() .then((d) => setAllUsers(d.users || [])) .catch(() => {}); if (trip) tripsApi .getMembers(trip.id) .then((d) => setExistingMembers(d.members || [])) .catch(() => {}); } } }, [trip, isOpen, embedded]); useEffect(() => { if (!trip && isOpen) { setFormData((prev) => ({ ...prev, reminder_days: tripRemindersEnabled ? 3 : 0 })); } }, [tripRemindersEnabled]); const handleSubmit = async (e) => { e.preventDefault(); setError(''); const formError = tripFormError( { title: formData.title, startDate: formData.start_date, endDate: formData.end_date }, trip, t ); if (formError) { setError(formError); 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. if (refusedOffline(payload)) return; 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); }; // A new trip, new dates or a new currency are made by the server; a new name // or description waits in the queue offline like any other change. const refusedOffline = (payload: TripPayload): boolean => { if (!isEffectivelyOffline() || (isEditing && !tripChangeNeedsConnection(trip ?? undefined, payload))) return false; setError(t(isEditing ? 'dashboard.tripChangeNeedsConnection' : 'common.needsConnection')); return true; }; const performSave = async (payload: TripPayload) => { if (refusedOffline(payload)) return; 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 await cover.applyToCreatedTrip(createdTrip?.id); onClose(); } catch (err: unknown) { setError(err instanceof Error ? err.message : t('places.saveError')); } finally { setIsLoading(false); } }; const handleCoverSelect = cover.selectFile; const handleCoverChange = (e: React.ChangeEvent) => { void handleCoverSelect(e.target.files?.[0]); e.target.value = ''; }; const handleCoverSearch = cover.search; const handleUnsplashSelect = cover.selectPhoto; const handleRemoveCover = cover.removeCover; // 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) void handleCoverSelect(file); return; } } }; // An edit answers whatever the last save complained about, so the complaint // goes with it rather than standing over the fixed field until the next save. const update = (field, value) => { setError(''); 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) { // Keep the trip's length, counted in UTC calendar days: a local-time // shift across a DST change used to drop or add a day. next.end_date = addIsoDays(value, tripSpanDays(prev.start_date, prev.end_date) - 1); } } 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. */ // The dialogs' own language: their fields, eyebrows, panels and buttons, so the // form reads alike on its own and inside the trip settings. const inputCls = INPUT; const labelCls = `${LABEL.replace('block ', '')} flex items-center gap-1`; /* 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 = PANEL; // Escape in the date question goes back to the form; embedded, the settings around it must not close on it. const reviewing = pendingReview != null; useEffect(() => { onReviewChange?.(reviewing); }, [reviewing, onReviewChange]); const reviewTitle = pendingReview ? t(pendingReview.askShift ? 'dashboard.dateShiftTitle' : 'dashboard.shrinkTitle') : ''; const buttons = pendingReview ? ( <> setPendingReview(null)} disabled={busy}> {t('common.back')} {/* Removing days with something on them is the one save here that loses work, so it asks in the danger tone and says so. */} performSave( pendingReview.askShift ? { ...pendingReview.payload, date_shift_mode: dateShiftMode } : pendingReview.payload ) } > {isLoading ? t('common.saving') : t(pendingReview.removal ? 'dashboard.shrinkConfirm' : 'common.update')} ) : ( <> {!embedded && {t('common.cancel')}} {busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')} ); const content = ( <> {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 && (
} title={t('trip.form.coverTitle')} hint={t('trip.form.coverHint')} /> {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(); void handleCoverSearch(); } }} placeholder={t('dashboard.unsplashSearchPlaceholder')} className={inputCls} />
{coverSearchError &&

{coverSearchError}

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