- BudgetTab shows current leaders per category (hotel/golf/nightlife/excursion) - Dominant tier (budget/balanced/splurge) auto-detected from votes - Per-person and group totals for 8/10/12 guy scenarios - Built on seed data pricing signals for accuracy
257 lines
8.6 KiB
JavaScript
257 lines
8.6 KiB
JavaScript
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 (
|
|
<>
|
|
<a className="skip-link" href="#main">Skip to content</a>
|
|
|
|
<Header
|
|
voterName={voterName}
|
|
pollsOpen={pollsOpen}
|
|
totalVoters={totalVoters}
|
|
wsConnected={wsConnected}
|
|
onChangeName={clearVoter}
|
|
soundEnabled={soundEnabled}
|
|
onToggleSound={toggleSound}
|
|
theme={theme}
|
|
onToggleTheme={toggleTheme}
|
|
onlineCount={onlineCount}
|
|
pollDeadline={pollDeadline}
|
|
pollsExpired={pollsExpired}
|
|
/>
|
|
|
|
{!voterName && <NameModal onSubmit={setVoterName} />}
|
|
|
|
<TabBar
|
|
categories={categories}
|
|
activeTab={activeTab}
|
|
onTab={setActiveTab}
|
|
optionCounts={optionCounts}
|
|
onYourVotes={() => setYourVotesOpen(true)}
|
|
voterName={voterName}
|
|
yourVotesCount={yourVotes.length}
|
|
/>
|
|
|
|
<main id="main">
|
|
{activeTab === 'results' ? (
|
|
<ResultsTab
|
|
categories={categories}
|
|
options={options}
|
|
pollsOpen={pollsOpen}
|
|
totalVoters={totalVoters}
|
|
/>
|
|
) : activeTab === 'map' ? (
|
|
<MapTab
|
|
options={options}
|
|
categories={categories}
|
|
onSelectOption={setSelectedOption}
|
|
/>
|
|
) : activeTab === 'budget' ? (
|
|
<BudgetTab
|
|
options={options}
|
|
categories={categories}
|
|
/>
|
|
) : (
|
|
<OptionList
|
|
options={options.filter(o => o.categoryId === activeTab && o.approved)}
|
|
voterName={voterName}
|
|
pollsOpen={pollsOpen}
|
|
pollsExpired={pollsExpired}
|
|
onVote={handleVote}
|
|
onCardClick={setSelectedOption}
|
|
categoryId={activeTab}
|
|
/>
|
|
)}
|
|
|
|
{activeTab !== 'results' && activeTab !== 'map' && activeTab !== 'budget' && (
|
|
<AddOption
|
|
categories={categories}
|
|
voterName={voterName}
|
|
onSubmit={handleAddSubmit}
|
|
onNeedsName={() => showToast('Enter your name first', 'error')}
|
|
/>
|
|
)}
|
|
</main>
|
|
|
|
{/* Option detail modal */}
|
|
{selectedOption && (
|
|
<OptionModal
|
|
option={selectedOption}
|
|
voterName={voterName}
|
|
onClose={() => setSelectedOption(null)}
|
|
onVote={handleVote}
|
|
categories={categories}
|
|
/>
|
|
)}
|
|
|
|
{/* Your votes modal */}
|
|
{yourVotesOpen && (
|
|
<YourVotesModal
|
|
votes={yourVotes}
|
|
voterName={voterName}
|
|
onClose={() => setYourVotesOpen(false)}
|
|
onRemoveVote={handleRemoveVote}
|
|
onViewOption={setSelectedOption}
|
|
categories={categories}
|
|
/>
|
|
)}
|
|
|
|
<WsOverlay connected={wsConnected} onReconnect={reconnect} />
|
|
{toast && <Toast msg={toast.msg} type={toast.type} />}
|
|
{socialToast && (
|
|
<SocialToast
|
|
voterName={socialToast.voterName}
|
|
optionName={socialToast.optionName}
|
|
categoryId={socialToast.categoryId}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|