refactor(planner): share the place detail actions between desktop and phone

The place inspector and the phone place sheet now run on one module for
their participants, file uploads, image picks, ratings and place updates,
and both build the save to collection target with placeToSaveTarget.
This commit is contained in:
Maurice
2026-10-08 20:53:55 +00:00
parent 8bd831238d
commit 34127b99bb
9 changed files with 480 additions and 203 deletions
@@ -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<HTMLInputElement>(null)
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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)
}
@@ -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<HTMLInputElement>, 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<void>((resolve) => (finish = resolve)));
const { result } = renderHook(() => usePlaceFileUpload(5, upload, { error: vi.fn() }));
let running: Promise<void> = 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<void>((_resolve, reject) => (fail = reject)));
const toast = { error: vi.fn() };
const { result } = renderHook(() => usePlaceImagePick(upload, toast));
let running: Promise<void> = 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);
});
});
@@ -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, string | number>) => 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<M extends { id: number }>(
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<M extends { id: number }>(
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<M extends { id: number }>(
members: M[],
participantIds: number[],
userId: number
): number[] {
const next = [...participantIds, userId];
return next.length === members.length ? [] : next;
}
export interface PlaceActionDeps {
tripId: number;
tripActions: Pick<TripStoreState, 'ratePlace' | 'updatePlace'>;
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<Place>) => {
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<unknown>) | undefined,
toast: PlaceActionToast,
logFailure = false
) {
const { t } = useTranslation();
const [uploading, setUploading] = useState(false);
const [filesExpanded, setFilesExpanded] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const handleUpload = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
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<unknown>) | undefined, toast: PlaceActionToast) {
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const pickImage = async (e: ChangeEvent<HTMLInputElement>) => {
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 };
}
@@ -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<HTMLInputElement>(null)
const anchorRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
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)
@@ -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,
}
}
@@ -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)
@@ -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<HTMLButtonElement>(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<HTMLInputElement | null>(null)
const imageInputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 = () => {
+8 -31
View File
@@ -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}
/>
@@ -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 },