diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index ed2d21b50..2cdfaa131 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -22,10 +22,11 @@ import { collectionsApi } from '../../api/collections' import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' import { useSaveToCollectionStore } from '../../store/saveToCollectionStore' +import { placeToSaveTarget } from '../Collections/saveTarget' import DawarichIcon from '../shared/DawarichIcon' import { Tooltip } from '../shared/Tooltip' import { useToast } from '../shared/Toast' -import { useTranslation, translateApiError } from '../../i18n' +import { useTranslation } from '../../i18n' import { usePluginStore } from '../../store/pluginStore' import PluginFrame from '../Plugins/PluginFrame' import type { Place, Category, Day, Reservation, TripFile, AssignmentsMap, DistanceUnit } from '../../types' @@ -46,6 +47,7 @@ import { BOX, Field, TypeTile, toneOf, toneTint, useOpenFile } from './bookings/ import { parseMeta } from './bookings/bookingsModel' import { SoftPill, TimePill, tintOf } from './planParts' import { usePlaceLanguage } from '../../hooks/usePlaceLanguage' +import { participantsWith, participantsWithout, splitParticipants, usePlaceFileUpload } from './usePlaceActions' const detailsCache = new Map() @@ -266,12 +268,9 @@ export default function PlaceInspector({ const saveVersion = useSaveToCollectionStore(s => s.version) const [savedInCollection, setSavedInCollection] = useState(false) const [hoursExpanded, setHoursExpanded] = useState(false) - const [filesExpanded, setFilesExpanded] = useState(false) - const [isUploading, setIsUploading] = useState(false) const [editingName, setEditingName] = useState(false) const [nameValue, setNameValue] = useState('') const nameInputRef = useRef(null) - const fileInputRef = useRef(null) const googleDetails = usePlaceDetails(place?.google_place_id, place?.osm_id, placeLang) // Library-wide "is this place already saved anywhere I can see?" indicator for @@ -296,51 +295,15 @@ export default function PlaceInspector({ const handleSaveToCollection = useCallback(() => { if (!place) return - openSavePicker({ - name: place.name, - source_trip_id: place.trip_id ?? null, - source_place_id: place.id, - description: place.description ?? null, - lat: place.lat ?? null, - lng: place.lng ?? null, - address: place.address ?? null, - category_id: place.category_id ?? null, - price: place.price ?? null, - currency: place.currency ?? null, - notes: place.notes ?? null, - image_url: place.image_url ?? null, - google_place_id: place.google_place_id ?? null, - google_ftid: place.google_ftid ?? null, - osm_id: place.osm_id ?? null, - website: place.website ?? null, - phone: place.phone ?? null, - }) + openSavePicker(placeToSaveTarget(place)) }, [place, openSavePicker]) // Sits above the `if (!place)` bail-out below: a hook after an early return is // only reached while a place is selected, so deselecting one mid-session // changes the hook count and React tears the tree down. - const placeId = place?.id - const handleFileUpload = useCallback(async (e: React.ChangeEvent) => { - const selectedFiles = Array.from(e.target.files || []) - if (!selectedFiles.length || !onFileUpload || !placeId) return - setIsUploading(true) - try { - for (const file of selectedFiles) { - const fd = new FormData() - fd.append('file', file) - fd.append('place_id', String(placeId)) - await onFileUpload(fd) - } - setFilesExpanded(true) - } catch (err: unknown) { - console.error('Upload failed', err) - toast.error(translateApiError(t, err, 'files.uploadError')) - } finally { - setIsUploading(false) - if (fileInputRef.current) fileInputRef.current.value = '' - } - }, [onFileUpload, placeId, toast, t]) + const { + uploading: isUploading, filesExpanded, setFilesExpanded, fileInputRef, handleUpload: handleFileUpload, + } = usePlaceFileUpload(place?.id, onFileUpload, toast, true) const startNameEdit = () => { if (!onUpdatePlace) return @@ -963,30 +926,17 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip }, [showAdd]) // Active participants: if allJoined, show all members; otherwise show only those in participantIds - const activeMembers = allJoined ? tripMembers : tripMembers.filter(m => participantIds.includes(m.id)) - const availableToAdd = allJoined ? [] : tripMembers.filter(m => !participantIds.includes(m.id)) + const { activeMembers, availableMembers: availableToAdd } = splitParticipants(tripMembers, participantIds, allJoined) const canRemove = activeMembers.length > 1 const handleRemove = (userId: number) => { if (!onSetParticipants) return - let newIds: number[] - if (allJoined) { - newIds = tripMembers.filter(m => m.id !== userId).map(m => m.id) - } else { - newIds = participantIds.filter(id => id !== userId) - } - if (newIds.length === tripMembers.length) newIds = [] - onSetParticipants(selectedAssignmentId, selectedDayId, newIds) + onSetParticipants(selectedAssignmentId, selectedDayId, participantsWithout(tripMembers, participantIds, allJoined, userId)) } const handleAdd = (userId: number) => { if (!onSetParticipants) return - const newIds = [...participantIds, userId] - if (newIds.length === tripMembers.length) { - onSetParticipants(selectedAssignmentId, selectedDayId, []) - } else { - onSetParticipants(selectedAssignmentId, selectedDayId, newIds) - } + onSetParticipants(selectedAssignmentId, selectedDayId, participantsWith(tripMembers, participantIds, userId)) setShowAdd(false) } diff --git a/client/src/components/Planner/usePlaceActions.test.ts b/client/src/components/Planner/usePlaceActions.test.ts new file mode 100644 index 000000000..6c9aca4fc --- /dev/null +++ b/client/src/components/Planner/usePlaceActions.test.ts @@ -0,0 +1,268 @@ +// FE-PLANNER-PLACEACT-001 to -015: the place writes behind both the desktop +// inspector and the phone place sheet. +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { ChangeEvent } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { buildAssignment, buildPlace } from '../../../tests/helpers/factories'; +import { resetAllStores } from '../../../tests/helpers/store'; +import { assignmentsApi } from '../../api/client'; +import { useTripStore } from '../../store/tripStore'; +import { normalizeImageFile } from '../../utils/convertHeic'; +import { + participantsWith, + participantsWithout, + placeActions, + splitParticipants, + usePlaceFileUpload, + usePlaceImagePick, +} from './usePlaceActions'; + +vi.mock('../../utils/convertHeic', () => ({ + normalizeImageFile: vi.fn(async (file: File) => new File([file], `normalized-${file.name}`)), +})); + +const MEMBERS = [{ id: 1 }, { id: 2 }, { id: 3 }]; +const t = (key: string) => `t:${key}`; + +/** A change event over a file input, the shape both shells' handlers read. */ +function pick(files: File[]) { + const target = { files, value: 'C:\\fakepath\\picked' }; + return { event: { target } as unknown as ChangeEvent, target }; +} + +function deps() { + return { + tripId: 7, + tripActions: { ratePlace: vi.fn(async () => buildPlace()), updatePlace: vi.fn(async () => buildPlace()) }, + toast: { error: vi.fn() }, + t, + }; +} + +beforeEach(() => { + resetAllStores(); + vi.clearAllMocks(); +}); +afterEach(() => vi.restoreAllMocks()); + +describe('participant lists', () => { + it('FE-PLANNER-PLACEACT-001: nobody set means every member joins and nobody is left to add', () => { + expect(splitParticipants(MEMBERS, [], true)).toEqual({ activeMembers: MEMBERS, availableMembers: [] }); + expect(splitParticipants(MEMBERS, [2], false)).toEqual({ + activeMembers: [{ id: 2 }], + availableMembers: [{ id: 1 }, { id: 3 }], + }); + }); + + it('FE-PLANNER-PLACEACT-002: taking one off keeps the others, and the whole trip again is stored as nobody', () => { + expect(participantsWithout(MEMBERS, [], true, 2)).toEqual([1, 3]); + expect(participantsWithout(MEMBERS, [1, 2], false, 2)).toEqual([1]); + // A stale id leaves exactly the trip's members behind, which is everyone again. + expect(participantsWithout(MEMBERS, [1, 2, 3, 99], false, 99)).toEqual([]); + }); + + it('FE-PLANNER-PLACEACT-003: adding the last missing member stores nobody, any other add appends', () => { + expect(participantsWith(MEMBERS, [1], 3)).toEqual([1, 3]); + expect(participantsWith(MEMBERS, [1, 2], 3)).toEqual([]); + }); +}); + +describe('placeActions', () => { + it('FE-PLANNER-PLACEACT-004: setParticipants writes the answer back into that day only', async () => { + const spy = vi.spyOn(assignmentsApi, 'setParticipants').mockResolvedValue({ participants: [{ user_id: 2 }] }); + useTripStore.setState({ + assignments: { + '4': [buildAssignment({ id: 10, day_id: 4 }), buildAssignment({ id: 11, day_id: 4 })], + '5': [buildAssignment({ id: 12, day_id: 5 })], + }, + } as never); + const d = deps(); + + await placeActions(d).setParticipants(10, 4, [2]); + + expect(spy).toHaveBeenCalledWith(7, 10, [2]); + const { assignments } = useTripStore.getState(); + expect(assignments['4'][0].participants).toEqual([{ user_id: 2 }]); + expect(assignments['4'][1].participants).not.toEqual([{ user_id: 2 }]); + expect(assignments['5'][0].participants).not.toEqual([{ user_id: 2 }]); + expect(d.toast.error).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-PLACEACT-005: a refused participant write says why, or the generic line', async () => { + const spy = vi.spyOn(assignmentsApi, 'setParticipants').mockRejectedValueOnce(new Error('no rights')); + const d = deps(); + await placeActions(d).setParticipants(10, 4, [2]); + expect(d.toast.error).toHaveBeenLastCalledWith('no rights'); + + spy.mockRejectedValueOnce('offline'); + await placeActions(d).setParticipants(10, 4, [2]); + expect(d.toast.error).toHaveBeenLastCalledWith('t:common.unknownError'); + }); + + it('FE-PLANNER-PLACEACT-006: ratePlace and updatePlace go through the trip actions with the trip id', async () => { + const d = deps(); + const actions = placeActions(d); + await actions.ratePlace(3, 4); + await actions.updatePlace(3, { route_color: '#123456' }); + expect(d.tripActions.ratePlace).toHaveBeenCalledWith(7, 3, 4); + expect(d.tripActions.updatePlace).toHaveBeenCalledWith(7, 3, { route_color: '#123456' }); + expect(d.toast.error).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-PLACEACT-007: a failed rating or update is reported with its own message', async () => { + const d = deps(); + d.tripActions.ratePlace.mockRejectedValueOnce(new Error('rate limited')); + d.tripActions.updatePlace.mockRejectedValueOnce('gone'); + const actions = placeActions(d); + await actions.ratePlace(3, null); + await actions.updatePlace(3, { name: 'X' }); + expect(d.toast.error.mock.calls.map((c) => c[0])).toEqual(['rate limited', 't:common.unknownError']); + }); +}); + +describe('usePlaceFileUpload', () => { + it('FE-PLANNER-PLACEACT-008: uploads every picked file against the place, then opens the list', async () => { + const upload = vi.fn(async (_fd: FormData) => undefined); + const toast = { error: vi.fn() }; + const { result } = renderHook(() => usePlaceFileUpload(5, upload, toast)); + const input = { value: 'picked' } as HTMLInputElement; + result.current.fileInputRef.current = input; + + await act(async () => { + await result.current.handleUpload(pick([new File(['a'], 'a.pdf'), new File(['b'], 'b.pdf')]).event); + }); + + expect(upload).toHaveBeenCalledTimes(2); + const fd = upload.mock.calls[1][0]; + expect((fd.get('file') as File).name).toBe('b.pdf'); + expect(fd.get('place_id')).toBe('5'); + expect(result.current.filesExpanded).toBe(true); + expect(result.current.uploading).toBe(false); + expect(input.value).toBe(''); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-PLACEACT-009: holds the uploading flag while a file is on its way', async () => { + let finish: () => void = () => undefined; + const upload = vi.fn(() => new Promise((resolve) => (finish = resolve))); + const { result } = renderHook(() => usePlaceFileUpload(5, upload, { error: vi.fn() })); + + let running: Promise = Promise.resolve(); + act(() => { + running = result.current.handleUpload(pick([new File(['a'], 'a.pdf')]).event); + }); + expect(result.current.uploading).toBe(true); + + await act(async () => { + finish(); + await running; + }); + expect(result.current.uploading).toBe(false); + }); + + it('FE-PLANNER-PLACEACT-010: a failed upload keeps the list as it was and says so; only the desktop logs it', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const upload = vi.fn(async () => { + throw new Error('too large'); + }); + const toast = { error: vi.fn() }; + + const phone = renderHook(() => usePlaceFileUpload(5, upload, toast)); + await act(async () => { + await phone.result.current.handleUpload(pick([new File(['a'], 'a.pdf')]).event); + }); + // The default translation echoes the key, so the server's text is no key and the fallback is used. + expect(toast.error).toHaveBeenCalledWith('files.uploadError'); + expect(phone.result.current.filesExpanded).toBe(false); + expect(phone.result.current.uploading).toBe(false); + expect(consoleError).not.toHaveBeenCalled(); + + const desktop = renderHook(() => usePlaceFileUpload(5, upload, toast, true)); + await act(async () => { + await desktop.result.current.handleUpload(pick([new File(['a'], 'a.pdf')]).event); + }); + expect(consoleError).toHaveBeenCalledWith('Upload failed', expect.any(Error)); + }); + + it('FE-PLANNER-PLACEACT-011: without a place, an upload or a file the pick does nothing', async () => { + const upload = vi.fn(async () => undefined); + const noPlace = renderHook(() => usePlaceFileUpload(null, upload, { error: vi.fn() })); + const noUpload = renderHook(() => usePlaceFileUpload(5, undefined, { error: vi.fn() })); + const nothingPicked = renderHook(() => usePlaceFileUpload(5, upload, { error: vi.fn() })); + await act(async () => { + await noPlace.result.current.handleUpload(pick([new File(['a'], 'a.pdf')]).event); + await noUpload.result.current.handleUpload(pick([new File(['a'], 'a.pdf')]).event); + await nothingPicked.result.current.handleUpload(pick([]).event); + }); + expect(upload).not.toHaveBeenCalled(); + expect(noUpload.result.current.uploading).toBe(false); + expect(nothingPicked.result.current.filesExpanded).toBe(false); + }); + + it('FE-PLANNER-PLACEACT-012: the list can be opened and closed by hand', () => { + const { result } = renderHook(() => usePlaceFileUpload(5, vi.fn(), { error: vi.fn() })); + act(() => result.current.setFilesExpanded(true)); + expect(result.current.filesExpanded).toBe(true); + act(() => result.current.setFilesExpanded((v) => !v)); + expect(result.current.filesExpanded).toBe(false); + }); +}); + +describe('usePlaceImagePick', () => { + it('FE-PLANNER-PLACEACT-013: converts the picked picture, uploads it and clears the input', async () => { + const upload = vi.fn(async (_file: File) => undefined); + const toast = { error: vi.fn() }; + const { result } = renderHook(() => usePlaceImagePick(upload, toast)); + const { event, target } = pick([new File(['x'], 'photo.heic')]); + + await act(async () => { + await result.current.pickImage(event); + }); + + expect(normalizeImageFile).toHaveBeenCalledTimes(1); + expect(upload.mock.calls[0][0].name).toBe('normalized-photo.heic'); + expect(target.value).toBe(''); + expect(result.current.busy).toBe(false); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-PLACEACT-014: a refused picture is reported and the busy flag drops again', async () => { + let fail: (err: Error) => void = () => undefined; + const upload = vi.fn(() => new Promise((_resolve, reject) => (fail = reject))); + const toast = { error: vi.fn() }; + const { result } = renderHook(() => usePlaceImagePick(upload, toast)); + + let running: Promise = Promise.resolve(); + act(() => { + running = result.current.pickImage(pick([new File(['x'], 'a.png')]).event); + }); + expect(result.current.busy).toBe(true); + await waitFor(() => expect(upload).toHaveBeenCalled()); + + await act(async () => { + fail(new Error('unsupported')); + await running; + }); + expect(toast.error).toHaveBeenCalledWith('places.imageUploadError'); + expect(result.current.busy).toBe(false); + }); + + it('FE-PLANNER-PLACEACT-015: without an upload or a file the pick only clears the input', async () => { + const upload = vi.fn(async () => undefined); + const noUpload = renderHook(() => usePlaceImagePick(undefined, { error: vi.fn() })); + const nothingPicked = renderHook(() => usePlaceImagePick(upload, { error: vi.fn() })); + const first = pick([new File(['x'], 'a.png')]); + const second = pick([]); + await act(async () => { + await noUpload.result.current.pickImage(first.event); + await nothingPicked.result.current.pickImage(second.event); + }); + expect(first.target.value).toBe(''); + expect(second.target.value).toBe(''); + expect(upload).not.toHaveBeenCalled(); + expect(normalizeImageFile).not.toHaveBeenCalled(); + act(() => noUpload.result.current.setBusy(true)); + expect(noUpload.result.current.busy).toBe(true); + }); +}); diff --git a/client/src/components/Planner/usePlaceActions.ts b/client/src/components/Planner/usePlaceActions.ts new file mode 100644 index 000000000..62ff6f9ca --- /dev/null +++ b/client/src/components/Planner/usePlaceActions.ts @@ -0,0 +1,170 @@ +import { type ChangeEvent, useCallback, useRef, useState } from 'react'; + +import { assignmentsApi } from '../../api/client'; +import { translateApiError, useTranslation } from '../../i18n'; +import type { TripStoreState } from '../../store/tripStore'; +import { useTripStore } from '../../store/tripStore'; +import type { Place } from '../../types'; +import { normalizeImageFile } from '../../utils/convertHeic'; + +type Translate = (key: string, params?: Record) => string; + +/** The part of a toast both shells hand in, theirs or the planner's. */ +export interface PlaceActionToast { + error: (message: string) => void; +} + +/** Who joins a stop: nobody set means everyone, so every member is active and nobody is left to add. */ +export function splitParticipants( + members: M[], + participantIds: number[], + allJoined: boolean +) { + return { + activeMembers: allJoined ? members : members.filter((m) => participantIds.includes(m.id)), + availableMembers: allJoined ? [] : members.filter((m) => !participantIds.includes(m.id)), + }; +} + +/** The participant list with one member taken off; the whole trip again is stored as nobody. */ +export function participantsWithout( + members: M[], + participantIds: number[], + allJoined: boolean, + userId: number +): number[] { + const next = allJoined + ? members.filter((m) => m.id !== userId).map((m) => m.id) + : participantIds.filter((id) => id !== userId); + return next.length === members.length ? [] : next; +} + +/** The participant list with one member added; the whole trip again is stored as nobody. */ +export function participantsWith( + members: M[], + participantIds: number[], + userId: number +): number[] { + const next = [...participantIds, userId]; + return next.length === members.length ? [] : next; +} + +export interface PlaceActionDeps { + tripId: number; + tripActions: Pick; + toast: PlaceActionToast; + t: Translate; +} + +/** + * The place writes the desktop inspector and the phone sheet both make. Each one + * reports a failure with the error's own message. + */ +export function placeActions({ tripId, tripActions, toast, t }: PlaceActionDeps) { + const report = (err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')); + + /** Who joins the assignment; the answer is written back into that day's list. */ + const setParticipants = async (assignmentId: number, dayId: number, userIds: number[]) => { + try { + const data = await assignmentsApi.setParticipants(tripId, assignmentId, userIds); + useTripStore.setState((state) => ({ + assignments: { + ...state.assignments, + [String(dayId)]: (state.assignments[String(dayId)] || []).map((a) => + a.id === assignmentId ? { ...a, participants: data.participants } : a + ), + }, + })); + } catch (err: unknown) { + report(err); + } + }; + + /** Collaborative rating (#1435): every trip member casts their own star vote. */ + const ratePlace = async (placeId: number, rating: number | null) => { + try { + await tripActions.ratePlace(tripId, placeId, rating); + } catch (err: unknown) { + report(err); + } + }; + + const updatePlace = async (placeId: number, data: Partial) => { + try { + await tripActions.updatePlace(tripId, placeId, data); + } catch (err: unknown) { + report(err); + } + }; + + return { setParticipants, ratePlace, updatePlace }; +} + +/** + * The files picked for a place, uploaded one after the other against it, and the + * list opened once they are in. Without a place or an upload the pick does nothing. + * `logFailure` keeps the console line the desktop inspector has always written. + */ +export function usePlaceFileUpload( + placeId: number | null | undefined, + upload: ((fd: FormData) => Promise) | undefined, + toast: PlaceActionToast, + logFailure = false +) { + const { t } = useTranslation(); + const [uploading, setUploading] = useState(false); + const [filesExpanded, setFilesExpanded] = useState(false); + const fileInputRef = useRef(null); + + const handleUpload = useCallback( + async (e: ChangeEvent) => { + const selected = Array.from(e.target.files || []); + if (!selected.length || !upload || !placeId) return; + setUploading(true); + try { + for (const file of selected) { + const fd = new FormData(); + fd.append('file', file); + fd.append('place_id', String(placeId)); + await upload(fd); + } + setFilesExpanded(true); + } catch (err: unknown) { + if (logFailure) console.error('Upload failed', err); + toast.error(translateApiError(t, err, 'files.uploadError')); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }, + [upload, placeId, toast, t, logFailure] + ); + + return { uploading, filesExpanded, setFilesExpanded, fileInputRef, handleUpload }; +} + +/** + * A picture picked as a place's image (#1136): converted from HEIC where needed, + * uploaded, and a failure said in a toast. `busy` also covers whatever else the + * caller does to the image while it runs. Without an upload the pick does nothing. + */ +export function usePlaceImagePick(upload: ((file: File) => Promise) | undefined, toast: PlaceActionToast) { + const { t } = useTranslation(); + const [busy, setBusy] = useState(false); + + const pickImage = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file || !upload) return; + setBusy(true); + try { + await upload(await normalizeImageFile(file)); + } catch (err: unknown) { + toast.error(translateApiError(t, err, 'places.imageUploadError')); + } finally { + setBusy(false); + } + }; + + return { busy, setBusy, pickImage }; +} diff --git a/client/src/components/shared/PlaceAvatarUpload.tsx b/client/src/components/shared/PlaceAvatarUpload.tsx index 5946ce896..559d41758 100644 --- a/client/src/components/shared/PlaceAvatarUpload.tsx +++ b/client/src/components/shared/PlaceAvatarUpload.tsx @@ -3,10 +3,10 @@ import { createPortal } from 'react-dom' import { Camera, Loader2, Upload, X } from 'lucide-react' import PlaceAvatar from './PlaceAvatar' import { Tooltip } from './Tooltip' -import { normalizeImageFile } from '../../utils/convertHeic' import { useToast } from './Toast' import { useTranslation, translateApiError } from '../../i18n' import { fetchImageAsBlob } from '../../api/authUrl' +import { usePlaceImagePick } from '../Planner/usePlaceActions' import type { Place, TripFile } from '../../types' interface Category { @@ -43,7 +43,7 @@ export default function PlaceAvatarUpload({ place, category, size = 52, onUpload const toast = useToast() const fileRef = useRef(null) const anchorRef = useRef(null) - const [busy, setBusy] = useState(false) + const { busy, setBusy, pickImage: handleFile } = usePlaceImagePick(onUpload, toast) const [chooserOpen, setChooserOpen] = useState(false) const hasCustom = Boolean(place.image_url) const offersAttached = !!onPickAttached && attachedImages.length > 0 @@ -62,20 +62,6 @@ export default function PlaceAvatarUpload({ place, category, size = 52, onUpload } } - const handleFile = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - e.target.value = '' - if (!file) return - setBusy(true) - try { - await onUpload(await normalizeImageFile(file)) - } catch (err: unknown) { - toast.error(translateApiError(t, err, 'places.imageUploadError')) - } finally { - setBusy(false) - } - } - const handleRemove = async (e: React.MouseEvent) => { e.stopPropagation() setBusy(true) diff --git a/client/src/mobile/screens/trip/lib/collectionTarget.ts b/client/src/mobile/screens/trip/lib/collectionTarget.ts deleted file mode 100644 index 9297563f8..000000000 --- a/client/src/mobile/screens/trip/lib/collectionTarget.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { SaveToCollectionTarget } from '../../../../store/saveToCollectionStore' -import type { Place } from '../../../../types' - -/** The save-to-collection picker payload for a trip pool place (provenance + maps identity). */ -export function collectionTargetFromPlace(place: Place): SaveToCollectionTarget { - return { - name: place.name, - source_trip_id: place.trip_id ?? null, - source_place_id: place.id, - description: place.description ?? null, - lat: place.lat ?? null, - lng: place.lng ?? null, - address: place.address ?? null, - category_id: place.category_id ?? null, - price: place.price ?? null, - currency: place.currency ?? null, - notes: place.notes ?? null, - image_url: place.image_url ?? null, - google_place_id: place.google_place_id ?? null, - google_ftid: place.google_ftid ?? null, - osm_id: place.osm_id ?? null, - website: place.website ?? null, - phone: place.phone ?? null, - } -} diff --git a/client/src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx b/client/src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx index 86d11974d..620ba04b3 100644 --- a/client/src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx @@ -5,7 +5,7 @@ import type { MTripSheetsProps } from '../MTripShell' import { useTranslation } from '../../../../i18n' import { useAddonStore } from '../../../../store/addonStore' import { useSaveToCollectionStore } from '../../../../store/saveToCollectionStore' -import { collectionTargetFromPlace } from '../lib/collectionTarget' +import { placeToSaveTarget } from '../../../../components/Collections/saveTarget' import type { Place } from '../../../../types' interface BrowseActionsPayload { @@ -55,7 +55,7 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps const saveToCollection = () => { shell.closeSheet() - openSavePicker(collectionTargetFromPlace(place)) + openSavePicker(placeToSaveTarget(place)) } const tourAlreadyAssignedToDay = (dayId: number) => isTourPlace(place.id) diff --git a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx index 52ef62473..8165063bc 100644 --- a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx @@ -7,12 +7,12 @@ import { import MSheet from '../../../components/MSheet' import type { MTripSheetsProps } from '../MTripShell' import { useTranslation, translateApiError } from '../../../../i18n' -import { normalizeImageFile } from '../../../../utils/convertHeic' -import { assignmentsApi } from '../../../../api/client' -import { useTripStore } from '../../../../store/tripStore' import { useAddonStore } from '../../../../store/addonStore' import { useSaveToCollectionStore } from '../../../../store/saveToCollectionStore' -import { collectionTargetFromPlace } from '../lib/collectionTarget' +import { placeToSaveTarget } from '../../../../components/Collections/saveTarget' +import { + participantsWith, participantsWithout, placeActions, splitParticipants, usePlaceFileUpload, usePlaceImagePick, +} from '../../../../components/Planner/usePlaceActions' import { getCategoryIcon } from '../../../../components/shared/categoryIcons' import PlaceRating from '../../../../components/shared/StarRating' import MarkdownText from '../../../../components/shared/MarkdownText' @@ -50,15 +50,18 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) { const collectionsEnabled = useAddonStore(s => s.isEnabled('collections')) const openSavePicker = useSaveToCollectionStore(s => s.open) - const [filesExpanded, setFilesExpanded] = useState(false) + const actions = placeActions({ tripId: planner.tripId, tripActions: planner.tripActions, toast: planner.toast, t }) + const { + uploading, filesExpanded, setFilesExpanded, fileInputRef, handleUpload, + } = usePlaceFileUpload(place && !isTourPlace ? place.id : null, fd => planner.tripActions.addFile(planner.tripId, fd), planner.toast) const [dayPickerOpen, setDayPickerOpen] = useState(false) const [participantPickerOpen, setParticipantPickerOpen] = useState(false) - const [uploading, setUploading] = useState(false) const [navOpen, setNavOpen] = useState(false) const navBtnRef = useRef(null) - const [imgBusy, setImgBusy] = useState(false) + const { + busy: imgBusy, setBusy: setImgBusy, pickImage: handleImagePick, + } = usePlaceImagePick(place && !isTourPlace ? file => planner.tripActions.uploadPlaceImage(planner.tripId, place.id, file) : undefined, planner.toast) const [colorPickerOpen, setColorPickerOpen] = useState(false) - const fileInputRef = useRef(null) const imageInputRef = useRef(null) const close = () => { @@ -127,36 +130,19 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) { const participantIds = participants.map(p => p.user_id) const allJoined = participants.length === 0 const members = planner.tripMembers as TripMember[] - const activeMembers = allJoined ? members : members.filter(m => participantIds.includes(m.id)) - const availableMembers = allJoined ? [] : members.filter(m => !participantIds.includes(m.id)) + const { activeMembers, availableMembers } = splitParticipants(members, participantIds, allJoined) const setParticipants = async (userIds: number[]) => { if (!assignmentInDay || !planner.selectedDayId) return - const dayId = planner.selectedDayId - try { - const data = await assignmentsApi.setParticipants(planner.tripId, assignmentInDay.id, userIds) - useTripStore.setState(state => ({ - assignments: { - ...state.assignments, - [String(dayId)]: (state.assignments[String(dayId)] || []).map(a => - a.id === assignmentInDay.id ? { ...a, participants: data.participants } : a, - ), - }, - })) - } catch (err: unknown) { - planner.toast.error(err instanceof Error ? err.message : t('common.unknownError')) - } + await actions.setParticipants(assignmentInDay.id, planner.selectedDayId, userIds) } const removeParticipant = (userId: number) => { - let next = allJoined ? members.filter(m => m.id !== userId).map(m => m.id) : participantIds.filter(id => id !== userId) - if (next.length === members.length) next = [] - void setParticipants(next) + void setParticipants(participantsWithout(members, participantIds, allJoined, userId)) } const addParticipant = (userId: number) => { - const next = [...participantIds, userId] - void setParticipants(next.length === members.length ? [] : next) + void setParticipants(participantsWith(members, participantIds, userId)) setParticipantPickerOpen(false) } @@ -168,20 +154,6 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) { assignmentInDay ? [assignmentInDay.id] : [], ) - const handleImagePick = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - e.target.value = '' - if (!file || !place || isTourPlace) return - setImgBusy(true) - try { - await planner.tripActions.uploadPlaceImage(planner.tripId, place.id, await normalizeImageFile(file)) - } catch (err: unknown) { - planner.toast.error(translateApiError(t, err, 'places.imageUploadError')) - } finally { - setImgBusy(false) - } - } - const handleTrackColor = async (color: string | null) => { if (!place || isTourPlace) return try { @@ -203,39 +175,15 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) { } } - const handleUpload = async (e: React.ChangeEvent) => { - const selected = Array.from(e.target.files || []) - if (!selected.length || !place || isTourPlace) return - setUploading(true) - try { - for (const file of selected) { - const fd = new FormData() - fd.append('file', file) - fd.append('place_id', String(place.id)) - await planner.tripActions.addFile(planner.tripId, fd) - } - setFilesExpanded(true) - } catch (err: unknown) { - planner.toast.error(translateApiError(t, err, 'files.uploadError')) - } finally { - setUploading(false) - if (fileInputRef.current) fileInputRef.current.value = '' - } - } - const saveToCollection = () => { if (!place) return - openSavePicker(collectionTargetFromPlace(place)) + openSavePicker(placeToSaveTarget(place)) } // Collaborative rating (#1435): every trip member casts their own star vote. const handleRate = async (rating: number | null) => { if (!place) return - try { - await planner.tripActions.ratePlace(planner.tripId, place.id, rating) - } catch (err: unknown) { - planner.toast.error(err instanceof Error ? err.message : t('common.unknownError')) - } + await actions.ratePlace(place.id, rating) } const showOnMap = () => { diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 6f197c1b9..95a211db5 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -38,7 +38,7 @@ import HelpAnchor from '../components/Help/HelpAnchor' import { getHelpContext } from '../help/registry' import { useToast } from '../components/shared/Toast' import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, Wallet, FolderOpen, Users, Train } from 'lucide-react' -import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client' +import { addonsApi, accommodationsApi, authApi, tripsApi, mapsApi } from '../api/client' import { accommodationRepo } from '../repo/accommodationRepo' import { useAuthStore } from '../store/authStore' import ConfirmDialog from '../components/shared/ConfirmDialog' @@ -143,6 +143,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { } = pageState.planner const { poi, + placeActions, tourPlanner, permissions: { canPlaceEdit, canDayEdit }, tourDetails, @@ -591,23 +592,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null { files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} tripMembers={tripMembers} - onSetParticipants={async (assignmentId, dayId, userIds) => { - try { - const data = await assignmentsApi.setParticipants(tripId, assignmentId, userIds) - useTripStore.setState(state => ({ - assignments: { - ...state.assignments, - [String(dayId)]: (state.assignments[String(dayId)] || []).map(a => - a.id === assignmentId ? { ...a, participants: data.participants } : a - ), - } - })) - } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } - }} - onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }} + onSetParticipants={placeActions.setParticipants} + onUpdatePlace={placeActions.updatePlace} onUploadImage={async (placeId, file) => { await tripActions.uploadPlaceImage(tripId, placeId, file) }} onImageFromFile={async (placeId, fileId) => { await tripActions.setPlaceImageFromFile(tripId, placeId, fileId) }} - onRate={async (placeId, rating) => { try { await tripActions.ratePlace(tripId, placeId, rating) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }} + onRate={placeActions.ratePlace} leftWidth={isMobile ? 0 : leftPanelPx} rightWidth={isMobile ? 0 : rightPanelPx} /> @@ -636,23 +625,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null { files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} tripMembers={tripMembers} - onSetParticipants={async (assignmentId, dayId, userIds) => { - try { - const data = await assignmentsApi.setParticipants(tripId, assignmentId, userIds) - useTripStore.setState(state => ({ - assignments: { - ...state.assignments, - [String(dayId)]: (state.assignments[String(dayId)] || []).map(a => - a.id === assignmentId ? { ...a, participants: data.participants } : a - ), - } - })) - } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } - }} - onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }} + onSetParticipants={placeActions.setParticipants} + onUpdatePlace={placeActions.updatePlace} onUploadImage={async (placeId, file) => { await tripActions.uploadPlaceImage(tripId, placeId, file) }} onImageFromFile={async (placeId, fileId) => { await tripActions.setPlaceImageFromFile(tripId, placeId, fileId) }} - onRate={async (placeId, rating) => { try { await tripActions.ratePlace(tripId, placeId, rating) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }} + onRate={placeActions.ratePlace} leftWidth={0} rightWidth={0} /> diff --git a/client/src/pages/tripPlanner/useTripPlannerPage.ts b/client/src/pages/tripPlanner/useTripPlannerPage.ts index e309b94be..964d00150 100644 --- a/client/src/pages/tripPlanner/useTripPlannerPage.ts +++ b/client/src/pages/tripPlanner/useTripPlannerPage.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { TourListItem } from '@trek/shared' import { usePoiExplore, type Bbox } from '../../components/Map/usePoiExplore' +import { placeActions } from '../../components/Planner/usePlaceActions' import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner' import { useTripPlanner } from './useTripPlanner' @@ -101,6 +102,8 @@ export function useTripPlannerPage() { return { planner, poi, + // The place inspector's writes, the same ones the phone's place sheet makes. + placeActions: placeActions({ tripId, tripActions, toast, t }), permissions: { canPlaceEdit, canDayEdit }, tourPlanner, tourDetails: { openerRef: tourDetailOpenerRef, onSelectTour },