mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
bdd8c08dd4
The day plan wrote straight to the API, so offline a place dropped on a day snapped back with "Error assigning place", a renamed day or a new day note failed, and a moved stop, a leg's travel mode or who joins a visit were lost. Visits (put on a day, taken off, reordered, moved, travellers, leg mode), the day's name, notes text and travel mode, and the day notes (add, edit, delete, move) now go through assignmentRepo, dayRepo and dayNoteRepo and the mutation queue: the cached day changes at once, a visit or note made offline gets a temporary id that the edits after it wait for, and an online write held behind an older queued one joins the queue, since none of these carry a version token. A new order of a day that names a visit made offline is sent with its real id, or without it when its create failed. Desktop, the phone timeline and note sheet all write through the store, so both are covered. The replay reads each answer for what it names: the day answer keeps the cached notes and visits, the participants answer lands on its visit, and a visit the server refuses with 409 (a Tour already on that day) fails instead of waiting as a conflict no retry can solve. The trip's name, description, reminder and the archive flag wait in the queue too, from the planner, the phone and the dashboard. What only the server can do says so instead of failing: new dates, a day count, a currency or a cover, a new trip, a copy or a delete, members, guests, share and invite links, and a new order of the days, which moves the dates of the bookings. Refs #2601
162 lines
5.5 KiB
TypeScript
162 lines
5.5 KiB
TypeScript
import { type ChangeEvent, useCallback, useRef, useState } from 'react';
|
|
|
|
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 {
|
|
await useTripStore.getState().setAssignmentParticipants(tripId, dayId, assignmentId, userIds);
|
|
} 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 };
|
|
}
|