diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx index cc12fdb49..024f171e3 100644 --- a/client/src/components/Trips/TripFormModal.tsx +++ b/client/src/components/Trips/TripFormModal.tsx @@ -9,14 +9,15 @@ 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 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' type DateShiftMode = ShiftMode type TripPayload = TripCreateRequest & { date_shift_mode?: DateShiftMode } @@ -42,21 +43,9 @@ interface TripFormModalProps { 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) @@ -79,14 +68,11 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp 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) + 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. @@ -113,22 +99,12 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp day_count: trip.day_count || 7, }) setCustomReminder(![0, 1, 3, 9].includes(rd)) - setCoverPreview(trip.cover_image || null) - setCoverSearchQuery('') + 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) - setCoverPreview(null) - setCoverSearchQuery('') + cover.resetCover(null) } - if (previewUrlRef.current) { - URL.revokeObjectURL(previewUrlRef.current) - previewUrlRef.current = null - } - setPendingCoverFile(null) - setPendingUnsplashUrl(null) - setCoverSearchResults([]) - setCoverSearchError('') setSelectedMembers([]) setPendingReview(null) setDateShiftMode('keep_bookings') @@ -153,24 +129,11 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp } }, [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', { count: MAX_TRIP_DAYS })); return } - } + 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) { @@ -225,24 +188,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp 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')) - } - } + await cover.applyToCreatedTrip(createdTrip?.id) onClose() } catch (err: unknown) { setError(err instanceof Error ? err.message : t('places.saveError')) @@ -251,106 +197,16 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp } } - 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 - void 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 handleCoverSelect = cover.selectFile const handleCoverChange = (e: React.ChangeEvent) => { void 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')) - } - } + const handleCoverSearch = cover.search + const handleUnsplashSelect = cover.selectPhoto + const handleRemoveCover = cover.removeCover // Paste support for cover image const handlePaste = (e: React.ClipboardEvent) => { diff --git a/client/src/components/Trips/tripFormModel.test.ts b/client/src/components/Trips/tripFormModel.test.ts new file mode 100644 index 000000000..c21e1048c --- /dev/null +++ b/client/src/components/Trips/tripFormModel.test.ts @@ -0,0 +1,55 @@ +// FE-COMP-TRIPFORMMODEL-001 to -006: the trip form checks the dialog and the phone sheet share. +import { MAX_TRIP_DAYS, addIsoDays } from '@trek/shared'; + +import { endDateForNewStart, tripFormError } from './tripFormModel'; + +const t = (key: string, params?: Record) => (params ? `${key}:${params.count}` : key); + +describe('tripFormError', () => { + it('FE-COMP-TRIPFORMMODEL-001: a blank title is refused first', () => { + expect(tripFormError({ title: ' ', startDate: '2026-05-02', endDate: '2026-05-01' }, null, t)).toBe( + 'dashboard.titleRequired' + ); + }); + + it('FE-COMP-TRIPFORMMODEL-002: an end before the start is refused', () => { + expect(tripFormError({ title: 'Rome', startDate: '2026-05-02', endDate: '2026-05-01' }, null, t)).toBe( + 'dashboard.endDateError' + ); + }); + + it('FE-COMP-TRIPFORMMODEL-003: a new range past the limit is refused, an untouched stored one is not', () => { + const start = '2020-01-01'; + const end = addIsoDays(start, MAX_TRIP_DAYS); + expect(tripFormError({ title: 'Long', startDate: start, endDate: end }, null, t)).toBe( + `dashboard.tripTooLong:${MAX_TRIP_DAYS}` + ); + expect( + tripFormError({ title: 'Long', startDate: start, endDate: end }, { start_date: start, end_date: end }, t) + ).toBe(''); + expect( + tripFormError({ title: 'Long', startDate: start, endDate: end }, { start_date: start, end_date: '2020-01-05' }, t) + ).toBe(`dashboard.tripTooLong:${MAX_TRIP_DAYS}`); + }); + + it('FE-COMP-TRIPFORMMODEL-004: a valid form or one without dates passes', () => { + expect(tripFormError({ title: 'Rome', startDate: '2026-05-01', endDate: '2026-05-01' }, null, t)).toBe(''); + expect(tripFormError({ title: 'Rome', startDate: '', endDate: '' }, null, t)).toBe(''); + expect(tripFormError({ title: 'Rome', startDate: '2026-05-01', endDate: '' }, null, t)).toBe(''); + }); +}); + +describe('endDateForNewStart', () => { + it('FE-COMP-TRIPFORMMODEL-005: a valid range keeps its length when the start moves', () => { + expect(endDateForNewStart('2026-05-01', '2026-05-08', '2026-06-10')).toBe('2026-06-17'); + expect(endDateForNewStart('2026-05-01', '2026-05-08', '2026-04-20')).toBe('2026-04-27'); + }); + + it('FE-COMP-TRIPFORMMODEL-006: without a valid range the end only follows a start past it', () => { + expect(endDateForNewStart('', '', '2026-05-01')).toBe('2026-05-01'); + expect(endDateForNewStart('', '2026-05-03', '2026-05-01')).toBe('2026-05-03'); + expect(endDateForNewStart('', '2026-05-03', '2026-05-09')).toBe('2026-05-09'); + expect(endDateForNewStart('2026-05-08', '2026-05-03', '2026-05-05')).toBe('2026-05-05'); + expect(endDateForNewStart('2026-05-01', '2026-05-08', '')).toBe('2026-05-08'); + }); +}); diff --git a/client/src/components/Trips/tripFormModel.ts b/client/src/components/Trips/tripFormModel.ts new file mode 100644 index 000000000..a8db48e84 --- /dev/null +++ b/client/src/components/Trips/tripFormModel.ts @@ -0,0 +1,46 @@ +import { MAX_TRIP_DAYS, tripSpanDays } from '@trek/shared'; + +type T = (key: string, params?: Record) => string; + +interface TripFormFields { + title: string; + startDate: string; + endDate: string; +} + +/** + * Why the title and range of the trip form cannot be saved, or '' when they can. + * 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. + */ +export function tripFormError( + { title, startDate, endDate }: TripFormFields, + trip: { start_date?: string | null; end_date?: string | null } | null, + t: T +): string { + if (!title.trim()) return t('dashboard.titleRequired'); + if (startDate && endDate) { + const span = tripSpanDays(startDate, endDate); + if (span < 1) return t('dashboard.endDateError'); + const datesTouched = !trip || startDate !== (trip.start_date || '') || endDate !== (trip.end_date || ''); + if (datesTouched && span > MAX_TRIP_DAYS) return t('dashboard.tripTooLong', { count: MAX_TRIP_DAYS }); + } + return ''; +} + +/** + * The end date the phone sheet shows after the start moved to `value`: a valid + * range keeps its length, an end before the new start follows it. + */ +export function endDateForNewStart(startDate: string, endDate: string, value: string): string { + if (value && endDate && startDate && endDate >= startDate) { + const duration = Math.round( + (new Date(endDate + 'T00:00:00Z').getTime() - new Date(startDate + 'T00:00:00Z').getTime()) / 86400000 + ); + const newEnd = new Date(value + 'T00:00:00Z'); + newEnd.setDate(newEnd.getDate() + duration); + return newEnd.toISOString().split('T')[0]; + } + if (value && (!endDate || endDate < value)) return value; + return endDate; +} diff --git a/client/src/components/Trips/useTripCoverPicker.test.ts b/client/src/components/Trips/useTripCoverPicker.test.ts new file mode 100644 index 000000000..ad55f6ec8 --- /dev/null +++ b/client/src/components/Trips/useTripCoverPicker.test.ts @@ -0,0 +1,214 @@ +// FE-COMP-TRIPCOVER-001 to -014: the cover picker the trip dialog and the phone sheet share. +import { act, renderHook } from '@testing-library/react'; + +import { tripsApi } from '../../api/client'; +import { useTripCoverPicker } from './useTripCoverPicker'; + +const toast = { success: vi.fn(), error: vi.fn() }; +vi.mock('../shared/Toast', () => ({ useToast: () => toast })); +vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) })); +vi.mock('../../utils/convertHeic', () => ({ normalizeImageFile: (file: File) => Promise.resolve(file) })); + +const origCreate = URL.createObjectURL; +const origRevoke = URL.revokeObjectURL; +const createObjectURL = vi.fn<(obj: Blob) => string>(); +const revokeObjectURL = vi.fn<(url: string) => void>(); + +const png = () => new File(['x'], 'cover.png', { type: 'image/png' }); +const photo = { id: 'p1', url: 'https://images.example/p1.jpg', thumb: 'https://images.example/p1-thumb.jpg' }; + +type Options = Parameters[0]; + +function picker(over: Partial = {}) { + const props: Options = { trip: null, title: '', releasePreviewOnChange: false, ...over }; + return renderHook((p: Options) => useTripCoverPicker(p), { initialProps: props }); +} + +beforeEach(() => { + let n = 0; + createObjectURL.mockReset().mockImplementation(() => `blob:${++n}`); + revokeObjectURL.mockReset(); + URL.createObjectURL = createObjectURL; + URL.revokeObjectURL = revokeObjectURL; + toast.success.mockReset(); + toast.error.mockReset(); +}); +afterEach(() => { + URL.createObjectURL = origCreate; + URL.revokeObjectURL = origRevoke; + vi.restoreAllMocks(); +}); + +describe('useTripCoverPicker', () => { + it('FE-COMP-TRIPCOVER-001: creating stages a picked file as a blob preview without a request', async () => { + const upload = vi.spyOn(tripsApi, 'uploadCover'); + const { result } = picker(); + await act(() => result.current.selectFile(png())); + expect(result.current.coverPreview).toBe('blob:1'); + expect(upload).not.toHaveBeenCalled(); + }); + + it('FE-COMP-TRIPCOVER-002: editing uploads a picked file straight away', async () => { + vi.spyOn(tripsApi, 'uploadCover').mockResolvedValue({ cover_image: '/uploads/covers/new.jpg' }); + const onCoverUpdate = vi.fn(); + const { result } = picker({ trip: { id: 5 }, onCoverUpdate }); + await act(() => result.current.selectFile(png())); + expect(tripsApi.uploadCover).toHaveBeenCalledWith(5, expect.any(FormData)); + expect(result.current.coverPreview).toBe('/uploads/covers/new.jpg'); + expect(onCoverUpdate).toHaveBeenCalledWith(5, '/uploads/covers/new.jpg'); + expect(toast.success).toHaveBeenCalledWith('dashboard.coverSaved'); + expect(result.current.uploadingCover).toBe(false); + }); + + it('FE-COMP-TRIPCOVER-003: a failed upload while editing toasts and keeps the preview', async () => { + vi.spyOn(tripsApi, 'uploadCover').mockRejectedValue(new Error('boom')); + const { result } = picker({ trip: { id: 5 } }); + act(() => result.current.resetCover('/uploads/covers/old.jpg')); + await act(() => result.current.selectFile(png())); + expect(toast.error).toHaveBeenCalledWith('dashboard.coverUploadError'); + expect(result.current.coverPreview).toBe('/uploads/covers/old.jpg'); + }); + + it('FE-COMP-TRIPCOVER-004: the desktop mode revokes a staged preview when another file replaces it', async () => { + const { result } = picker(); + await act(() => result.current.selectFile(png())); + await act(() => result.current.selectFile(png())); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:1'); + expect(result.current.coverPreview).toBe('blob:2'); + }); + + it('FE-COMP-TRIPCOVER-005: the desktop mode keeps a dropped preview until reset, then revokes it', async () => { + const { result } = picker(); + await act(() => result.current.selectFile(png())); + await act(() => result.current.removeCover()); + expect(result.current.coverPreview).toBeNull(); + expect(revokeObjectURL).not.toHaveBeenCalled(); + act(() => result.current.resetCover(null)); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:1'); + }); + + it('FE-COMP-TRIPCOVER-006: the desktop mode revokes the staged preview on unmount', async () => { + const { result, unmount } = picker(); + await act(() => result.current.selectFile(png())); + unmount(); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:1'); + }); + + it('FE-COMP-TRIPCOVER-007: the phone mode releases a blob preview as soon as it is dropped', async () => { + const { result } = picker({ releasePreviewOnChange: true }); + await act(() => result.current.selectFile(png())); + expect(revokeObjectURL).not.toHaveBeenCalled(); + await act(() => result.current.removeCover()); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:1'); + }); + + it('FE-COMP-TRIPCOVER-008: the phone mode never revokes a stored cover url', () => { + const { result, unmount } = picker({ releasePreviewOnChange: true, trip: { id: 5 } }); + act(() => result.current.resetCover('/uploads/covers/a.jpg')); + unmount(); + expect(revokeObjectURL).not.toHaveBeenCalled(); + }); + + it('FE-COMP-TRIPCOVER-009: search falls back to the title and needs one of the two', async () => { + const searchSpy = vi.spyOn(tripsApi, 'searchCoverImages').mockResolvedValue({ photos: [photo] }); + const empty = picker(); + await act(() => empty.result.current.search()); + expect(empty.result.current.searchError).toBe('dashboard.unsplashQueryRequired'); + expect(searchSpy).not.toHaveBeenCalled(); + + const { result } = picker({ title: ' Lisbon ' }); + await act(() => result.current.search()); + expect(searchSpy).toHaveBeenCalledWith('Lisbon'); + expect(result.current.searchResults).toEqual([photo]); + expect(result.current.searching).toBe(false); + }); + + it('FE-COMP-TRIPCOVER-010: no photos and failures land in the search error', async () => { + vi.spyOn(tripsApi, 'searchCoverImages').mockResolvedValueOnce({ photos: [] }); + const { result } = picker(); + act(() => result.current.setSearchQuery('nowhere')); + await act(() => result.current.search()); + expect(result.current.searchError).toBe('dashboard.unsplashNoResults'); + + vi.spyOn(tripsApi, 'searchCoverImages').mockRejectedValueOnce(new Error('offline')); + await act(() => result.current.search()); + expect(result.current.searchError).toBe('offline'); + }); + + it('FE-COMP-TRIPCOVER-011: only the latest search applies its results', async () => { + let resolveFirst!: (v: { photos: (typeof photo)[] }) => void; + vi.spyOn(tripsApi, 'searchCoverImages') + .mockImplementationOnce(() => new Promise((r) => (resolveFirst = r))) + .mockResolvedValueOnce({ photos: [{ ...photo, id: 'newer' }] }); + const { result } = picker({ title: 'x' }); + let first!: Promise; + act(() => { + first = result.current.search(); + }); + await act(() => result.current.search()); + await act(async () => { + resolveFirst({ photos: [photo] }); + await first; + }); + expect(result.current.searchResults.map((p) => p.id)).toEqual(['newer']); + }); + + it('FE-COMP-TRIPCOVER-012: picking a photo stages it while creating and saves it while editing', async () => { + const update = vi.spyOn(tripsApi, 'update').mockResolvedValue({ trip: {} } as never); + const creating = picker(); + await act(() => creating.result.current.selectPhoto(photo)); + expect(creating.result.current.coverPreview).toBe(photo.url); + expect(update).not.toHaveBeenCalled(); + + const onCoverUpdate = vi.fn(); + const editing = picker({ trip: { id: 9 }, onCoverUpdate }); + await act(() => editing.result.current.selectPhoto(photo)); + expect(update).toHaveBeenCalledWith(9, { cover_image: photo.url }); + expect(onCoverUpdate).toHaveBeenCalledWith(9, photo.url); + expect(toast.success).toHaveBeenCalledWith('dashboard.coverSaved'); + }); + + it('FE-COMP-TRIPCOVER-013: removing a stored cover clears it on the server, a failure toasts', async () => { + const update = vi.spyOn(tripsApi, 'update').mockResolvedValueOnce({ trip: {} } as never); + const onCoverUpdate = vi.fn(); + const { result } = picker({ trip: { id: 9 }, onCoverUpdate }); + act(() => result.current.resetCover('/uploads/covers/a.jpg')); + await act(() => result.current.removeCover()); + expect(update).toHaveBeenCalledWith(9, { cover_image: null }); + expect(onCoverUpdate).toHaveBeenCalledWith(9, null); + expect(result.current.coverPreview).toBeNull(); + + update.mockRejectedValueOnce(new Error('no')); + await act(() => result.current.removeCover()); + expect(toast.error).toHaveBeenCalledWith('dashboard.coverRemoveError'); + }); + + it('FE-COMP-TRIPCOVER-014: applyToCreatedTrip saves the staged file or photo on the new trip', async () => { + const upload = vi.spyOn(tripsApi, 'uploadCover').mockResolvedValue({ cover_image: '/uploads/covers/c.jpg' }); + const update = vi.spyOn(tripsApi, 'update').mockResolvedValue({ trip: {} } as never); + const onCoverUpdate = vi.fn(); + + const withFile = picker({ onCoverUpdate }); + await act(() => withFile.result.current.applyToCreatedTrip(3)); + expect(upload).not.toHaveBeenCalled(); + await act(() => withFile.result.current.selectFile(png())); + await act(() => withFile.result.current.applyToCreatedTrip(undefined)); + expect(upload).not.toHaveBeenCalled(); + await act(() => withFile.result.current.applyToCreatedTrip(3)); + expect(upload).toHaveBeenCalledWith(3, expect.any(FormData)); + expect(onCoverUpdate).toHaveBeenCalledWith(3, '/uploads/covers/c.jpg'); + + const withPhoto = picker({ onCoverUpdate }); + await act(() => withPhoto.result.current.selectPhoto(photo)); + await act(() => withPhoto.result.current.applyToCreatedTrip(4)); + expect(update).toHaveBeenCalledWith(4, { cover_image: photo.url }); + expect(onCoverUpdate).toHaveBeenCalledWith(4, photo.url); + + upload.mockRejectedValueOnce(new Error('x')); + await act(() => withFile.result.current.applyToCreatedTrip(3)); + expect(toast.error).toHaveBeenCalledWith('dashboard.coverUploadError'); + update.mockRejectedValueOnce(new Error('x')); + await act(() => withPhoto.result.current.applyToCreatedTrip(4)); + expect(toast.error).toHaveBeenCalledWith('dashboard.coverSaveError'); + }); +}); diff --git a/client/src/components/Trips/useTripCoverPicker.ts b/client/src/components/Trips/useTripCoverPicker.ts new file mode 100644 index 000000000..3e860137e --- /dev/null +++ b/client/src/components/Trips/useTripCoverPicker.ts @@ -0,0 +1,227 @@ +import { useEffect, useRef, useState } from 'react'; + +import { tripsApi } from '../../api/client'; +import { useTranslation } from '../../i18n'; +import { getApiErrorMessage } from '../../types'; +import { normalizeImageFile } from '../../utils/convertHeic'; +import { useToast } from '../shared/Toast'; + +interface CoverSearchPhoto { + id: string; + url: string; + thumb: string; + description?: string | null; + photographer?: string | null; + link?: string | null; +} + +interface TripCoverPickerOptions { + /** The trip being edited, or null while creating one. */ + trip: { id: number } | null; + /** Falls back as the Unsplash query when the search field is empty. */ + title: string; + onCoverUpdate?: (tripId: number, coverUrl: string | null) => void; + /** + * Release a staged file's blob url as soon as the preview moves on (the phone + * sheet). Otherwise it is released when another file replaces it, on reset and + * on unmount (the desktop dialog). + */ + releasePreviewOnChange: boolean; +} + +/** + * The cover of the create/edit trip form: device upload and Unsplash search. While + * editing, a pick is saved on the trip straight away; while creating, it is staged + * and `applyToCreatedTrip` saves it once the trip exists. + */ +export function useTripCoverPicker({ trip, title, onCoverUpdate, releasePreviewOnChange }: TripCoverPickerOptions) { + const { t } = useTranslation(); + const toast = useToast(); + const isEditing = !!trip; + const coverSearchSeq = useRef(0); + // The staged cover lives on as an object URL until it is replaced or the form goes. + const previewUrlRef = useRef(null); + + const [coverPreview, setCoverPreview] = useState(null); + const [pendingCoverFile, setPendingCoverFile] = useState(null); + const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState(null); + const [uploadingCover, setUploadingCover] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [searchError, setSearchError] = useState(''); + const [searching, setSearching] = useState(false); + + // Server and Unsplash urls are left alone; only a local file preview is a blob url. + useEffect(() => { + if (!releasePreviewOnChange || !coverPreview?.startsWith('blob:')) return; + return () => { + URL.revokeObjectURL(coverPreview); + }; + }, [coverPreview, releasePreviewOnChange]); + + // 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); + }, + [] + ); + + /** Back to a clean picker showing `preview` (the trip's stored cover, or none). */ + const resetCover = (preview: string | null) => { + setCoverPreview(preview); + setSearchQuery(''); + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + } + setPendingCoverFile(null); + setPendingUnsplashUrl(null); + setSearchResults([]); + setSearchError(''); + }; + + const stagePreview = (file: File) => { + if (releasePreviewOnChange) { + setCoverPreview(URL.createObjectURL(file)); + return; + } + if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = URL.createObjectURL(file); + setCoverPreview(previewUrlRef.current); + }; + + const uploadCoverNow = async (tripId: number, file: File) => { + setUploadingCover(true); + try { + const fd = new FormData(); + fd.append('cover', file); + const data = await tripsApi.uploadCover(tripId, fd); + setCoverPreview(data.cover_image); + onCoverUpdate?.(tripId, data.cover_image); + toast.success(t('dashboard.coverSaved')); + } catch { + toast.error(t('dashboard.coverUploadError')); + } finally { + setUploadingCover(false); + } + }; + + const selectFile = 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 + await uploadCoverNow(trip.id, normalized); + } else { + // New trip: stage for upload after creation + setPendingCoverFile(normalized); + stagePreview(normalized); + } + }; + + const search = async () => { + const query = searchQuery.trim() || title.trim(); + if (!query) { + setSearchError(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; + setSearching(true); + setSearchError(''); + try { + const data = await tripsApi.searchCoverImages(query); + if (seq !== coverSearchSeq.current) return; + const photos: CoverSearchPhoto[] = data.photos || []; + setSearchResults(photos); + if (photos.length === 0) setSearchError(t('dashboard.unsplashNoResults')); + } catch (err: unknown) { + if (seq !== coverSearchSeq.current) return; + setSearchError(getApiErrorMessage(err, t('dashboard.coverSearchError'))); + } finally { + if (seq === coverSearchSeq.current) setSearching(false); + } + }; + + const selectPhoto = 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 removeCover = async () => { + if (pendingCoverFile || pendingUnsplashUrl) { + setPendingCoverFile(null); + setPendingUnsplashUrl(null); + setCoverPreview(null); + return; + } + // Nothing pending left, so the preview is a saved trip's stored cover. + const id = trip!.id; + try { + await tripsApi.update(id, { cover_image: null }); + setCoverPreview(null); + onCoverUpdate?.(id, null); + } catch { + toast.error(t('dashboard.coverRemoveError')); + } + }; + + /** Saves the staged cover on a trip that was just created. */ + const applyToCreatedTrip = async (createdId: number | undefined) => { + if (pendingCoverFile && createdId) { + try { + const fd = new FormData(); + fd.append('cover', pendingCoverFile); + const data = await tripsApi.uploadCover(createdId, fd); + onCoverUpdate?.(createdId, 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 && createdId) { + try { + await tripsApi.update(createdId, { cover_image: pendingUnsplashUrl }); + onCoverUpdate?.(createdId, pendingUnsplashUrl); + } catch { + toast.error(t('dashboard.coverSaveError')); + } + } + }; + + return { + coverPreview, + uploadingCover, + searchQuery, + setSearchQuery, + searchResults, + searchError, + searching, + resetCover, + selectFile, + search, + selectPhoto, + removeCover, + applyToCreatedTrip, + }; +} diff --git a/client/src/mobile/screens/dashboard/MNewTripSheet.tsx b/client/src/mobile/screens/dashboard/MNewTripSheet.tsx index 38bece089..ce8d4fb5d 100644 --- a/client/src/mobile/screens/dashboard/MNewTripSheet.tsx +++ b/client/src/mobile/screens/dashboard/MNewTripSheet.tsx @@ -1,17 +1,13 @@ import React, { useEffect, useRef, useState } from 'react' import { Archive, ArchiveRestore, Camera, Search, X } from 'lucide-react' import { useTranslation } from '../../../i18n' -import { tripsApi } from '../../../api/client' import { useCanDo } from '../../../store/permissionsStore' import { useSettingsStore } from '../../../store/settingsStore' -import { useToast } from '../../../components/shared/Toast' -import { normalizeImageFile } from '../../../utils/convertHeic' -import { getApiErrorMessage } from '../../../types' import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker' import CustomSelect from '../../../components/shared/CustomSelect' import { currenciesWith, SYMBOLS } from '../../../components/Budget/BudgetPanel.constants' import type { DashboardTrip } from '../../../pages/dashboard/dashboardModel' -import { MAX_TRIP_DAYS, tripSpanDays, type Trip, type TripCreateRequest } from '@trek/shared' +import { type Trip, type TripCreateRequest } from '@trek/shared' import MSheet from '../../components/MSheet' import MIconBtn from '../../components/MIconBtn' import MListRow from '../../components/MListRow' @@ -19,14 +15,8 @@ import MDayImpactList from '../../components/MDayImpactList' import MConfirmSheet from '../settings/MConfirmSheet' import { useTripRangeGuard, type RangeRemoval } from '../../../hooks/useTripRangeGuard' import { dayChips, shrinkTripLines } from '../../../utils/dayImpactLines' - -interface CoverSearchPhoto { - id: string - url: string - thumb: string - description?: string | null - photographer?: string | null -} +import { useTripCoverPicker } from '../../../components/Trips/useTripCoverPicker' +import { endDateForNewStart, tripFormError } from '../../../components/Trips/tripFormModel' interface MNewTripSheetProps { open: boolean @@ -53,11 +43,9 @@ function FieldLabel({ children }: { children: React.ReactNode }): React.ReactEle export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpdate, onArchive }: MNewTripSheetProps): React.ReactElement { const isEditing = !!trip const { t } = useTranslation() - const toast = useToast() const can = useCanDo() const defaultCurrency = useSettingsStore(s => s.settings.default_currency) || 'EUR' const fileRef = useRef(null) - const coverSearchSeq = useRef(0) const canEditTrip = !isEditing || can('trip_edit', trip) const canUploadCover = !isEditing || can('trip_cover_upload', trip) @@ -68,14 +56,8 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda const [currency, setCurrency] = useState('EUR') const [error, setError] = useState('') const [isSaving, setIsSaving] = useState(false) - const [coverPreview, setCoverPreview] = useState(null) - const [pendingCoverFile, setPendingCoverFile] = useState(null) - const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState(null) - const [uploadingCover, setUploadingCover] = useState(false) - const [searchQuery, setSearchQuery] = useState('') - const [searchResults, setSearchResults] = useState([]) - const [searchError, setSearchError] = useState('') - const [searching, setSearching] = useState(false) + const cover = useTripCoverPicker({ trip, title, onCoverUpdate, releasePreviewOnChange: true }) + const { coverPreview, uploadingCover, searchQuery, setSearchQuery, searchResults, searchError, searching } = cover // A save whose new dates remove days with something on them, held until the // traveller confirms. The phone has no shift step, so bookings keep their dates. const [pendingRemoval, setPendingRemoval] = useState<{ payload: TripCreateRequest; removal: RangeRemoval | 'unknown' } | null>(null) @@ -89,46 +71,22 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda setStartDate(trip?.start_date || '') setEndDate(trip?.end_date || '') setCurrency(trip?.currency || defaultCurrency) - setCoverPreview(trip?.cover_image || null) - setPendingCoverFile(null) - setPendingUnsplashUrl(null) - setSearchQuery('') - setSearchResults([]) - setSearchError('') + cover.resetCover(trip?.cover_image || null) setError('') setPendingRemoval(null) }, [trip, open]) - // The local file preview is a blob url; release it once a new cover replaces it - // or the sheet goes away. Server and Unsplash urls are left alone. - useEffect(() => { - if (!coverPreview?.startsWith('blob:')) return - return () => { URL.revokeObjectURL(coverPreview) } - }, [coverPreview]) - // Moving the start keeps the trip length (same rule as TripFormModal). const changeStart = (value: string) => { - if (value && endDate && startDate && endDate >= startDate) { - const duration = Math.round((new Date(endDate + 'T00:00:00Z').getTime() - new Date(startDate + 'T00:00:00Z').getTime()) / 86400000) - const newEnd = new Date(value + 'T00:00:00Z') - newEnd.setDate(newEnd.getDate() + duration) - setEndDate(newEnd.toISOString().split('T')[0]) - } else if (value && (!endDate || endDate < value)) { - setEndDate(value) - } + setEndDate(endDateForNewStart(startDate, endDate, value)) setStartDate(value) } /** The payload the form stands for, or null after putting the reason on screen. */ const validPayload = (): TripCreateRequest | null => { setError('') - if (!title.trim()) { setError(t('dashboard.titleRequired')); return null } - if (startDate && endDate) { - const span = tripSpanDays(startDate, endDate) - if (span < 1) { setError(t('dashboard.endDateError')); return null } - const datesTouched = !trip || startDate !== (trip.start_date || '') || endDate !== (trip.end_date || '') - if (datesTouched && span > MAX_TRIP_DAYS) { setError(t('dashboard.tripTooLong', { count: MAX_TRIP_DAYS })); return null } - } + const formError = tripFormError({ title, startDate, endDate }, trip, t) + if (formError) { setError(formError); return null } return { title: title.trim(), description: description.trim() || null, @@ -154,23 +112,7 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda try { const result = await onSave(payload) const created = result ? result.trip : undefined - if (pendingCoverFile && created?.id) { - try { - const fd = new FormData() - fd.append('cover', pendingCoverFile) - const data = await tripsApi.uploadCover(created.id, fd) - onCoverUpdate?.(created.id, data.cover_image) - } catch { - toast.error(t('dashboard.coverUploadError')) - } - } else if (pendingUnsplashUrl && created?.id) { - try { - await tripsApi.update(created.id, { cover_image: pendingUnsplashUrl }) - onCoverUpdate?.(created.id, pendingUnsplashUrl) - } catch { - toast.error(t('dashboard.coverSaveError')) - } - } + await cover.applyToCreatedTrip(created?.id) onClose() } catch (err: unknown) { setError(err instanceof Error ? err.message : t('places.saveError')) @@ -179,89 +121,10 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda } } - const handleCoverFile = async (file: File | undefined | null) => { - if (!file) return - const normalized = await normalizeImageFile(file) - setPendingUnsplashUrl(null) - if (isEditing && trip?.id) { - setUploadingCover(true) - try { - const fd = new FormData() - fd.append('cover', normalized) - 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) - } - } else { - setPendingCoverFile(normalized) - setCoverPreview(URL.createObjectURL(normalized)) - } - } - - const handleSearch = async () => { - const query = searchQuery.trim() || title.trim() - if (!query) { setSearchError(t('dashboard.unsplashQueryRequired')); return } - // Only the latest search may apply its results (out-of-order guard). - const seq = ++coverSearchSeq.current - setSearching(true) - setSearchError('') - try { - const data = await tripsApi.searchCoverImages(query) - if (seq !== coverSearchSeq.current) return - const photos: CoverSearchPhoto[] = data.photos || [] - setSearchResults(photos) - if (photos.length === 0) setSearchError(t('dashboard.unsplashNoResults')) - } catch (err: unknown) { - if (seq !== coverSearchSeq.current) return - setSearchError(getApiErrorMessage(err, t('dashboard.coverSearchError'))) - } finally { - if (seq === coverSearchSeq.current) setSearching(false) - } - } - - const selectUnsplash = 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 removeCover = async () => { - if (pendingCoverFile || pendingUnsplashUrl) { - setPendingCoverFile(null) - setPendingUnsplashUrl(null) - setCoverPreview(null) - return - } - // Anything else is a cover stored on the trip, so this is the edit sheet. - const id = trip!.id - try { - await tripsApi.update(id, { cover_image: null }) - setCoverPreview(null) - onCoverUpdate?.(id, null) - } catch { - toast.error(t('dashboard.coverRemoveError')) - } - } + const handleCoverFile = cover.selectFile + const handleSearch = cover.search + const selectUnsplash = cover.selectPhoto + const removeCover = cover.removeCover const boxCls = 'rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px_12px]' const inputCls = 'w-full border-none bg-transparent pt-[2px] font-[inherit] text-[0.9375rem] font-semibold text-m-ink outline-none placeholder:text-m-faint'