diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index 4cc7018ee..24d45e507 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -38,7 +38,7 @@ "@typescript-eslint/no-non-null-assertion": 1907, "@typescript-eslint/no-misused-promises": 22, "@typescript-eslint/no-unused-vars": 51, - "@typescript-eslint/no-floating-promises": 27, + "@typescript-eslint/no-floating-promises": 33, "trek/no-window-globals": 325, "trek/no-swallowed-catch": 14, "react-hooks/globals": 5, diff --git a/client/scripts/format-baseline.json b/client/scripts/format-baseline.json index b9d1605e2..ec7044735 100644 --- a/client/scripts/format-baseline.json +++ b/client/scripts/format-baseline.json @@ -1025,7 +1025,6 @@ "src/components/Tours/useTourPermissions.ts", "src/components/Trips/TripDateReview.tsx", "src/components/Trips/TripFormModal.test.tsx", - "src/components/Trips/TripFormModal.tsx", "src/components/Trips/TripMemberAvatar.tsx", "src/components/Trips/TripMembersModal.test.tsx", "src/components/Trips/TripMembersModal.tsx", diff --git a/client/src/components/Trips/TripFormModal.test.tsx b/client/src/components/Trips/TripFormModal.test.tsx index 45fae3a66..78876c296 100644 --- a/client/src/components/Trips/TripFormModal.test.tsx +++ b/client/src/components/Trips/TripFormModal.test.tsx @@ -152,7 +152,7 @@ describe('TripFormModal', () => { it('FE-COMP-TRIPFORM-013: shows Cover Image section', () => { render(); - expect(screen.getByText('Cover Image')).toBeInTheDocument(); + expect(screen.getByText('Cover image')).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-014: shows start and end date labels', () => { @@ -538,7 +538,7 @@ describe('TripFormModal', () => { it('FE-COMP-TRIPFORM-040: a users payload without a list hides the member section', async () => { server.use(http.get('/api/auth/users', () => HttpResponse.json({}))); render(); - await screen.findByText('Cover Image'); + await screen.findByText('Cover image'); expect(screen.queryByText('Travel buddies')).not.toBeInTheDocument(); }); @@ -910,7 +910,7 @@ describe('TripFormModal', () => { seedStore(usePermissionsStore, { permissions: { trip_cover_upload: 'admin', trip_edit: 'admin' } }); render(); - expect(screen.queryByText('Cover Image')).not.toBeInTheDocument(); + expect(screen.queryByText('Cover image')).not.toBeInTheDocument(); fireEvent.paste(document.querySelector('form')!, { clipboardData: { items: [{ type: 'image/png', getAsFile: () => pngFile() }] }, }); @@ -1365,7 +1365,7 @@ describe('TripFormModal', () => { // No start moved, so no shift question. expect(screen.queryByText('Keep bookings on their dates')).not.toBeInTheDocument(); // The longest confirm label of any language stays on one line. - expect(screen.getByRole('button', { name: 'Remove days and save' })).toHaveClass('bg-danger', 'whitespace-nowrap'); + expect(screen.getByRole('button', { name: 'Remove days and save' })).toHaveClass('bg-danger'); expect(onSave).not.toHaveBeenCalled(); }); diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx index 83113e617..5de06d886 100644 --- a/client/src/components/Trips/TripFormModal.tsx +++ b/client/src/components/Trips/TripFormModal.tsx @@ -1,29 +1,37 @@ -import { useId, useState, useEffect, useRef } from 'react' -import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../shared/DialogShell' -import { CardHead, INPUT, LABEL, PANEL } from '../shared/dialogParts' -import { CalendarDays, Camera, Image as ImageIcon, Pencil, Plane, Search, Type, X } 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 { type Trip } from '../../types' -import { MAX_TRIP_DAYS, addIsoDays, 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' -import { useTripCoverPicker } from './useTripCoverPicker' -import { tripFormError } from './tripFormModel' -import { isEffectivelyOffline } from '../../sync/networkMode' -import { tripChangeNeedsConnection } from '../../repo/tripRepo' +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 } +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 @@ -31,40 +39,50 @@ type TripPayload = TripCreateRequest & { date_shift_mode?: DateShiftMode } * something on them. The payload waits here until the traveller confirms. */ interface PendingReview { - payload: TripPayload - askShift: boolean - removal: RangeCheck + payload: TripPayload; + askShift: boolean; + removal: RangeCheck; } interface TripFormModalProps { - isOpen: boolean - onClose: () => void + 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 + 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?: 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 }: 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) +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: '', @@ -74,31 +92,36 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp 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 [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 + 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 + 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 + const rd = trip.reminder_days ?? 3; setFormData({ title: trip.title || '', description: trip.description || '', @@ -107,55 +130,82 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp 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) + }); + 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) + 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([]) + 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(() => {}) + 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]) + }, [trip, isOpen, embedded]); useEffect(() => { if (!trip && isOpen) { - setFormData(prev => ({ ...prev, reminder_days: tripRemindersEnabled ? 3 : 0 })) + setFormData((prev) => ({ ...prev, reminder_days: tripRemindersEnabled ? 3 : 0 })); } - }, [tripRemindersEnabled]) + }, [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 } + 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) + const dc = Number(formData.day_count); if (formData.day_count === '' || !Number.isInteger(dc) || dc < 1 || dc > MAX_TRIP_DAYS) { - setError(t('dashboard.dayCountRequired')); return + 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 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, @@ -164,100 +214,113 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp 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) + 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 + const removal = isEditing && trip ? await rangeGuard.check(trip, payload) : null; if (askShift || removal) { - setDateShiftMode('keep_bookings') - setPendingReview({ payload, askShift, removal }) - return + setDateShiftMode('keep_bookings'); + setPendingReview({ payload, askShift, removal }); + return; } - await performSave(payload) - } + 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 - } + 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) + if (refusedOffline(payload)) return; + setIsLoading(true); try { - const result = await onSave(payload) - const createdTrip = result ? result.trip : undefined + 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 + let memberAddFailed = false; for (const userId of selectedMembers) { - const user = allUsers.find(u => u.id === userId) + const user = allUsers.find((u) => u.id === userId); if (user) { - try { await tripsApi.addMember(createdTrip.id, user.username) } catch { memberAddFailed = true } + try { + await tripsApi.addMember(createdTrip.id, user.username); + } catch { + memberAddFailed = true; + } } } - if (memberAddFailed) toast.error(t('trips.memberAddError')) + if (memberAddFailed) toast.error(t('trips.memberAddError')); } // Upload pending cover for newly created trips - await cover.applyToCreatedTrip(createdTrip?.id) - onClose() + await cover.applyToCreatedTrip(createdTrip?.id); + onClose(); } catch (err: unknown) { - setError(err instanceof Error ? err.message : t('places.saveError')) + setError(err instanceof Error ? err.message : t('places.saveError')); } finally { - setIsLoading(false) + setIsLoading(false); } - } + }; - const handleCoverSelect = cover.selectFile + const handleCoverSelect = cover.selectFile; const handleCoverChange = (e: React.ChangeEvent) => { - void handleCoverSelect(e.target.files?.[0]) - e.target.value = '' - } + void handleCoverSelect(e.target.files?.[0]); + e.target.value = ''; + }; - const handleCoverSearch = cover.search - const handleUnsplashSelect = cover.selectPhoto - const handleRemoveCover = cover.removeCover + 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 + 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 + 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) + 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 - }) } + 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 @@ -266,22 +329,41 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp 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` + 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 + const columnCls = 'flex flex-col gap-4'; + const panelCls = PANEL; - const reviewTitle = pendingReview ? t(pendingReview.askShift ? 'dashboard.dateShiftTitle' : 'dashboard.shrinkTitle') : '' + // 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')} + 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)}> + + performSave( + pendingReview.askShift + ? { ...pendingReview.payload, date_shift_mode: dateShiftMode } + : pendingReview.payload + ) + } + > {isLoading ? t('common.saving') : t(pendingReview.removal ? 'dashboard.shrinkConfirm' : 'common.update')} @@ -292,303 +374,412 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp {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} /> -
- -
- -