mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user