refactor(trips): share the trip form cover picker and checks between desktop and phone

The dialog and the phone sheet each carried the cover upload, the Unsplash search, the staged cover for a new trip and the title and range checks. They now live in useTripCoverPicker and tripFormModel; the blob url release keeps each view's timing behind an option.
This commit is contained in:
Maurice
2026-10-09 10:07:27 +02:00
parent 106830609e
commit e964a98138
6 changed files with 573 additions and 312 deletions
+17 -161
View File
@@ -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<HTMLInputElement>(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<string | null>(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<string | null>(null)
const [pendingCoverFile, setPendingCoverFile] = useState<File | null>(null)
const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState<string | null>(null)
const [uploadingCover, setUploadingCover] = useState(false)
const [coverSearchQuery, setCoverSearchQuery] = useState('')
const [coverSearchResults, setCoverSearchResults] = useState<CoverSearchPhoto[]>([])
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<HTMLInputElement>) => {
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) => {
@@ -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<string, string | number>) => (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');
});
});
@@ -0,0 +1,46 @@
import { MAX_TRIP_DAYS, tripSpanDays } from '@trek/shared';
type T = (key: string, params?: Record<string, string | number>) => 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;
}
@@ -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<typeof useTripCoverPicker>[0];
function picker(over: Partial<Options> = {}) {
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<void>;
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');
});
});
@@ -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<string | null>(null);
const [coverPreview, setCoverPreview] = useState<string | null>(null);
const [pendingCoverFile, setPendingCoverFile] = useState<File | null>(null);
const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState<string | null>(null);
const [uploadingCover, setUploadingCover] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<CoverSearchPhoto[]>([]);
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,
};
}
@@ -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<HTMLInputElement>(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<string | null>(null)
const [pendingCoverFile, setPendingCoverFile] = useState<File | null>(null)
const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState<string | null>(null)
const [uploadingCover, setUploadingCover] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<CoverSearchPhoto[]>([])
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'