- {addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
+
+ {isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
diff --git a/client/src/components/Collab/CollabChat.jsx b/client/src/components/Collab/CollabChat.jsx
new file mode 100644
index 0000000..78b22e9
--- /dev/null
+++ b/client/src/components/Collab/CollabChat.jsx
@@ -0,0 +1,615 @@
+import React, { useState, useEffect, useRef, useCallback } from 'react'
+import { Send, Trash2, Reply, ChevronUp, MessageCircle } from 'lucide-react'
+import { collabApi } from '../../api/client'
+import { addListener, removeListener } from '../../api/websocket'
+import { useTranslation } from '../../i18n'
+
+/* ───────── relative timestamp ───────── */
+function formatRelativeTime(isoString, t) {
+ const now = Date.now()
+ const then = new Date(isoString).getTime()
+ const diff = now - then
+
+ const seconds = Math.floor(diff / 1000)
+ const minutes = Math.floor(seconds / 60)
+ const hours = Math.floor(minutes / 60)
+
+ if (seconds < 60) return t('collab.chat.justNow')
+ if (minutes < 60) return t('collab.chat.minutesAgo', { n: minutes })
+ if (hours < 24) return t('collab.chat.hoursAgo', { n: hours })
+
+ const d = new Date(isoString)
+ const today = new Date()
+ const yesterday = new Date()
+ yesterday.setDate(today.getDate() - 1)
+
+ if (
+ d.getDate() === yesterday.getDate() &&
+ d.getMonth() === yesterday.getMonth() &&
+ d.getFullYear() === yesterday.getFullYear()
+ ) {
+ const hh = String(d.getHours()).padStart(2, '0')
+ const mm = String(d.getMinutes()).padStart(2, '0')
+ return `yesterday ${hh}:${mm}`
+ }
+
+ const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
+ return `${monthNames[d.getMonth()]} ${d.getDate()}`
+}
+
+/* ─────────────────────────────────────── */
+/* Component */
+/* ─────────────────────────────────────── */
+export default function CollabChat({ tripId, currentUser }) {
+ const { t } = useTranslation()
+
+ const [messages, setMessages] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [hasMore, setHasMore] = useState(false)
+ const [loadingMore, setLoadingMore] = useState(false)
+ const [text, setText] = useState('')
+ const [replyTo, setReplyTo] = useState(null)
+ const [hoveredId, setHoveredId] = useState(null)
+ const [sending, setSending] = useState(false)
+
+ const scrollRef = useRef(null)
+ const textareaRef = useRef(null)
+ const isAtBottom = useRef(true)
+
+ /* ── scroll helpers ── */
+ const scrollToBottom = useCallback((behavior = 'auto') => {
+ const el = scrollRef.current
+ if (!el) return
+ requestAnimationFrame(() => {
+ el.scrollTo({ top: el.scrollHeight, behavior })
+ })
+ }, [])
+
+ const checkAtBottom = useCallback(() => {
+ const el = scrollRef.current
+ if (!el) return
+ isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48
+ }, [])
+
+ /* ── load messages ── */
+ useEffect(() => {
+ let cancelled = false
+ setLoading(true)
+
+ collabApi.getMessages(tripId).then(data => {
+ if (cancelled) return
+ const msgs = Array.isArray(data) ? data : data.messages || []
+ setMessages(msgs)
+ setHasMore(msgs.length >= 100)
+ setLoading(false)
+ setTimeout(() => scrollToBottom(), 30)
+ }).catch(() => {
+ if (!cancelled) setLoading(false)
+ })
+
+ return () => { cancelled = true }
+ }, [tripId, scrollToBottom])
+
+ /* ── load more (older messages) ── */
+ const handleLoadMore = useCallback(async () => {
+ if (loadingMore || messages.length === 0) return
+ setLoadingMore(true)
+ const el = scrollRef.current
+ const prevHeight = el ? el.scrollHeight : 0
+
+ try {
+ const oldestId = messages[0]?.id
+ const data = await collabApi.getMessages(tripId, oldestId)
+ const older = Array.isArray(data) ? data : data.messages || []
+
+ if (older.length === 0) {
+ setHasMore(false)
+ } else {
+ setMessages(prev => [...older, ...prev])
+ setHasMore(older.length >= 100)
+ requestAnimationFrame(() => {
+ if (el) el.scrollTop = el.scrollHeight - prevHeight
+ })
+ }
+ } catch {
+ // silently ignore
+ } finally {
+ setLoadingMore(false)
+ }
+ }, [tripId, loadingMore, messages])
+
+ /* ── websocket ── */
+ useEffect(() => {
+ const handler = (event) => {
+ if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) {
+ setMessages(prev => {
+ if (prev.some(m => m.id === event.message.id)) return prev
+ return [...prev, event.message]
+ })
+ if (isAtBottom.current) {
+ setTimeout(() => scrollToBottom('smooth'), 30)
+ }
+ }
+ if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) {
+ setMessages(prev => prev.filter(m => m.id !== event.messageId))
+ }
+ }
+
+ addListener(handler)
+ return () => removeListener(handler)
+ }, [tripId, scrollToBottom])
+
+ /* ── auto-resize textarea ── */
+ const handleTextChange = useCallback((e) => {
+ setText(e.target.value)
+ const ta = textareaRef.current
+ if (ta) {
+ ta.style.height = 'auto'
+ ta.style.height = Math.min(ta.scrollHeight, 3 * 20 + 18) + 'px'
+ }
+ }, [])
+
+ /* ── send ── */
+ const handleSend = useCallback(async () => {
+ const body = text.trim()
+ if (!body || sending) return
+
+ setSending(true)
+ try {
+ const payload = { text: body }
+ if (replyTo) payload.reply_to = replyTo.id
+ const data = await collabApi.sendMessage(tripId, payload)
+ if (data?.message) {
+ setMessages(prev => {
+ if (prev.some(m => m.id === data.message.id)) return prev
+ return [...prev, data.message]
+ })
+ }
+ setText('')
+ setReplyTo(null)
+ if (textareaRef.current) {
+ textareaRef.current.style.height = 'auto'
+ }
+ scrollToBottom('smooth')
+ } catch {
+ // keep text on failure so user can retry
+ } finally {
+ setSending(false)
+ }
+ }, [text, sending, replyTo, tripId, scrollToBottom])
+
+ /* ── keyboard ── */
+ const handleKeyDown = useCallback((e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleSend()
+ }
+ }, [handleSend])
+
+ /* ── delete ── */
+ const handleDelete = useCallback(async (msgId) => {
+ try {
+ await collabApi.deleteMessage(tripId, msgId)
+ } catch {
+ // ignore
+ }
+ }, [tripId])
+
+ /* ── find a replied-to message ── */
+ const findMessage = useCallback((id) => messages.find(m => m.id === id), [messages])
+
+ /* ── helpers ── */
+ const isOwn = (msg) => String(msg.user_id) === String(currentUser.id)
+
+ const font = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
+
+ /* ───────── render: loading ───────── */
+ if (loading) {
+ return (
+
+ )
+ }
+
+ /* ───────── render: main ───────── */
+ return (
+
+ {/* ── messages area ── */}
+ {messages.length === 0 ? (
+
+
+
+ {t('collab.chat.empty')}
+
+
+ {t('collab.chat.emptyDesc') || ''}
+
+
+ ) : (
+
+ {/* load more */}
+ {hasMore && (
+
+
+
+ )}
+
+ {messages.map((msg, idx) => {
+ const own = isOwn(msg)
+ const repliedMsg = msg.reply_to_id ? findMessage(msg.reply_to_id) : null
+ const prevMsg = messages[idx - 1]
+ const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id)
+ const showHeader = !own && isNewGroup
+
+ return (
+
+ {/* username + avatar for others */}
+ {showHeader && (
+
+ {msg.user_avatar ? (
+

+ ) : (
+
+ {(msg.username || '?')[0].toUpperCase()}
+
+ )}
+
+ {msg.username}
+
+
+ )}
+
+ {/* reply quote */}
+ {repliedMsg && (
+
+ {repliedMsg.username}:
+ {(repliedMsg.text || '').slice(0, 80)}
+ {(repliedMsg.text || '').length > 80 ? '...' : ''}
+
+ )}
+
+ {/* bubble with hover actions */}
+
setHoveredId(msg.id)}
+ onMouseLeave={() => setHoveredId(null)}
+ >
+
+ {msg.text}
+
+
+ {/* action buttons */}
+
+
+ {own && (
+
+ )}
+
+
+
+ {/* timestamp */}
+
+ {formatRelativeTime(msg.created_at, t)}
+
+
+ )
+ })}
+
+ )}
+
+ {/* ── composer ── */}
+
+ {/* reply preview */}
+ {replyTo && (
+
+
+
+ {replyTo.username}: {(replyTo.text || '').slice(0, 60)}
+ {(replyTo.text || '').length > 60 ? '...' : ''}
+
+ setReplyTo(null)}
+ >
+ ×
+
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/client/src/components/Collab/CollabNotes.jsx b/client/src/components/Collab/CollabNotes.jsx
new file mode 100644
index 0000000..2169aa2
--- /dev/null
+++ b/client/src/components/Collab/CollabNotes.jsx
@@ -0,0 +1,1024 @@
+import React, { useState, useEffect, useCallback } from 'react'
+import ReactDOM from 'react-dom'
+import { Plus, Trash2, Pin, PinOff, Pencil, X, Check } from 'lucide-react'
+import { collabApi } from '../../api/client'
+import { addListener, removeListener } from '../../api/websocket'
+import { useTranslation } from '../../i18n'
+
+const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
+
+const NOTE_COLORS = [
+ { value: '#6366f1', label: 'Indigo' },
+ { value: '#ef4444', label: 'Red' },
+ { value: '#f59e0b', label: 'Amber' },
+ { value: '#10b981', label: 'Emerald' },
+ { value: '#3b82f6', label: 'Blue' },
+ { value: '#8b5cf6', label: 'Violet' },
+]
+
+const formatTimestamp = (ts) => {
+ if (!ts) return ''
+ const d = new Date(ts)
+ const now = new Date()
+ const diffMs = now - d
+ const diffMins = Math.floor(diffMs / 60000)
+ if (diffMins < 1) return 'just now'
+ if (diffMins < 60) return `${diffMins}m ago`
+ const diffHrs = Math.floor(diffMins / 60)
+ if (diffHrs < 24) return `${diffHrs}h ago`
+ const diffDays = Math.floor(diffHrs / 24)
+ if (diffDays < 7) return `${diffDays}d ago`
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
+}
+
+// ── Avatar ──────────────────────────────────────────────────────────────────
+function UserAvatar({ user, size = 14 }) {
+ if (!user) return null
+ if (user.avatar) {
+ return (
+
+ )
+ }
+ const initials = (user.username || '?').slice(0, 1)
+ return (
+
+ {initials}
+
+ )
+}
+
+// ── New Note Modal (portal to body) ─────────────────────────────────────────
+function NewNoteModal({ onClose, onSubmit, existingCategories, t }) {
+ const [title, setTitle] = useState('')
+ const [content, setContent] = useState('')
+ const [category, setCategory] = useState('')
+ const [customCategory, setCustomCategory] = useState('')
+ const [color, setColor] = useState(NOTE_COLORS[0].value)
+ const [submitting, setSubmitting] = useState(false)
+
+ const isCustom = category === '__custom__'
+ const finalCategory = isCustom ? customCategory.trim() : category
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ if (!title.trim()) return
+ setSubmitting(true)
+ try {
+ await onSubmit({
+ title: title.trim(),
+ content: content.trim(),
+ category: finalCategory || null,
+ color,
+ })
+ onClose()
+ } catch {
+ // error handled upstream
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const canSubmit = title.trim() && !submitting
+
+ return ReactDOM.createPortal(
+ ,
+ document.body
+ )
+}
+
+// ── Note Card ───────────────────────────────────────────────────────────────
+function NoteCard({ note, currentUser, onUpdate, onDelete, t }) {
+ const [hovered, setHovered] = useState(false)
+ const [editing, setEditing] = useState(false)
+ const [editTitle, setEditTitle] = useState(note.title)
+ const [editContent, setEditContent] = useState(note.content || '')
+ const [saving, setSaving] = useState(false)
+
+ const author = note.author || note.user || {}
+ const color = note.color || '#6366f1'
+
+ const handleStartEdit = useCallback(() => {
+ setEditTitle(note.title)
+ setEditContent(note.content || '')
+ setEditing(true)
+ }, [note.title, note.content])
+
+ const handleCancelEdit = useCallback(() => {
+ setEditing(false)
+ setEditTitle(note.title)
+ setEditContent(note.content || '')
+ }, [note.title, note.content])
+
+ const handleSaveEdit = useCallback(async () => {
+ if (!editTitle.trim()) return
+ setSaving(true)
+ try {
+ await onUpdate(note.id, {
+ title: editTitle.trim(),
+ content: editContent.trim(),
+ })
+ setEditing(false)
+ } catch {
+ // error handled upstream
+ } finally {
+ setSaving(false)
+ }
+ }, [note.id, editTitle, editContent, onUpdate])
+
+ const handleTogglePin = useCallback(() => {
+ onUpdate(note.id, { pinned: !note.pinned })
+ }, [note.id, note.pinned, onUpdate])
+
+ const handleDelete = useCallback(() => {
+ onDelete(note.id)
+ }, [note.id, onDelete])
+
+ useEffect(() => {
+ if (!editing) {
+ setEditTitle(note.title)
+ setEditContent(note.content || '')
+ }
+ }, [note.title, note.content, editing])
+
+ return (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ style={{
+ position: 'relative',
+ borderRadius: 10,
+ border: '1px solid var(--border-faint)',
+ overflow: 'hidden',
+ background: 'var(--bg-card)',
+ display: 'flex',
+ flexDirection: 'column',
+ fontFamily: FONT,
+ }}
+ >
+ {/* Color stripe */}
+
+
+ {/* Pin icon — top right */}
+ {!editing && (
+
+ )}
+
+ {/* Hover actions — edit + delete */}
+
+
+ {/* Card body */}
+
+ {editing ? (
+ <>
+
setEditTitle(e.target.value)}
+ placeholder={t('collab.notes.titlePlaceholder')}
+ onKeyDown={e => {
+ if (e.key === 'Escape') handleCancelEdit()
+ if (e.key === 'Enter' && e.metaKey) handleSaveEdit()
+ }}
+ style={{
+ width: '100%',
+ border: '1px solid var(--border-primary)',
+ borderRadius: 10,
+ padding: '8px 12px',
+ fontSize: 13,
+ background: 'var(--bg-input)',
+ color: 'var(--text-primary)',
+ fontFamily: 'inherit',
+ outline: 'none',
+ boxSizing: 'border-box',
+ }}
+ />
+
+
+ )
+}
+
+// ── Main Component ──────────────────────────────────────────────────────────
+export default function CollabNotes({ tripId, currentUser }) {
+ const { t } = useTranslation()
+ const [notes, setNotes] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [showNewModal, setShowNewModal] = useState(false)
+ const [activeCategory, setActiveCategory] = useState(null)
+
+ // ── Load notes on mount ──
+ useEffect(() => {
+ if (!tripId) return
+ let cancelled = false
+ setLoading(true)
+ collabApi.getNotes(tripId)
+ .then(data => { if (!cancelled) setNotes(data?.notes || data || []) })
+ .catch(() => { if (!cancelled) setNotes([]) })
+ .finally(() => { if (!cancelled) setLoading(false) })
+ return () => { cancelled = true }
+ }, [tripId])
+
+ // ── WebSocket real-time sync ──
+ useEffect(() => {
+ if (!tripId) return
+
+ const handler = (msg) => {
+ if (msg.type === 'collab:note:created' && msg.note) {
+ setNotes(prev => {
+ if (prev.some(n => n.id === msg.note.id)) return prev
+ return [msg.note, ...prev]
+ })
+ }
+ if (msg.type === 'collab:note:updated' && msg.note) {
+ setNotes(prev =>
+ prev.map(n => (n.id === msg.note.id ? { ...n, ...msg.note } : n))
+ )
+ }
+ if (msg.type === 'collab:note:deleted') {
+ const deletedId = msg.noteId || msg.id
+ if (deletedId) {
+ setNotes(prev => prev.filter(n => n.id !== deletedId))
+ }
+ }
+ }
+
+ addListener(handler)
+ return () => removeListener(handler)
+ }, [tripId])
+
+ // ── Actions ──
+ const handleCreateNote = useCallback(async (data) => {
+ const created = await collabApi.createNote(tripId, data)
+ if (created) {
+ setNotes(prev => {
+ if (prev.some(n => n.id === created.id)) return prev
+ return [created, ...prev]
+ })
+ }
+ }, [tripId])
+
+ const handleUpdateNote = useCallback(async (noteId, data) => {
+ const updated = await collabApi.updateNote(tripId, noteId, data)
+ if (updated) {
+ setNotes(prev =>
+ prev.map(n => (n.id === noteId ? { ...n, ...updated } : n))
+ )
+ }
+ }, [tripId])
+
+ const handleDeleteNote = useCallback(async (noteId) => {
+ await collabApi.deleteNote(tripId, noteId)
+ setNotes(prev => prev.filter(n => n.id !== noteId))
+ }, [tripId])
+
+ // ── Derived data ──
+ const categories = [...new Set(notes.map(n => n.category).filter(Boolean))]
+
+ const sortedNotes = [...notes]
+ .filter(n => activeCategory === null || n.category === activeCategory)
+ .sort((a, b) => {
+ if (a.pinned && !b.pinned) return -1
+ if (!a.pinned && b.pinned) return 1
+ const tA = new Date(a.updated_at || a.created_at || 0).getTime()
+ const tB = new Date(b.updated_at || b.created_at || 0).getTime()
+ return tB - tA
+ })
+
+ // ── Loading state ──
+ if (loading) {
+ return (
+
+
+
+ {t('collab.notes.title')}
+
+
+
+
+ )
+ }
+
+ return (
+
+ {/* ── Header ── */}
+
+
+ {t('collab.notes.title')}
+
+
+
+
+ {/* ── Category filter pills ── */}
+ {categories.length > 0 && (
+
+
+ {categories.map(cat => (
+
+ ))}
+
+ )}
+
+ {/* ── Scrollable content ── */}
+
+ {sortedNotes.length === 0 ? (
+ /* ── Empty state ── */
+
+
+
+ {t('collab.notes.empty')}
+
+
+ {t('collab.notes.emptyDesc') || 'Create a note to get started'}
+
+
+ ) : (
+ /* ── Notes list — single column ── */
+
+ {sortedNotes.map(note => (
+
+ ))}
+
+ )}
+
+
+ {/* ── New Note Modal ── */}
+ {showNewModal && (
+
setShowNewModal(false)}
+ onSubmit={handleCreateNote}
+ existingCategories={categories}
+ t={t}
+ />
+ )}
+
+ )
+}
diff --git a/client/src/components/Collab/CollabPanel.jsx b/client/src/components/Collab/CollabPanel.jsx
new file mode 100644
index 0000000..42b67ff
--- /dev/null
+++ b/client/src/components/Collab/CollabPanel.jsx
@@ -0,0 +1,34 @@
+import React from 'react'
+import { useAuthStore } from '../../store/authStore'
+import CollabChat from './CollabChat'
+import CollabNotes from './CollabNotes'
+import CollabPolls from './CollabPolls'
+
+export default function CollabPanel({ tripId }) {
+ const { user } = useAuthStore()
+
+ return (
+
+
+
+
+ {/* Chat — takes 1 column, full height */}
+
+
+
+
+ {/* Notes — takes 1 column */}
+
+
+
+
+ {/* Polls — takes 1 column */}
+
+
+
+
+
+
+
+ )
+}
diff --git a/client/src/components/Collab/CollabPolls.jsx b/client/src/components/Collab/CollabPolls.jsx
new file mode 100644
index 0000000..d2ece2e
--- /dev/null
+++ b/client/src/components/Collab/CollabPolls.jsx
@@ -0,0 +1,1046 @@
+import React, { useState, useEffect, useCallback } from 'react'
+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'
+
+// ── Constants ────────────────────────────────────────────────────────────────
+
+const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function timeRemaining(deadline) {
+ if (!deadline) return null
+ const diff = new Date(deadline).getTime() - Date.now()
+ if (diff <= 0) return null
+ const mins = Math.floor(diff / 60000)
+ const hrs = Math.floor(mins / 60)
+ const days = Math.floor(hrs / 24)
+ if (days > 0) return `${days}d ${hrs % 24}h`
+ if (hrs > 0) return `${hrs}h ${mins % 60}m`
+ return `${mins}m`
+}
+
+function isExpired(deadline) {
+ if (!deadline) return false
+ return new Date(deadline).getTime() <= Date.now()
+}
+
+function totalVotes(poll) {
+ if (!poll.options) return 0
+ return poll.options.reduce((s, o) => s + (o.voters?.length || 0), 0)
+}
+
+// ── Voter Avatars ────────────────────────────────────────────────────────────
+
+function VoterAvatars({ voters = [] }) {
+ const MAX = 4
+ const shown = voters.slice(0, MAX)
+ const extra = voters.length - MAX
+
+ if (!voters.length) return null
+
+ return (
+
+ {shown.map((v, i) => (
+
+ {v.avatar ? (
+

+ ) : (
+ (v.username || '?')[0].toUpperCase()
+ )}
+
+ ))}
+ {extra > 0 && (
+
+ +{extra}
+
+ )}
+
+ )
+}
+
+// ── Option Bar ───────────────────────────────────────────────────────────────
+
+function OptionBar({ option, index, poll, currentUser, onVote, isClosed }) {
+ const total = totalVotes(poll)
+ const count = option.voters?.length || 0
+ const pct = total > 0 ? Math.round((count / total) * 100) : 0
+ const voted = option.voters?.some((v) => v.id === currentUser.id)
+ const canVote = !isClosed && !poll.is_closed
+
+ return (
+ canVote && onVote(poll.id, index)}
+ style={{
+ position: 'relative',
+ width: '100%',
+ padding: '8px 12px',
+ borderRadius: 8,
+ border: voted
+ ? '1px solid var(--accent)'
+ : '1px solid var(--border-faint)',
+ overflow: 'hidden',
+ cursor: canVote ? 'pointer' : 'default',
+ marginTop: 4,
+ boxSizing: 'border-box',
+ fontFamily: FONT,
+ }}
+ onMouseEnter={(e) => {
+ if (canVote) e.currentTarget.style.borderColor = 'var(--accent)'
+ }}
+ onMouseLeave={(e) => {
+ if (canVote && !voted)
+ e.currentTarget.style.borderColor = 'var(--border-faint)'
+ }}
+ >
+ {/* Vote fill bar */}
+
+
+ {/* Content row */}
+
+
+ {voted && (
+
+ )}
+
+ {option.text}
+
+
+
+
+
+ {count}
+
+
+ {pct}%
+
+
+
+
+ {/* Voter avatars */}
+ {option.voters?.length > 0 && (
+
+
+
+ )}
+
+ )
+}
+
+// ── Create Poll Form ─────────────────────────────────────────────────────────
+
+function CreatePollForm({ onSubmit, onCancel, t }) {
+ const [question, setQuestion] = useState('')
+ const [options, setOptions] = useState(['', ''])
+ const [multipleChoice, setMultipleChoice] = useState(false)
+ const [deadline, setDeadline] = useState('')
+ const [submitting, setSubmitting] = useState(false)
+
+ const canSubmit =
+ question.trim().length > 0 &&
+ options.filter((o) => o.trim()).length >= 2 &&
+ !submitting
+
+ const addOption = () => setOptions((prev) => [...prev, ''])
+
+ const removeOption = (i) => {
+ if (options.length <= 2) return
+ setOptions((prev) => prev.filter((_, idx) => idx !== i))
+ }
+
+ const updateOption = (i, val) =>
+ setOptions((prev) => prev.map((o, idx) => (idx === i ? val : o)))
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ if (!canSubmit) return
+ setSubmitting(true)
+ try {
+ await onSubmit({
+ question: question.trim(),
+ options: options.filter((o) => o.trim()).map((o) => o.trim()),
+ multiple_choice: multipleChoice,
+ deadline: deadline || undefined,
+ })
+ setQuestion('')
+ setOptions(['', ''])
+ setMultipleChoice(false)
+ setDeadline('')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+// ── Poll Card ────────────────────────────────────────────────────────────────
+
+function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }) {
+ const isCreator = poll.created_by?.id === currentUser.id
+ const isClosed = poll.is_closed || isExpired(poll.deadline)
+ const remaining = timeRemaining(poll.deadline)
+ const total = totalVotes(poll)
+
+ return (
+
+ {/* Header row */}
+
+
+ {/* Question */}
+
+ {poll.question}
+
+
+ {/* Meta line */}
+
+ {/* by username */}
+
+ by {poll.created_by?.username || '?'}
+
+
+ {/* Vote count */}
+
+
+ {total} {t('collab.polls.votes')}
+
+
+ {/* Multiple choice badge */}
+ {poll.multiple_choice && (
+
+ {t('collab.polls.multipleChoice')}
+
+ )}
+
+ {/* Closed badge */}
+ {isClosed && (
+
+
+ {t('collab.polls.closed')}
+
+ )}
+
+ {/* Deadline countdown */}
+ {!isClosed && remaining && (
+
+
+ {remaining}
+
+ )}
+
+
+
+ {/* Creator actions: close / delete */}
+ {isCreator && (
+
+ {!isClosed && (
+
+ )}
+
+
+ )}
+
+
+ {/* Options */}
+
+ {poll.options?.map((opt, i) => (
+
+ ))}
+
+
+ )
+}
+
+// ── Main Component ───────────────────────────────────────────────────────────
+
+function CollabPolls({ tripId, currentUser }) {
+ const { t } = useTranslation()
+ const [polls, setPolls] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [showForm, setShowForm] = useState(false)
+
+ // ── Load polls ──
+ const loadPolls = useCallback(async () => {
+ try {
+ const data = await collabApi.getPolls(tripId)
+ setPolls(Array.isArray(data) ? data : data.polls || [])
+ } catch (err) {
+ console.error('Failed to load polls:', err)
+ } finally {
+ setLoading(false)
+ }
+ }, [tripId])
+
+ useEffect(() => {
+ loadPolls()
+ }, [loadPolls])
+
+ // ── WebSocket ──
+ useEffect(() => {
+ const handler = (msg) => {
+ if (!msg || !msg.type) return
+
+ if (msg.type === 'collab:poll:created' && msg.poll) {
+ setPolls((prev) => {
+ if (prev.some((p) => p.id === msg.poll.id)) return prev
+ return [msg.poll, ...prev]
+ })
+ }
+
+ if (msg.type === 'collab:poll:voted' && msg.poll) {
+ setPolls((prev) =>
+ prev.map((p) => (p.id === msg.poll.id ? msg.poll : p))
+ )
+ }
+
+ if (msg.type === 'collab:poll:closed' && msg.poll) {
+ setPolls((prev) =>
+ prev.map((p) => (p.id === msg.poll.id ? { ...p, ...msg.poll, is_closed: true } : p))
+ )
+ }
+
+ if (msg.type === 'collab:poll:deleted') {
+ const deletedId = msg.pollId || msg.poll?.id || msg.id
+ if (deletedId) {
+ setPolls((prev) => prev.filter((p) => p.id !== deletedId))
+ }
+ }
+ }
+
+ addListener(handler)
+ return () => removeListener(handler)
+ }, [])
+
+ // ── Actions ──
+ const handleCreate = useCallback(
+ async (data) => {
+ const result = await collabApi.createPoll(tripId, data)
+ const created = result.poll || result
+ setPolls((prev) => {
+ if (prev.some((p) => p.id === created.id)) return prev
+ return [created, ...prev]
+ })
+ setShowForm(false)
+ },
+ [tripId]
+ )
+
+ 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 (err) {
+ console.error('Vote failed:', err)
+ }
+ },
+ [tripId]
+ )
+
+ 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 (err) {
+ console.error('Close poll failed:', err)
+ }
+ },
+ [tripId]
+ )
+
+ const handleDelete = useCallback(
+ async (pollId) => {
+ try {
+ await collabApi.deletePoll(tripId, pollId)
+ setPolls((prev) => prev.filter((p) => p.id !== pollId))
+ } catch (err) {
+ console.error('Delete poll failed:', err)
+ }
+ },
+ [tripId]
+ )
+
+ // ── Separate active / closed ──
+ const activePolls = polls.filter(
+ (p) => !p.is_closed && !isExpired(p.deadline)
+ )
+ const closedPolls = polls.filter(
+ (p) => p.is_closed || isExpired(p.deadline)
+ )
+
+ // ── Deadline countdown ticker ──
+ const [, setTick] = useState(0)
+ useEffect(() => {
+ const hasDeadlines = polls.some((p) => p.deadline && !p.is_closed)
+ if (!hasDeadlines) return
+ const iv = setInterval(() => setTick((t) => t + 1), 30000)
+ return () => clearInterval(iv)
+ }, [polls])
+
+ // ── Render ──
+ return (
+
+ {/* Header — fixed */}
+
+
+
+ {t('collab.polls.title')}
+
+ {!showForm && (
+
+ )}
+
+
+ {/* Content — scrollable */}
+
+ {/* Create form */}
+ {showForm && (
+
setShowForm(false)}
+ t={t}
+ />
+ )}
+
+ {/* Loading */}
+ {loading && (
+
+ ...
+
+ )}
+
+ {/* Empty state */}
+ {!loading && polls.length === 0 && !showForm && (
+
+
+
+ {t('collab.polls.empty')}
+
+
+ {t('collab.polls.emptyDesc', 'Create a poll to get started')}
+
+
+ )}
+
+ {/* Active polls */}
+ {!loading && (
+
+ {activePolls.map((poll) => (
+
+ ))}
+
+ )}
+
+ {/* Closed polls divider + section */}
+ {!loading && closedPolls.length > 0 && (
+ <>
+ {activePolls.length > 0 && (
+
+
+
+
+ {t('collab.polls.closed')}
+
+
+
+ )}
+
+ {closedPolls.map((poll) => (
+
+ ))}
+
+ >
+ )}
+
+
+ )
+}
+
+export default CollabPolls
diff --git a/client/src/components/Planner/PlaceInspector.jsx b/client/src/components/Planner/PlaceInspector.jsx
index b91d1e8..4739662 100644
--- a/client/src/components/Planner/PlaceInspector.jsx
+++ b/client/src/components/Planner/PlaceInspector.jsx
@@ -344,8 +344,8 @@ export default function PlaceInspector({
)
})()}
- {/* Opening hours + Files — side by side on desktop */}
-
+ {/* Opening hours + Files — side by side on desktop only if both exist */}
+
0 ? 'sm:grid-cols-2' : ''} gap-2`}>
{openingHours && openingHours.length > 0 && (
)}
+
+ {activeTab === 'collab' && (
+
+
+
+ )}
{ setShowPlaceForm(false); setEditingPlace(null) }} onSave={handleSavePlace} place={editingPlace} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
diff --git a/docker-compose.yml b/docker-compose.yml
index 820e85f..1374f29 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -14,7 +14,7 @@ services:
- ./uploads:/app/uploads
restart: unless-stopped
healthcheck:
- test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/auth/me"]
+ test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
diff --git a/server/src/db/database.js b/server/src/db/database.js
index 9cf8017..f3005fe 100644
--- a/server/src/db/database.js
+++ b/server/src/db/database.js
@@ -316,6 +316,54 @@ function initDb() {
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
+
+ -- Collab addon tables
+ CREATE TABLE IF NOT EXISTS collab_notes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ category TEXT DEFAULT 'General',
+ title TEXT NOT NULL,
+ content TEXT,
+ color TEXT DEFAULT '#6366f1',
+ pinned INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE TABLE IF NOT EXISTS collab_polls (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ question TEXT NOT NULL,
+ options TEXT NOT NULL,
+ multiple INTEGER DEFAULT 0,
+ closed INTEGER DEFAULT 0,
+ deadline TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE TABLE IF NOT EXISTS collab_poll_votes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ option_index INTEGER NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(poll_id, user_id, option_index)
+ );
+
+ CREATE TABLE IF NOT EXISTS collab_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ text TEXT NOT NULL,
+ reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id);
+ CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id);
+ CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id);
`);
// Create indexes for performance
@@ -457,6 +505,57 @@ function initDb() {
)
`);
},
+ // 25: Collab addon tables
+ () => {
+ _db.exec(`
+ CREATE TABLE IF NOT EXISTS collab_notes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ category TEXT DEFAULT 'General',
+ title TEXT NOT NULL,
+ content TEXT,
+ color TEXT DEFAULT '#6366f1',
+ pinned INTEGER DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE IF NOT EXISTS collab_polls (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ question TEXT NOT NULL,
+ options TEXT NOT NULL,
+ multiple INTEGER DEFAULT 0,
+ closed INTEGER DEFAULT 0,
+ deadline TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE IF NOT EXISTS collab_poll_votes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ option_index INTEGER NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(poll_id, user_id, option_index)
+ );
+ CREATE TABLE IF NOT EXISTS collab_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ text TEXT NOT NULL,
+ reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id);
+ CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id);
+ CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id);
+ `);
+ // Ensure collab addon exists for existing installations
+ try {
+ _db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collab', 'Collab', 'Notes, polls, and live chat for trip collaboration', 'trip', 'Users', 0, 6)").run();
+ } catch {}
+ },
// Future migrations go here (append only, never reorder)
];
@@ -505,6 +604,7 @@ function initDb() {
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', sort_order: 2 },
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', sort_order: 10 },
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', sort_order: 11 },
+ { id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', sort_order: 6 },
];
const insertAddon = _db.prepare('INSERT INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, 1, ?)');
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.sort_order);
diff --git a/server/src/index.js b/server/src/index.js
index 6a503fd..5a1751a 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -71,6 +71,7 @@ const dayNotesRoutes = require('./routes/dayNotes');
const weatherRoutes = require('./routes/weather');
const settingsRoutes = require('./routes/settings');
const budgetRoutes = require('./routes/budget');
+const collabRoutes = require('./routes/collab');
const backupRoutes = require('./routes/backup');
const oidcRoutes = require('./routes/oidc');
@@ -83,8 +84,10 @@ app.use('/api/trips/:tripId/places', placesRoutes);
app.use('/api/trips/:tripId/packing', packingRoutes);
app.use('/api/trips/:tripId/files', filesRoutes);
app.use('/api/trips/:tripId/budget', budgetRoutes);
+app.use('/api/trips/:tripId/collab', collabRoutes);
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
+app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
app.use('/api', assignmentsRoutes);
app.use('/api/tags', tagsRoutes);
app.use('/api/categories', categoriesRoutes);
diff --git a/server/src/routes/collab.js b/server/src/routes/collab.js
new file mode 100644
index 0000000..350ff57
--- /dev/null
+++ b/server/src/routes/collab.js
@@ -0,0 +1,365 @@
+const express = require('express');
+const { db, canAccessTrip } = require('../db/database');
+const { authenticate } = require('../middleware/auth');
+const { broadcast } = require('../websocket');
+
+const router = express.Router({ mergeParams: true });
+
+function verifyTripAccess(tripId, userId) {
+ return canAccessTrip(tripId, userId);
+}
+
+// ─── NOTES ───────────────────────────────────────────────────────────────────
+
+// GET /notes - list all notes for trip
+router.get('/notes', authenticate, (req, res) => {
+ const { tripId } = req.params;
+
+ const trip = verifyTripAccess(tripId, req.user.id);
+ if (!trip) return res.status(404).json({ error: 'Trip not found' });
+
+ const notes = db.prepare(`
+ SELECT n.*, u.username, u.avatar
+ FROM collab_notes n
+ JOIN users u ON n.user_id = u.id
+ WHERE n.trip_id = ?
+ ORDER BY n.pinned DESC, n.updated_at DESC
+ `).all(tripId);
+
+ res.json({ notes });
+});
+
+// POST /notes - create note
+router.post('/notes', authenticate, (req, res) => {
+ const { tripId } = req.params;
+ const { title, content, category, color } = req.body;
+
+ const trip = verifyTripAccess(tripId, req.user.id);
+ if (!trip) return res.status(404).json({ error: 'Trip not found' });
+
+ if (!title) return res.status(400).json({ error: 'Title is required' });
+
+ const result = db.prepare(`
+ INSERT INTO collab_notes (trip_id, user_id, title, content, category, color)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `).run(tripId, req.user.id, title, content || null, category || 'General', color || '#6366f1');
+
+ const note = db.prepare(`
+ SELECT n.*, u.username, u.avatar
+ FROM collab_notes n
+ JOIN users u ON n.user_id = u.id
+ WHERE n.id = ?
+ `).get(result.lastInsertRowid);
+
+ res.status(201).json({ note });
+ broadcast(tripId, 'collab:note:created', { note }, req.headers['x-socket-id']);
+});
+
+// PUT /notes/:id - update note
+router.put('/notes/:id', authenticate, (req, res) => {
+ const { tripId, id } = req.params;
+ const { title, content, category, color, pinned } = req.body;
+
+ const trip = verifyTripAccess(tripId, req.user.id);
+ if (!trip) return res.status(404).json({ error: 'Trip not found' });
+
+ const existing = db.prepare('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
+ if (!existing) return res.status(404).json({ error: 'Note not found' });
+
+ db.prepare(`
+ UPDATE collab_notes SET
+ title = COALESCE(?, title),
+ content = CASE WHEN ? THEN ? ELSE content END,
+ category = COALESCE(?, category),
+ color = COALESCE(?, color),
+ pinned = CASE WHEN ? IS NOT NULL THEN ? ELSE pinned END,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ `).run(
+ title || null,
+ content !== undefined ? 1 : 0, content !== undefined ? content : null,
+ category || null,
+ color || null,
+ pinned !== undefined ? 1 : null, pinned ? 1 : 0,
+ id
+ );
+
+ const note = db.prepare(`
+ SELECT n.*, u.username, u.avatar
+ FROM collab_notes n
+ JOIN users u ON n.user_id = u.id
+ WHERE n.id = ?
+ `).get(id);
+
+ res.json({ note });
+ broadcast(tripId, 'collab:note:updated', { note }, req.headers['x-socket-id']);
+});
+
+// DELETE /notes/:id - delete note
+router.delete('/notes/:id', authenticate, (req, res) => {
+ const { tripId, id } = req.params;
+
+ const trip = verifyTripAccess(tripId, req.user.id);
+ if (!trip) return res.status(404).json({ error: 'Trip not found' });
+
+ const existing = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
+ if (!existing) return res.status(404).json({ error: 'Note not found' });
+
+ db.prepare('DELETE FROM collab_notes WHERE id = ?').run(id);
+ res.json({ success: true });
+ broadcast(tripId, 'collab:note:deleted', { noteId: Number(id) }, req.headers['x-socket-id']);
+});
+
+// ─── POLLS ───────────────────────────────────────────────────────────────────
+
+// Helper: fetch a poll with aggregated votes
+function getPollWithVotes(pollId) {
+ const poll = db.prepare(`
+ SELECT p.*, u.username, u.avatar
+ FROM collab_polls p
+ JOIN users u ON p.user_id = u.id
+ WHERE p.id = ?
+ `).get(pollId);
+
+ if (!poll) return null;
+
+ poll.options = JSON.parse(poll.options);
+
+ const votes = db.prepare(`
+ SELECT v.option_index, v.user_id, u.username, u.avatar
+ FROM collab_poll_votes v
+ JOIN users u ON v.user_id = u.id
+ WHERE v.poll_id = ?
+ `).all(pollId);
+
+ poll.votes = votes;
+ return poll;
+}
+
+// GET /polls - list all polls with votes
+router.get('/polls', authenticate, (req, res) => {
+ const { tripId } = req.params;
+
+ const trip = verifyTripAccess(tripId, req.user.id);
+ if (!trip) return res.status(404).json({ error: 'Trip not found' });
+
+ const rows = db.prepare(`
+ SELECT p.*, u.username, u.avatar
+ FROM collab_polls p
+ JOIN users u ON p.user_id = u.id
+ WHERE p.trip_id = ?
+ ORDER BY p.created_at DESC
+ `).all(tripId);
+
+ const polls = rows.map(poll => {
+ poll.options = JSON.parse(poll.options);
+
+ const votes = db.prepare(`
+ SELECT v.option_index, v.user_id, u.username, u.avatar
+ FROM collab_poll_votes v
+ JOIN users u ON v.user_id = u.id
+ WHERE v.poll_id = ?
+ `).all(poll.id);
+
+ poll.votes = votes;
+ return poll;
+ });
+
+ res.json({ polls });
+});
+
+// POST /polls - create poll
+router.post('/polls', authenticate, (req, res) => {
+ const { tripId } = req.params;
+ const { question, options, multiple, deadline } = req.body;
+
+ const trip = verifyTripAccess(tripId, req.user.id);
+ if (!trip) return res.status(404).json({ error: 'Trip not found' });
+
+ if (!question) return res.status(400).json({ error: 'Question is required' });
+ if (!Array.isArray(options) || options.length < 2) {
+ return res.status(400).json({ error: 'At least 2 options are required' });
+ }
+
+ const result = db.prepare(`
+ INSERT INTO collab_polls (trip_id, user_id, question, options, multiple, deadline)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `).run(tripId, req.user.id, question, JSON.stringify(options), multiple ? 1 : 0, deadline || null);
+
+ const poll = getPollWithVotes(result.lastInsertRowid);
+
+ res.status(201).json({ poll });
+ broadcast(tripId, 'collab:poll:created', { poll }, req.headers['x-socket-id']);
+});
+
+// POST /polls/:id/vote - toggle vote on poll
+router.post('/polls/:id/vote', authenticate, (req, res) => {
+ const { tripId, id } = req.params;
+ const { option_index } = req.body;
+
+ const trip = verifyTripAccess(tripId, req.user.id);
+ if (!trip) return res.status(404).json({ error: 'Trip not found' });
+
+ const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
+ if (!poll) return res.status(404).json({ error: 'Poll not found' });
+ if (poll.closed) return res.status(400).json({ error: 'Poll is closed' });
+
+ const options = JSON.parse(poll.options);
+ if (option_index < 0 || option_index >= options.length) {
+ return res.status(400).json({ error: 'Invalid option index' });
+ }
+
+ // Toggle: if vote exists, remove it; otherwise add it
+ const existingVote = db.prepare(
+ 'SELECT id FROM collab_poll_votes WHERE poll_id = ? AND user_id = ? AND option_index = ?'
+ ).get(id, req.user.id, option_index);
+
+ if (existingVote) {
+ db.prepare('DELETE FROM collab_poll_votes WHERE id = ?').run(existingVote.id);
+ } else {
+ // If not multiple choice, remove any existing votes by this user first
+ if (!poll.multiple) {
+ db.prepare('DELETE FROM collab_poll_votes WHERE poll_id = ? AND user_id = ?').run(id, req.user.id);
+ }
+ db.prepare(
+ 'INSERT INTO collab_poll_votes (poll_id, user_id, option_index) VALUES (?, ?, ?)'
+ ).run(id, req.user.id, option_index);
+ }
+
+ const updatedPoll = getPollWithVotes(id);
+
+ res.json({ poll: updatedPoll });
+ broadcast(tripId, 'collab:poll:voted', { poll: updatedPoll }, req.headers['x-socket-id']);
+});
+
+// PUT /polls/:id/close - close poll
+router.put('/polls/:id/close', authenticate, (req, res) => {
+ const { tripId, id } = req.params;
+
+ const trip = verifyTripAccess(tripId, req.user.id);
+ if (!trip) return res.status(404).json({ error: 'Trip not found' });
+
+ const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
+ if (!poll) return res.status(404).json({ error: 'Poll not found' });
+
+ db.prepare('UPDATE collab_polls SET closed = 1 WHERE id = ?').run(id);
+
+ const updatedPoll = getPollWithVotes(id);
+
+ res.json({ poll: updatedPoll });
+ broadcast(tripId, 'collab:poll:closed', { poll: updatedPoll }, req.headers['x-socket-id']);
+});
+
+// DELETE /polls/:id - delete poll
+router.delete('/polls/:id', authenticate, (req, res) => {
+ const { tripId, id } = req.params;
+
+ const trip = verifyTripAccess(tripId, req.user.id);
+ if (!trip) return res.status(404).json({ error: 'Trip not found' });
+
+ const poll = db.prepare('SELECT id FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
+ if (!poll) return res.status(404).json({ error: 'Poll not found' });
+
+ db.prepare('DELETE FROM collab_polls WHERE id = ?').run(id);
+ res.json({ success: true });
+ broadcast(tripId, 'collab:poll:deleted', { pollId: Number(id) }, req.headers['x-socket-id']);
+});
+
+// ─── MESSAGES (CHAT) ────────────────────────────────────────────────────────
+
+// GET /messages - list messages (last 100, with pagination via ?before=id)
+router.get('/messages', authenticate, (req, res) => {
+ const { tripId } = req.params;
+ const { before } = req.query;
+
+ const trip = verifyTripAccess(tripId, req.user.id);
+ if (!trip) return res.status(404).json({ error: 'Trip not found' });
+
+ let messages;
+ if (before) {
+ messages = db.prepare(`
+ SELECT m.*, u.username, u.avatar,
+ rm.text AS reply_text, ru.username AS reply_username
+ FROM collab_messages m
+ JOIN users u ON m.user_id = u.id
+ LEFT JOIN collab_messages rm ON m.reply_to = rm.id
+ LEFT JOIN users ru ON rm.user_id = ru.id
+ WHERE m.trip_id = ? AND m.id < ?
+ ORDER BY m.id DESC
+ LIMIT 100
+ `).all(tripId, before);
+ } else {
+ messages = db.prepare(`
+ SELECT m.*, u.username, u.avatar,
+ rm.text AS reply_text, ru.username AS reply_username
+ FROM collab_messages m
+ JOIN users u ON m.user_id = u.id
+ LEFT JOIN collab_messages rm ON m.reply_to = rm.id
+ LEFT JOIN users ru ON rm.user_id = ru.id
+ WHERE m.trip_id = ?
+ ORDER BY m.id DESC
+ LIMIT 100
+ `).all(tripId);
+ }
+
+ // Return in chronological order (oldest first)
+ messages.reverse();
+
+ res.json({ messages });
+});
+
+// POST /messages - send message
+router.post('/messages', authenticate, (req, res) => {
+ const { tripId } = req.params;
+ const { text, reply_to } = req.body;
+
+ const trip = verifyTripAccess(tripId, req.user.id);
+ if (!trip) return res.status(404).json({ error: 'Trip not found' });
+
+ if (!text || !text.trim()) return res.status(400).json({ error: 'Message text is required' });
+
+ // Validate reply_to if provided
+ if (reply_to) {
+ const replyMsg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(reply_to, tripId);
+ if (!replyMsg) return res.status(400).json({ error: 'Reply target message not found' });
+ }
+
+ const result = db.prepare(`
+ INSERT INTO collab_messages (trip_id, user_id, text, reply_to)
+ VALUES (?, ?, ?, ?)
+ `).run(tripId, req.user.id, text.trim(), reply_to || null);
+
+ const message = db.prepare(`
+ SELECT m.*, u.username, u.avatar,
+ rm.text AS reply_text, ru.username AS reply_username
+ FROM collab_messages m
+ JOIN users u ON m.user_id = u.id
+ LEFT JOIN collab_messages rm ON m.reply_to = rm.id
+ LEFT JOIN users ru ON rm.user_id = ru.id
+ WHERE m.id = ?
+ `).get(result.lastInsertRowid);
+
+ res.status(201).json({ message });
+ broadcast(tripId, 'collab:message:created', { message }, req.headers['x-socket-id']);
+});
+
+// DELETE /messages/:id - delete own message
+router.delete('/messages/:id', authenticate, (req, res) => {
+ const { tripId, id } = req.params;
+
+ const trip = verifyTripAccess(tripId, req.user.id);
+ if (!trip) return res.status(404).json({ error: 'Trip not found' });
+
+ const message = db.prepare('SELECT * FROM collab_messages WHERE id = ? AND trip_id = ?').get(id, tripId);
+ if (!message) return res.status(404).json({ error: 'Message not found' });
+
+ if (message.user_id !== req.user.id) {
+ return res.status(403).json({ error: 'You can only delete your own messages' });
+ }
+
+ db.prepare('DELETE FROM collab_messages WHERE id = ?').run(id);
+ res.json({ success: true });
+ broadcast(tripId, 'collab:message:deleted', { messageId: Number(id) }, req.headers['x-socket-id']);
+});
+
+module.exports = router;