diff --git a/client/src/components/Collab/CollabLinks.tsx b/client/src/components/Collab/CollabLinks.tsx index d190bb961..d1b4d694f 100644 --- a/client/src/components/Collab/CollabLinks.tsx +++ b/client/src/components/Collab/CollabLinks.tsx @@ -14,6 +14,7 @@ import ConfirmDialog from '../shared/ConfirmDialog' import EmptyState from '../shared/EmptyState' import { Tooltip } from '../shared/Tooltip' import { useToast } from '../shared/Toast' +import { offlineOr } from '../../utils/needsConnection' const FONT = "var(--font-system)" @@ -218,7 +219,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) { setLinks(v => [d.link, ...v]) } catch (err) { console.error('Failed to add collab link:', err) - toast.error(t('common.error')) + toast.error(offlineOr(t, t('common.error'))) throw err } } @@ -229,7 +230,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) { setLinks(v => v.map(x => x.id === link.id ? d.link : x).sort((a, b) => Number(b.pinned) - Number(a.pinned))) } catch (err) { console.error('Failed to pin collab link:', err) - toast.error(t('common.error')) + toast.error(offlineOr(t, t('common.error'))) } } @@ -239,7 +240,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) { setLinks(v => v.map(x => x.id === link.id ? d.link : x)) } catch (err) { console.error('Failed to edit collab link:', err) - toast.error(t('common.error')) + toast.error(offlineOr(t, t('common.error'))) throw err } } @@ -250,7 +251,7 @@ export default function CollabLinks({ tripId }: { tripId: number }) { setLinks(v => v.filter(x => x.id !== id)) } catch (err) { console.error('Failed to delete collab link:', err) - toast.error(t('common.error')) + toast.error(offlineOr(t, t('common.error'))) } } diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx index 1eb336f2a..2f59fb3c5 100644 --- a/client/src/components/Collab/CollabNotes.tsx +++ b/client/src/components/Collab/CollabNotes.tsx @@ -26,6 +26,7 @@ import { FilePreviewPortal } from './CollabNotesFilePreviewPortal' import { AuthedImg } from './CollabNotesAuthedImg' import { safeExternalHref } from '../../utils/safeUrl' import { useIsPhone } from '../../mobile/useIsPhone' +import { offlineOr } from '../../utils/needsConnection' // ── Main Component ────────────────────────────────────────────────────────── interface CollabNotesProps { @@ -88,7 +89,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) { try { created = await collabApi.createNote(tripId, data) } catch (err) { - toast.error(t('common.error')) + toast.error(offlineOr(t, t('common.error'))) throw err } if (created) { @@ -114,7 +115,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) { result = await collabApi.updateNote(tripId, noteId, data) } catch (err) { // A batch of writes reports once for the whole run instead of once per note. - if (!opts.silent) toast.error(t('common.error')) + if (!opts.silent) toast.error(offlineOr(t, t('common.error'))) throw err } const updated = result?.note || result @@ -168,7 +169,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) { // settings modal has to stay open on the rejection instead of closing on it. if (failed > 0) { await resyncNotes() - toast.error(t('common.error')) + toast.error(offlineOr(t, t('common.error'))) throw new Error(`rename failed for ${failed} of ${toUpdate.length} notes`) } }, [notes, handleUpdateNote, resyncNotes, toast, t]) @@ -186,7 +187,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) { }, [editingNote, tripId, handleUpdateNote, setNotes, uploadNoteFiles]) const handleDeleteNoteFile = useCallback(async (noteId, fileId) => { - try { await collabApi.deleteNoteFile(tripId, noteId, fileId) } catch { toast.error(t('common.error')) } + try { await collabApi.deleteNoteFile(tripId, noteId, fileId) } catch { toast.error(offlineOr(t, t('common.error'))) } announceNoteFilesChanged() }, [tripId, toast, t]) @@ -194,7 +195,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) { try { await collabApi.deleteNote(tripId, noteId) } catch (err) { - toast.error(t('common.error')) + toast.error(offlineOr(t, t('common.error'))) throw err } setNotes(prev => prev.filter(n => n.id !== noteId)) diff --git a/client/src/components/Collab/useCollabChatData.ts b/client/src/components/Collab/useCollabChatData.ts index b6cb038c5..bad7e585d 100644 --- a/client/src/components/Collab/useCollabChatData.ts +++ b/client/src/components/Collab/useCollabChatData.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'reac import { collabApi } from '../../api/client'; import { addListener, removeListener } from '../../api/websocket'; import type { useTranslation } from '../../i18n'; +import { offlineOr } from '../../utils/needsConnection'; import type { useToast } from '../shared/Toast'; import type { ChatMessage, ChatReaction } from './collabModel'; import { MAX_CHAT_IMAGES, useChatImages } from './useChatImages'; @@ -143,7 +144,7 @@ export function useCollabChatData({ }); } } catch { - if (toastLoadMoreErrors) toast.error(t('common.error')); + if (toastLoadMoreErrors) toast.error(offlineOr(t, t('common.error'))); } finally { setLoadingMore(false); } @@ -204,7 +205,7 @@ export function useCollabChatData({ isAtBottom.current = true; setTimeout(() => scrollToBottom('smooth'), 50); } catch { - toast.error(t('common.error')); + toast.error(offlineOr(t, t('common.error'))); } finally { setSending(false); setUploadProgress(0); @@ -218,7 +219,7 @@ export function useCollabChatData({ await collabApi.deleteMessage(tripId, msgId); setMessages((prev) => prev.map((m) => (m.id === msgId ? { ...m, _deleted: true } : m))); } catch { - toast.error(t('common.error')); + toast.error(offlineOr(t, t('common.error'))); } }, [tripId, toast, t] @@ -230,7 +231,7 @@ export function useCollabChatData({ const data = (await collabApi.reactMessage(tripId, msgId, emoji)) as { reactions: ChatReaction[] }; setMessages((prev) => prev.map((m) => (m.id === msgId ? { ...m, reactions: data.reactions } : m))); } catch { - toast.error(t('common.error')); + toast.error(offlineOr(t, t('common.error'))); } }, [tripId, toast, t] diff --git a/client/src/components/Collab/useCollabNotesData.ts b/client/src/components/Collab/useCollabNotesData.ts index bffc28e59..1505ef50f 100644 --- a/client/src/components/Collab/useCollabNotesData.ts +++ b/client/src/components/Collab/useCollabNotesData.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { collabApi } from '../../api/client'; import { addListener, removeListener } from '../../api/websocket'; import type { useTranslation } from '../../i18n'; +import { offlineOr } from '../../utils/needsConnection'; import type { useToast } from '../shared/Toast'; import type { CollabNoteData } from './collabModel'; @@ -97,7 +98,7 @@ export function useCollabNotesData({ await collabApi.uploadNoteFile(tripId, noteId, fd); } catch (err) { onFileError?.(err); - toast.error(t('common.error')); + toast.error(offlineOr(t, t('common.error'))); } } if (files.length > 0) announceNoteFilesChanged(); diff --git a/client/src/components/Collab/useCollabPolls.ts b/client/src/components/Collab/useCollabPolls.ts index e188b4812..d9fc5df0d 100644 --- a/client/src/components/Collab/useCollabPolls.ts +++ b/client/src/components/Collab/useCollabPolls.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react'; import { collabApi } from '../../api/client'; import { addListener, removeListener } from '../../api/websocket'; import type { useTranslation } from '../../i18n'; +import { offlineOr } from '../../utils/needsConnection'; import type { useToast } from '../shared/Toast'; import { isPollActive, type CollabPollData } from './collabModel'; @@ -111,7 +112,7 @@ export function useCollabPolls({ const created = readPoll(await collabApi.createPoll(tripId, data)); setPolls((prev) => (prev.some((p) => p.id === created.id) ? prev : [created, ...prev])); } catch (err) { - toast.error(t('common.error')); + toast.error(offlineOr(t, t('common.error'))); throw err; } }, @@ -125,7 +126,7 @@ export function useCollabPolls({ const target = matchVoteByPollId ? pollId : updated.id; setPolls((prev) => prev.map((p) => (p.id === target ? updated : p))); } catch { - toast.error(t('common.error')); + toast.error(offlineOr(t, t('common.error'))); } }, [tripId, matchVoteByPollId, toast, t] @@ -142,7 +143,7 @@ export function useCollabPolls({ }) ); } catch { - toast.error(t('common.error')); + toast.error(offlineOr(t, t('common.error'))); } }, [tripId, replaceClosedPoll, toast, t] @@ -154,7 +155,7 @@ export function useCollabPolls({ await collabApi.deletePoll(tripId, pollId); setPolls((prev) => prev.filter((p) => p.id !== pollId)); } catch { - toast.error(t('common.error')); + toast.error(offlineOr(t, t('common.error'))); } }, [tripId, toast, t] diff --git a/client/src/components/Packing/ApplyTemplateButton.tsx b/client/src/components/Packing/ApplyTemplateButton.tsx index 239137927..6b2cdc17e 100644 --- a/client/src/components/Packing/ApplyTemplateButton.tsx +++ b/client/src/components/Packing/ApplyTemplateButton.tsx @@ -5,6 +5,7 @@ import { useTripStore } from '../../store/tripStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { PackingTemplateOption } from './PackingTemplateOption' +import { offlineOr } from '../../utils/needsConnection' interface Template { id: number @@ -50,7 +51,7 @@ export default function ApplyTemplateButton({ tripId, visibility, style, classNa toast.success(t('packing.templateApplied', { count: data.count })) setOpen(false) } catch { - toast.error(t('packing.templateError')) + toast.error(offlineOr(t, t('packing.templateError'))) } finally { setApplying(false) } diff --git a/client/src/components/Packing/usePackingBags.ts b/client/src/components/Packing/usePackingBags.ts index caf8f54be..40a463b59 100644 --- a/client/src/components/Packing/usePackingBags.ts +++ b/client/src/components/Packing/usePackingBags.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react'; import { packingApi } from '../../api/client'; import { useNetworkMode } from '../../hooks/useNetworkMode'; import type { PackingBag } from '../../types'; +import { offlineOr } from '../../utils/needsConnection'; import { BAG_COLORS } from './packingListPanel.constants'; import { useBagTotalsPing } from './useBagTotalsPing'; @@ -74,7 +75,7 @@ export function usePackingBags({ setBags((prev) => [...prev, data.bag]); return { bag: data.bag }; } catch { - toast.error(t('packing.toast.saveError')); + toast.error(offlineOr(t, t('packing.toast.saveError'))); return null; } }; @@ -87,7 +88,7 @@ export function usePackingBags({ const result = await packingApi.updateBag(tripId, bagId, data); setBags((prev) => prev.map((b) => (b.id === bagId ? { ...b, ...result.bag } : b))); } catch { - toast.error(t('common.error')); + toast.error(offlineOr(t, t('common.error'))); } }; @@ -96,7 +97,7 @@ export function usePackingBags({ await packingApi.deleteBag(tripId, bagId); setBags((prev) => prev.filter((b) => b.id !== bagId)); } catch { - toast.error(t('packing.toast.deleteError')); + toast.error(offlineOr(t, t('packing.toast.deleteError'))); } }; @@ -105,7 +106,7 @@ export function usePackingBags({ const result = await packingApi.setBagMembers(tripId, bagId, userIds); setBags((prev) => prev.map((b) => (b.id === bagId ? { ...b, members: result.members } : b))); } catch { - toast.error(t('common.error')); + toast.error(offlineOr(t, t('common.error'))); } }; diff --git a/client/src/components/Packing/usePackingCategoryAssignees.ts b/client/src/components/Packing/usePackingCategoryAssignees.ts index 2159562ef..d3718ed12 100644 --- a/client/src/components/Packing/usePackingCategoryAssignees.ts +++ b/client/src/components/Packing/usePackingCategoryAssignees.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { packingApi } from '../../api/client'; +import { offlineOr } from '../../utils/needsConnection'; type Translate = (key: string, params?: Record) => string; interface Toaster { @@ -36,7 +37,7 @@ export function usePackingCategoryAssignees({ tripId, t, toast }: { tripId: numb const data = await packingApi.setCategoryAssignees(tripId, category, userIds); setCategoryAssignees((prev) => ({ ...prev, [category]: data.assignees || [] })); } catch { - toast.error(t('packing.toast.saveError')); + toast.error(offlineOr(t, t('packing.toast.saveError'))); } }; diff --git a/client/src/components/Packing/usePackingImport.ts b/client/src/components/Packing/usePackingImport.ts index fddc30e68..8c882bb9f 100644 --- a/client/src/components/Packing/usePackingImport.ts +++ b/client/src/components/Packing/usePackingImport.ts @@ -2,6 +2,7 @@ import { useMemo, useState, type ChangeEvent } from 'react'; import { packingApi } from '../../api/client'; import { useTripStore } from '../../store/tripStore'; +import { offlineOr } from '../../utils/needsConnection'; import { parseImportLines } from './packingListPanel.helpers'; type Translate = (key: string, params?: Record) => string; @@ -66,7 +67,7 @@ export function usePackingImport({ setText(''); onImported(); } catch { - toast.error(t('packing.importError')); + toast.error(offlineOr(t, t('packing.importError'))); } finally { if (oneAtATime) setImporting(false); } diff --git a/client/src/components/Planner/useListImport.ts b/client/src/components/Planner/useListImport.ts index 008b8f20d..8aade131b 100644 --- a/client/src/components/Planner/useListImport.ts +++ b/client/src/components/Planner/useListImport.ts @@ -3,6 +3,7 @@ import { useState } from 'react'; import { placesApi } from '../../api/client'; import type { useTranslation } from '../../i18n'; import { useAuthStore } from '../../store/authStore'; +import { offlineOr } from '../../utils/needsConnection'; import type { useToast } from '../shared/Toast'; export type ListImportProvider = 'google' | 'naver'; @@ -84,7 +85,9 @@ export function useListImport({ tripId, t, toast, loadTrip, pushUndo, onDone }: onDone?.(); } catch (err: unknown) { const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; - toast.error(message || t(provider === 'google' ? 'places.googleListError' : 'places.naverListError')); + toast.error( + message || offlineOr(t, t(provider === 'google' ? 'places.googleListError' : 'places.naverListError')) + ); } finally { setLoading(false); } diff --git a/client/src/components/Planner/usePlaceForm.ts b/client/src/components/Planner/usePlaceForm.ts index 20e8c8b1d..e97b96985 100644 --- a/client/src/components/Planner/usePlaceForm.ts +++ b/client/src/components/Planner/usePlaceForm.ts @@ -3,6 +3,7 @@ import { useRef, useState } from 'react'; import type { useTranslation } from '../../i18n'; import type { Category } from '../../types'; +import { offlineOr } from '../../utils/needsConnection'; import type { useToast } from '../shared/Toast'; import type { PlaceDetailsSelection } from './PlaceDetailsColumn'; import { @@ -174,7 +175,7 @@ export function useNewCategory({ t, toast, onCreated, variant, create }: NewCate setName(''); setOpen(false); } catch { - toast.error(t('places.categoryCreateError')); + toast.error(offlineOr(t, t('places.categoryCreateError'))); } finally { if (sheet) setSaving(false); } diff --git a/client/src/components/Planner/usePlacesFileImport.ts b/client/src/components/Planner/usePlacesFileImport.ts index b1f4fc470..7337de1f7 100644 --- a/client/src/components/Planner/usePlacesFileImport.ts +++ b/client/src/components/Planner/usePlacesFileImport.ts @@ -4,6 +4,7 @@ import { useEffect, useEffectEvent, useState } from 'react'; import { placesApi } from '../../api/client'; import type { useTranslation } from '../../i18n'; import { useAuthStore } from '../../store/authStore'; +import { offlineOr } from '../../utils/needsConnection'; import type { useToast } from '../shared/Toast'; export interface PlacesImportSummary { @@ -230,7 +231,8 @@ export function usePlacesFileImport({ } } catch (err: unknown) { const message = - (err as { response?: { data?: { error?: string } } })?.response?.data?.error || t('places.importFileError'); + (err as { response?: { data?: { error?: string } } })?.response?.data?.error || + offlineOr(t, t('places.importFileError')); errors.push(files.length > 1 ? `${f.name}: ${message}` : message); } } diff --git a/client/src/components/Planner/usePlacesPool.ts b/client/src/components/Planner/usePlacesPool.ts index ae9cbb313..71d9f7ddf 100644 --- a/client/src/components/Planner/usePlacesPool.ts +++ b/client/src/components/Planner/usePlacesPool.ts @@ -4,6 +4,7 @@ import { collectionsApi } from '../../api/collections'; import type { useTranslation } from '../../i18n'; import { useTripStore } from '../../store/tripStore'; import type { Place } from '../../types'; +import { offlineOr } from '../../utils/needsConnection'; import { useToast } from '../shared/Toast'; /** What the pool shows: everything, what is not on a day yet, what is, or the tracks. */ @@ -135,7 +136,7 @@ export function usePlacesPool({ tripId, places, poolPlaces, toursEnabled, t, sta else toast.success(t('collections.markedVisitedTrip', { count: matchedPlaces ?? 0 })); exitSelectMode(); } catch { - toast.error(t('common.error')); + toast.error(offlineOr(t, t('common.error'))); } finally { setMarkVisitedBusy(false); } diff --git a/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx b/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx index d70b47dde..3a849d960 100644 --- a/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx +++ b/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx @@ -22,6 +22,7 @@ import { type CollabNoteData, type CollabNoteFile, } from '../../../../components/Collab/collabModel' +import { offlineOr } from '../../../../utils/needsConnection' interface MCollabNotesProps { planner: TripPlanner @@ -77,7 +78,7 @@ export default function MCollabNotes({ planner }: MCollabNotesProps) { })) as NoteResponse created = res.note } catch { - toast.error(t('common.error')) + toast.error(offlineOr(t, t('common.error'))) throw new Error('create failed') } if (data.pendingFiles.length > 0) { @@ -99,7 +100,7 @@ export default function MCollabNotes({ planner }: MCollabNotesProps) { const res = (await collabApi.updateNote(tripId, noteId, data)) as NoteResponse updated = res.note } catch { - toast.error(t('common.error')) + toast.error(offlineOr(t, t('common.error'))) throw new Error('update failed') } if (pendingFiles.length > 0) { @@ -117,7 +118,7 @@ export default function MCollabNotes({ planner }: MCollabNotesProps) { setNotes(prev => prev.filter(n => n.id !== noteId)) announceNoteFilesChanged() } catch { - toast.error(t('common.error')) + toast.error(offlineOr(t, t('common.error'))) } }, [tripId, toast, t, setNotes]) @@ -132,7 +133,7 @@ export default function MCollabNotes({ planner }: MCollabNotesProps) { announceNoteFilesChanged() return true } catch { - toast.error(t('common.error')) + toast.error(offlineOr(t, t('common.error'))) return false } }, [tripId, toast, t, setNotes])