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' import { useToast } from '../shared/Toast' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import ReactDOM from 'react-dom' import type { User } from '../../types' interface PollVoter { user_id: number username: string avatar_url: string | null } interface PollOption { id: number text: string voters: PollVoter[] } interface Poll { id: number question: string options: PollOption[] multi_choice: boolean is_closed: boolean deadline: string | null created_by: number created_at: string } const FONT = "var(--font-system)" 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) { return (poll.options || []).reduce((s, o) => s + (o.voters?.length || 0), 0) } // ── Create Poll Modal ──────────────────────────────────────────────────────── interface CreatePollModalProps { onClose: () => void onCreate: (data: { question: string; options: string[]; multi_choice: boolean }) => Promise t: (key: string) => string } function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) { const [question, setQuestion] = useState('') const [options, setOptions] = useState(['', '']) const [multiChoice, setMultiChoice] = useState(false) const [submitting, setSubmitting] = useState(false) const addOption = () => setOptions(prev => [...prev, '']) const removeOption = (i) => setOptions(prev => prev.filter((_, j) => j !== i)) const updateOption = (i, v) => setOptions(prev => prev.map((o, j) => j === i ? v : o)) const canSubmit = question.trim() && options.filter(o => o.trim()).length >= 2 && !submitting const handleSubmit = async (e) => { e.preventDefault() if (!canSubmit) return setSubmitting(true) try { await onCreate({ question: question.trim(), options: options.filter(o => o.trim()), multi_choice: multiChoice }) onClose() } catch {} finally { setSubmitting(false) } } return ReactDOM.createPortal(
e.stopPropagation()} onSubmit={handleSubmit}>

{t('collab.polls.new')}

{/* Question */}
{t('collab.polls.question')}
setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder') || 'Ask a question...'} 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' }} />
{/* Options */}
{t('collab.polls.options')}
{options.map((opt, i) => (
updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`} style={{ flex: 1, 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' }} /> {options.length > 2 && ( )}
))}
{/* Multi choice toggle */}
, document.body ) } // ── Voter Chip with custom tooltip ──────────────────────────────────────────── interface VoterChipProps { voter: PollVoter offset: boolean } function VoterChip({ voter, offset }: VoterChipProps) { const [hover, setHover] = useState(false) const ref = React.useRef(null) const [pos, setPos] = useState({ top: 0, left: 0 }) return ( <>
{ if (ref.current) { const rect = ref.current.getBoundingClientRect() setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 }) } setHover(true) }} onMouseLeave={() => setHover(false)} style={{ width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 7, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', border: '1.5px solid var(--bg-card)', marginLeft: offset ? -5 : 0, flexShrink: 0, }}> {voter.avatar_url ? : (voter.username || '?')[0].toUpperCase()}
{hover && ReactDOM.createPortal(
{voter.username}
, document.body )} ) } // ── Poll Card ──────────────────────────────────────────────────────────────── interface PollCardProps { poll: Poll currentUser: User canEdit: boolean onVote: (pollId: number, optionId: number) => Promise onClose: (pollId: number) => Promise onDelete: (pollId: number) => Promise t: (key: string) => string } function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }: PollCardProps) { const total = totalVotes(poll) const isClosed = poll.is_closed || isExpired(poll.deadline) const remaining = timeRemaining(poll.deadline) const hasVoted = (poll.options || []).some(o => (o.voters || []).some(v => String(v.user_id) === String(currentUser.id))) return (
{/* Header */}
{poll.question}
{isClosed && ( {t('collab.polls.closed')} )} {remaining && !isClosed && ( {remaining} )} {poll.multi_choice && ( {t('collab.polls.multiChoice')} )} {total} {total === 1 ? 'vote' : 'votes'}
{/* Actions */} {canEdit && (
{!isClosed && ( )}
)}
{/* Options */}
{(poll.options || []).map((opt, idx) => { const count = opt.voters?.length || 0 const pct = total > 0 ? Math.round((count / total) * 100) : 0 const myVote = (opt.voters || []).some(v => String(v.user_id) === String(currentUser.id)) const isWinner = isClosed && count === Math.max(...(poll.options || []).map(o => o.voters?.length || 0)) && count > 0 return ( ) })}
) } // ── Main Component ─────────────────────────────────────────────────────────── interface CollabPollsProps { tripId: number currentUser: User } 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) const [polls, setPolls] = useState([]) const [loading, setLoading] = useState(true) const [showForm, setShowForm] = useState(false) useEffect(() => { collabApi.getPolls(tripId).then(data => { setPolls(Array.isArray(data) ? data : data.polls || []) }).catch(() => {}).finally(() => setLoading(false)) }, [tripId]) // WebSocket useEffect(() => { const handler = (msg) => { if (!msg?.type) return if (msg.type === 'collab:poll:created' && msg.poll) { setPolls(prev => prev.some(p => p.id === msg.poll.id) ? prev : [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 id = msg.pollId || msg.poll?.id if (id) setPolls(prev => prev.filter(p => p.id !== id)) } } addListener(handler) return () => removeListener(handler) }, []) const handleCreate = useCallback(async (data) => { 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 { 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 { 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 { 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)) // Deadline ticker const [, setTick] = useState(0) useEffect(() => { if (!polls.some(p => p.deadline && !p.is_closed)) return const iv = setInterval(() => setTick(t => t + 1), 30000) return () => clearInterval(iv) }, [polls]) if (loading) { return (
) } return (
{/* Header */}

{t('collab.polls.title')}

{canEdit && ( )}
{/* Content */}
{polls.length === 0 ? (
{t('collab.polls.empty')}
{t('collab.polls.emptyHint')}
) : (
{activePolls.length > 0 && activePolls.map(poll => ( ))} {closedPolls.length > 0 && ( <> {activePolls.length > 0 && (
{t('collab.polls.closedSection') || 'Closed'}
)} {closedPolls.map(poll => ( ))} )}
)}
{/* Create Modal */} {showForm && setShowForm(false)} onCreate={handleCreate} t={t} />}
) }