import { useState, useEffect, useCallback, useRef } from 'react' import Header from './components/Header' import NameModal from './components/NameModal' import TabBar from './components/TabBar' import OptionList from './components/OptionList' import ResultsTab from './components/ResultsTab' import AddOption from './components/AddOption' import Toast from './components/Toast' import WsOverlay from './components/WsOverlay' import OptionModal from './components/OptionModal' import YourVotesModal from './components/YourVotesModal' import SocialToast from './components/SocialToast' import MapTab from './components/MapTab' import BudgetTab from './components/BudgetTab' import { useWebSocket } from './hooks/useWebSocket' import { useVoterSession } from './hooks/useVoterSession' import { useSound } from './hooks/useSound' import './App.css' const SOUND_KEY = 'cabo-voting-sound' const THEME_KEY = 'cabo-voting-theme' export default function App() { const [categories, setCategories] = useState([]) const [options, setOptions] = useState([]) const [pollsOpen, setPollsOpen] = useState(true) const [totalVoters, setTotalVoters] = useState(0) const [activeTab, setActiveTab] = useState('hotel') const [toast, setToast] = useState(null) const [selectedOption, setSelectedOption] = useState(null) // detail modal const [yourVotesOpen, setYourVotesOpen] = useState(false) // your votes modal const [socialToast, setSocialToast] = useState(null) // floating vote toast const [soundEnabled, setSoundEnabled] = useState(() => localStorage.getItem(SOUND_KEY) !== 'off') const [theme, setTheme] = useState(() => localStorage.getItem(THEME_KEY) || 'dark') const [onlineCount, setOnlineCount] = useState(0) const [pollDeadline, setPollDeadline] = useState(null) const socialToastTimer = useRef(null) const { voterName, setVoterName, clearVoter } = useVoterSession() const { playVoteSound, playRemoveSound } = useSound() const pollsExpired = pollDeadline && Date.now() > new Date(pollDeadline).getTime() const { wsRef, wsConnected, reconnect } = useWebSocket({ setCategories, setOptions, setPollsOpen, setTotalVoters, setOnlineCount, setPollDeadline, }) // Apply theme to body useEffect(() => { document.body.dataset.theme = theme localStorage.setItem(THEME_KEY, theme) }, [theme]) const showToast = useCallback((msg, type = '') => { setToast({ msg, type }) setTimeout(() => setToast(null), 3000) }, []) const handleVote = useCallback((option, removed = false) => { if (!voterName) return if (!pollsOpen || pollsExpired) return const opt = options.find(o => o.id === option.id) if (!opt) return const alreadyVoted = opt.votes?.some(v => v.name === voterName) // Optimistic update setOptions(prev => prev.map(o => { if (o.id !== option.id) return o if (removed || alreadyVoted) { return { ...o, votes: o.votes.filter(v => v.name !== voterName) } } else { return { ...o, votes: [...(o.votes || []), { name: voterName, timestamp: Date.now() }] } } })) const ws = wsRef.current if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'vote', optionId: option.id, voterName, remove: removed || alreadyVoted })) } if (removed || alreadyVoted) { playRemoveSound() showToast(`Removed vote for ${opt.name}`) } else { playVoteSound() showToast(`Voted for ${opt.name}!`) } // Social toast for others (shows voter name + option name) setSocialToast({ voterName, optionName: opt.name, categoryId: opt.categoryId }) clearTimeout(socialToastTimer.current) socialToastTimer.current = setTimeout(() => setSocialToast(null), 3000) }, [voterName, options, pollsOpen, pollsExpired, wsRef, playVoteSound, playRemoveSound, showToast]) const handleRemoveVote = useCallback((optionId) => { const opt = options.find(o => o.id === optionId) if (opt) handleVote(opt, true) }, [options, handleVote]) // Check URL params on load useEffect(() => { const params = new URLSearchParams(location.search) if (params.get('view') === 'results') setActiveTab('results') const optionId = params.get('option') if (optionId) { // Wait for options to load, then open modal const check = () => { const opt = options.find(o => o.id === optionId) if (opt) { setSelectedOption(opt); } else if (options.length > 0) { setSelectedOption(null) } } check() } }, []) // eslint-disable-line const handleAddSubmit = useCallback((data) => { if (!voterName) { showToast('Enter your name first', 'error'); return } const ws = wsRef.current if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'add_option', ...data, voterName })) } document.getElementById('add-name').value = '' document.getElementById('add-desc').value = '' document.getElementById('add-url').value = '' showToast(`Submitted "${data.name}" for approval!`, 'success') }, [voterName, wsRef, showToast]) const toggleSound = useCallback(() => { setSoundEnabled(prev => { const next = !prev localStorage.setItem(SOUND_KEY, next ? 'on' : 'off') return next }) }, []) const toggleTheme = useCallback(() => { setTheme(prev => prev === 'dark' ? 'light' : 'dark') }, []) const votingCats = categories.filter(c => c.id !== 'results') const optionCounts = votingCats.reduce((acc, cat) => { acc[cat.id] = options.filter(o => o.categoryId === cat.id).length return acc }, {}) // "Your Votes" — all options this voter voted for const yourVotes = options.filter(o => o.votes?.some(v => v.name === voterName)) return ( <> Skip to content
{!voterName && } setYourVotesOpen(true)} voterName={voterName} yourVotesCount={yourVotes.length} />
{activeTab === 'results' ? ( ) : activeTab === 'map' ? ( ) : activeTab === 'budget' ? ( ) : ( o.categoryId === activeTab && o.approved)} voterName={voterName} pollsOpen={pollsOpen} pollsExpired={pollsExpired} onVote={handleVote} onCardClick={setSelectedOption} categoryId={activeTab} /> )} {activeTab !== 'results' && activeTab !== 'map' && activeTab !== 'budget' && ( showToast('Enter your name first', 'error')} /> )}
{/* Option detail modal */} {selectedOption && ( setSelectedOption(null)} onVote={handleVote} categories={categories} /> )} {/* Your votes modal */} {yourVotesOpen && ( setYourVotesOpen(false)} onRemoveVote={handleRemoveVote} onViewOption={setSelectedOption} categories={categories} /> )} {toast && } {socialToast && ( )} ) }