|
setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder={t('budget.newEntry')} style={inp} />
@@ -224,9 +225,9 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
|
- - |
- - |
- - |
+ - |
+ - |
+ - |
@@ -561,6 +562,7 @@ interface BudgetPanelProps {
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
const can = useCanDo()
+ const toast = useToast()
const { t, locale } = useTranslation()
const isDark = useIsDark()
const theme = useMemo(() => widgetTheme(isDark), [isDark])
@@ -625,21 +627,24 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
})).filter(s => s.value > 0)
, [grouped, categoryNames])
- const handleAddItem = async (category, data) => { try { await addBudgetItem(tripId, { ...data, category }) } catch {} }
- const handleUpdateField = async (id, field, value) => { try { await updateBudgetItem(tripId, id, { [field]: value }) } catch {} }
- const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} }
+ const handleAddItem = async (category, data) => { try { await addBudgetItem(tripId, { ...data, category }) } catch { toast.error(t('common.error')) } }
+ const handleUpdateField = async (id, field, value) => { try { await updateBudgetItem(tripId, id, { [field]: value }) } catch { toast.error(t('common.error')) } }
+ const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch { toast.error(t('common.error')) } }
const handleDeleteCategory = async (cat) => {
const items = grouped.get(cat) || []
- for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id)
+ try { for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id) }
+ catch { toast.error(t('common.error')) }
}
const handleRenameCategory = async (oldName, newName) => {
if (!newName.trim() || newName.trim() === oldName) return
const items = grouped.get(oldName) || []
- for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
+ try { for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) }
+ catch { toast.error(t('common.error')) }
}
const handleAddCategory = () => {
if (!newCategoryName.trim()) return
- addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 })
+ Promise.resolve(addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 }))
+ .catch(() => toast.error(t('common.error')))
setNewCategoryName('')
}
diff --git a/client/src/components/Collab/CollabChat.tsx b/client/src/components/Collab/CollabChat.tsx
index 2eb6395e..a5c7e2d4 100644
--- a/client/src/components/Collab/CollabChat.tsx
+++ b/client/src/components/Collab/CollabChat.tsx
@@ -7,6 +7,7 @@ import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
+import { useToast } from '../shared/Toast'
import type { User } from '../../types'
interface ChatReaction {
@@ -367,7 +368,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
{/* Composer */}
-
+
{/* Reply preview */}
{replyTo && (
s.settings.time_format) === '12h'
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
@@ -569,8 +571,8 @@ function useCollabChat(tripId: any, currentUser: any) {
if (textareaRef.current) textareaRef.current.style.height = 'auto'
isAtBottom.current = true
setTimeout(() => scrollToBottom('smooth'), 50)
- } catch {} finally { setSending(false) }
- }, [text, sending, replyTo, tripId, scrollToBottom])
+ } catch { toast.error(t('common.error')) } finally { setSending(false) }
+ }, [text, sending, replyTo, tripId, scrollToBottom, toast, t])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
@@ -581,23 +583,23 @@ function useCollabChat(tripId: any, currentUser: any) {
requestAnimationFrame(() => {
setDeletingIds(prev => new Set(prev).add(msgId))
})
- const t = setTimeout(async () => {
+ const timer = setTimeout(async () => {
try {
await collabApi.deleteMessage(tripId, msgId)
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
- } catch {}
+ } catch { toast.error(t('common.error')) }
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
}, 400)
- deleteTimersRef.current.push(t)
- }, [tripId])
+ deleteTimersRef.current.push(timer)
+ }, [tripId, toast, t])
const handleReact = useCallback(async (msgId, emoji) => {
setReactMenu(null)
try {
const data = await collabApi.reactMessage(tripId, msgId, emoji)
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m))
- } catch {}
- }, [tripId])
+ } catch { toast.error(t('common.error')) }
+ }, [tripId, toast, t])
const handleEmojiSelect = useCallback((emoji) => {
setText(prev => prev + emoji)
diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx
index df81e941..f8bd652e 100644
--- a/client/src/components/Collab/CollabNotes.tsx
+++ b/client/src/components/Collab/CollabNotes.tsx
@@ -12,6 +12,7 @@ import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
+import { useToast } from '../shared/Toast'
import type { User } from '../../types'
interface NoteFile {
@@ -916,6 +917,7 @@ interface CollabNotesProps {
*/
function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
const { t } = useTranslation()
+ const toast = useToast()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('collab_edit', trip)
@@ -996,7 +998,13 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
const handleCreateNote = useCallback(async (data) => {
const pendingFiles = data._pendingFiles || []
delete data._pendingFiles
- const created = await collabApi.createNote(tripId, data)
+ let created
+ try {
+ created = await collabApi.createNote(tripId, data)
+ } catch (err) {
+ toast.error(t('common.error'))
+ throw err
+ }
if (created) {
const note = created.note || created
// Upload pending files
@@ -1004,7 +1012,7 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
- try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch (err) { console.error('Failed to upload note attachment:', err) }
+ try { await collabApi.uploadNoteFile(tripId, note.id, fd) } catch (err) { console.error('Failed to upload note attachment:', err); toast.error(t('common.error')) }
}
// Reload note with attachments
const fresh = await collabApi.getNotes(tripId)
@@ -1017,17 +1025,23 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
return [note, ...prev]
})
}
- }, [tripId])
+ }, [tripId, toast, t])
const handleUpdateNote = useCallback(async (noteId, data) => {
- const result = await collabApi.updateNote(tripId, noteId, data)
+ let result
+ try {
+ result = await collabApi.updateNote(tripId, noteId, data)
+ } catch (err) {
+ toast.error(t('common.error'))
+ throw err
+ }
const updated = result?.note || result
if (updated) {
setNotes(prev =>
prev.map(n => (n.id === noteId ? { ...n, ...updated } : n))
)
}
- }, [tripId])
+ }, [tripId, toast, t])
const saveCategoryColors = useCallback(async (newMap) => {
// Update notes with changed colors
@@ -1058,24 +1072,29 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
- try { await collabApi.uploadNoteFile(tripId, editingNote.id, fd) } catch {}
+ try { await collabApi.uploadNoteFile(tripId, editingNote.id, fd) } catch { toast.error(t('common.error')) }
}
const fresh = await collabApi.getNotes(tripId)
if (fresh?.notes) setNotes(fresh.notes)
window.dispatchEvent(new Event('collab-files-changed'))
}
- }, [editingNote, tripId, handleUpdateNote])
+ }, [editingNote, tripId, handleUpdateNote, toast, t])
const handleDeleteNoteFile = useCallback(async (noteId, fileId) => {
- try { await collabApi.deleteNoteFile(tripId, noteId, fileId) } catch {}
+ try { await collabApi.deleteNoteFile(tripId, noteId, fileId) } catch { toast.error(t('common.error')) }
window.dispatchEvent(new Event('collab-files-changed'))
- }, [tripId])
+ }, [tripId, toast, t])
const handleDeleteNote = useCallback(async (noteId) => {
- await collabApi.deleteNote(tripId, noteId)
+ try {
+ await collabApi.deleteNote(tripId, noteId)
+ } catch (err) {
+ toast.error(t('common.error'))
+ throw err
+ }
setNotes(prev => prev.filter(n => n.id !== noteId))
window.dispatchEvent(new Event('collab-files-changed'))
- }, [tripId])
+ }, [tripId, toast, t])
// ── Derived data ──
const categories = [...new Set(notes.map(n => n.category).filter(Boolean))]
diff --git a/client/src/components/Collab/CollabPolls.tsx b/client/src/components/Collab/CollabPolls.tsx
index 1a63ea1a..79528518 100644
--- a/client/src/components/Collab/CollabPolls.tsx
+++ b/client/src/components/Collab/CollabPolls.tsx
@@ -3,6 +3,7 @@ import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
+import { useToast } from '../shared/Toast'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import ReactDOM from 'react-dom'
@@ -342,6 +343,7 @@ interface CollabPollsProps {
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
const { t } = useTranslation()
+ const toast = useToast()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('collab_edit', trip)
@@ -378,33 +380,44 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
}, [])
const handleCreate = useCallback(async (data) => {
- const result = await collabApi.createPoll(tripId, data)
- const created = result.poll || result
- setPolls(prev => prev.some(p => p.id === created.id) ? prev : [created, ...prev])
- setShowForm(false)
- }, [tripId])
+ try {
+ const result = await collabApi.createPoll(tripId, data)
+ const created = result.poll || result
+ setPolls(prev => prev.some(p => p.id === created.id) ? prev : [created, ...prev])
+ setShowForm(false)
+ } catch (err) {
+ toast.error(t('common.error'))
+ throw err
+ }
+ }, [tripId, toast, t])
const handleVote = useCallback(async (pollId, optionIndex) => {
try {
const result = await collabApi.votePoll(tripId, pollId, optionIndex)
const updated = result.poll || result
setPolls(prev => prev.map(p => p.id === updated.id ? updated : p))
- } catch {}
- }, [tripId])
+ } catch {
+ toast.error(t('common.error'))
+ }
+ }, [tripId, toast, t])
const handleClose = useCallback(async (pollId) => {
try {
await collabApi.closePoll(tripId, pollId)
setPolls(prev => prev.map(p => p.id === pollId ? { ...p, is_closed: true } : p))
- } catch {}
- }, [tripId])
+ } catch {
+ toast.error(t('common.error'))
+ }
+ }, [tripId, toast, t])
const handleDelete = useCallback(async (pollId) => {
try {
await collabApi.deletePoll(tripId, pollId)
setPolls(prev => prev.filter(p => p.id !== pollId))
- } catch {}
- }, [tripId])
+ } catch {
+ toast.error(t('common.error'))
+ }
+ }, [tripId, toast, t])
const activePolls = polls.filter(p => !p.is_closed && !isExpired(p.deadline))
const closedPolls = polls.filter(p => p.is_closed || isExpired(p.deadline))
diff --git a/client/src/components/Journey/ContributorInviteDialog.tsx b/client/src/components/Journey/ContributorInviteDialog.tsx
new file mode 100644
index 00000000..2c9b8763
--- /dev/null
+++ b/client/src/components/Journey/ContributorInviteDialog.tsx
@@ -0,0 +1,136 @@
+import { useEffect, useState } from 'react'
+import { X, Check, UserPlus } from 'lucide-react'
+import { journeyApi, authApi } from '../../api/client'
+import { useTranslation } from '../../i18n'
+import { useToast } from '../shared/Toast'
+
+export default function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvited }: {
+ journeyId: number
+ existingUserIds: number[]
+ onClose: () => void
+ onInvited: () => void
+}) {
+ const { t } = useTranslation()
+ const [users, setUsers] = useState<{ id: number; username: string; email: string; avatar?: string | null }[]>([])
+ const [search, setSearch] = useState('')
+ const [selectedUserId, setSelectedUserId] = useState (null)
+ const [role, setRole] = useState<'editor' | 'viewer'>('viewer')
+ const [sending, setSending] = useState(false)
+ const toast = useToast()
+
+ useEffect(() => {
+ authApi.listUsers().then(d => setUsers(d.users || [])).catch(() => {})
+ }, [])
+
+ const filtered = users.filter(u => {
+ if (existingUserIds.includes(u.id)) return false
+ if (!search) return true
+ const q = search.toLowerCase()
+ return u.username.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
+ })
+
+ const handleInvite = async () => {
+ if (!selectedUserId) return
+ setSending(true)
+ try {
+ await journeyApi.addContributor(journeyId, selectedUserId, role)
+ toast.success(t('journey.contributors.added'))
+ onInvited()
+ } catch {
+ toast.error(t('journey.contributors.addFailed'))
+ } finally {
+ setSending(false)
+ }
+ }
+
+ return (
+
+
+
+
+ {t('journey.contributors.invite')}
+
+
+
+
+ {/* Search */}
+
+
+ setSearch(e.target.value)}
+ placeholder={t('journey.contributors.searchPlaceholder')}
+ className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
+ />
+
+
+ {/* User list */}
+
+ {filtered.length === 0 && (
+ {t('journey.contributors.noUsers')}
+ )}
+ {filtered.map(u => (
+ setSelectedUserId(u.id)}
+ className={`flex items-center gap-2.5 p-2.5 rounded-lg cursor-pointer transition-all ${
+ selectedUserId === u.id
+ ? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-white'
+ : 'hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent'
+ }`}
+ >
+
+ {u.username[0].toUpperCase()}
+
+
+ {u.username}
+ {u.email}
+
+ {selectedUserId === u.id && (
+
+
+
+ )}
+
+ ))}
+
+
+ {/* Role selector */}
+
+
+
+ {(['viewer', 'editor'] as const).map(r => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/client/src/components/Journey/JourneyShareSection.tsx b/client/src/components/Journey/JourneyShareSection.tsx
new file mode 100644
index 00000000..122987b1
--- /dev/null
+++ b/client/src/components/Journey/JourneyShareSection.tsx
@@ -0,0 +1,112 @@
+import { useEffect, useState } from 'react'
+import { Link, List, Grid, MapPin, Check } from 'lucide-react'
+import { journeyApi } from '../../api/client'
+import { useTranslation } from '../../i18n'
+import { useToast } from '../shared/Toast'
+
+export default function JourneyShareSection({ journeyId }: { journeyId: number }) {
+ const { t } = useTranslation()
+ const [link, setLink] = useState<{ token: string; share_timeline: boolean; share_gallery: boolean; share_map: boolean } | null>(null)
+ const [loading, setLoading] = useState(true)
+ const [copied, setCopied] = useState(false)
+ const toast = useToast()
+
+ useEffect(() => {
+ journeyApi.getShareLink(journeyId).then(d => setLink(d.link || null)).catch(() => {}).finally(() => setLoading(false))
+ }, [journeyId])
+
+ const createLink = async () => {
+ try {
+ const res = await journeyApi.createShareLink(journeyId, { share_timeline: true, share_gallery: true, share_map: true })
+ setLink({ token: res.token, share_timeline: true, share_gallery: true, share_map: true })
+ toast.success(t('journey.share.linkCreated'))
+ } catch { toast.error(t('journey.share.createFailed')) }
+ }
+
+ const togglePerm = async (key: 'share_timeline' | 'share_gallery' | 'share_map') => {
+ if (!link) return
+ const updated = { ...link, [key]: !link[key] }
+ setLink(updated)
+ try {
+ await journeyApi.createShareLink(journeyId, { share_timeline: updated.share_timeline, share_gallery: updated.share_gallery, share_map: updated.share_map })
+ } catch { setLink(link); toast.error(t('journey.share.updateFailed')) }
+ }
+
+ const deleteLink = async () => {
+ try {
+ await journeyApi.deleteShareLink(journeyId)
+ setLink(null)
+ toast.success(t('journey.share.linkDeleted'))
+ } catch { toast.error(t('journey.share.deleteFailed')) }
+ }
+
+ const shareUrl = link ? `${window.location.origin}/public/journey/${link.token}` : ''
+
+ const copyLink = () => {
+ navigator.clipboard.writeText(shareUrl)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+
+ if (loading) return null
+
+ return (
+
+
+
+ {!link ? (
+
+ ) : (
+
+ {/* URL + Copy */}
+
+
+ {shareUrl}
+
+
+
+ {/* Permission toggles */}
+
+ {[
+ { key: 'share_timeline' as const, label: t('journey.share.timeline'), icon: List },
+ { key: 'share_gallery' as const, label: t('journey.share.gallery'), icon: Grid },
+ { key: 'share_map' as const, label: t('journey.share.map'), icon: MapPin },
+ ].map(({ key, label, icon: Icon }) => (
+
+ ))}
+
+
+ {/* Delete link */}
+
+
+ )}
+
+ )
+}
diff --git a/client/src/components/Layout/InAppNotificationBell.tsx b/client/src/components/Layout/InAppNotificationBell.tsx
index 0b220382..42df852b 100644
--- a/client/src/components/Layout/InAppNotificationBell.tsx
+++ b/client/src/components/Layout/InAppNotificationBell.tsx
@@ -45,8 +45,7 @@ export default function InAppNotificationBell(): React.ReactElement {
|