feat: add BudgetTab with live vote-driven cost calculation
- 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
This commit is contained in:
256
client/src/App.jsx
Normal file
256
client/src/App.jsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
199
client/src/components/BudgetTab.css
Normal file
199
client/src/components/BudgetTab.css
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
.budget-tab {
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-header h2 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-header p {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-leaders {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-leaders h3 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaders-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leader-card {
|
||||||
|
background: var(--card-bg, #1e1e2e);
|
||||||
|
border: 1px solid var(--border, #2a2a3a);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.875rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leader-cat {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leader-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leader-name.no-votes {
|
||||||
|
opacity: 0.4;
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leader-tier {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leader-votes {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tier pills */
|
||||||
|
.budget-tiers {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-card {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.6rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid var(--border, #2a2a3a);
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-card.active {
|
||||||
|
opacity: 1;
|
||||||
|
border-color: var(--tier-color);
|
||||||
|
background: color-mix(in srgb, var(--tier-color) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
background: var(--tier-color);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breakdown table */
|
||||||
|
.budget-breakdown h3 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-table {
|
||||||
|
background: var(--card-bg, #1e1e2e);
|
||||||
|
border: 1px solid var(--border, #2a2a3a);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr repeat(3, 80px);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-bottom: 1px solid var(--border, #2a2a3a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-row.header-row {
|
||||||
|
font-weight: 700;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--bg-secondary, #16161e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-row span {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-row span:first-child {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-row.total-row {
|
||||||
|
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-row.group-row {
|
||||||
|
background: var(--bg-secondary, #16161e);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-note {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder */
|
||||||
|
.budget-placeholder {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-placeholder p {
|
||||||
|
max-width: 300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
243
client/src/components/BudgetTab.jsx
Normal file
243
client/src/components/BudgetTab.jsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import './BudgetTab.css'
|
||||||
|
|
||||||
|
// Map options to cost tiers based on seed data pricing signals
|
||||||
|
const TIER_BY_OPTION = {
|
||||||
|
// Hotels
|
||||||
|
'hotel-corazon': 'budget',
|
||||||
|
'hotel-grand-fiesta': 'balanced',
|
||||||
|
'hotel-pacifica': 'splurge',
|
||||||
|
'hotel-breathless': 'splurge',
|
||||||
|
'hotel-secrets': 'splurge',
|
||||||
|
// Golf
|
||||||
|
'golf-palmilla': 'budget',
|
||||||
|
'golf-cabo-del-sol': 'balanced',
|
||||||
|
'golf-quivira': 'splurge',
|
||||||
|
'golf-puerto-los-cabos': 'balanced',
|
||||||
|
// Nightlife
|
||||||
|
'nightlife-mango-deck': 'budget',
|
||||||
|
'nightlife-cabo-bash': 'balanced',
|
||||||
|
'nightlife-cabo-agency': 'balanced',
|
||||||
|
'nightlife-taboo': 'splurge',
|
||||||
|
// Excursions
|
||||||
|
'excursion-whale-public': 'budget',
|
||||||
|
'excursion-atv': 'balanced',
|
||||||
|
'excursion-sail': 'balanced',
|
||||||
|
'excursion-whale-private': 'splurge',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cost per person by category and tier (from seed data planning numbers)
|
||||||
|
const COST_PER_PERSON = {
|
||||||
|
hotel: { budget: 450, balanced: 850, splurge: 1250 },
|
||||||
|
golf: { budget: 130, balanced: 180, splurge: 250 },
|
||||||
|
nightlife: { budget: 40, balanced: 100, splurge: 200 },
|
||||||
|
excursion: { budget: 76, balanced: 110, splurge: 188 },
|
||||||
|
// Fixed costs
|
||||||
|
flight: 350,
|
||||||
|
transfers: { budget: 33, balanced: 83, splurge: 183 },
|
||||||
|
foodBuffer: 275,
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_SIZES = [8, 10, 12]
|
||||||
|
|
||||||
|
const CATEGORY_LABELS = {
|
||||||
|
hotel: '🏨 Hotel',
|
||||||
|
golf: '⛳ Golf',
|
||||||
|
nightlife: '🎧 Nightlife',
|
||||||
|
excursion: '🚤 Excursion',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptionTier(optionId) {
|
||||||
|
return TIER_BY_OPTION[optionId] || 'balanced'
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcTierTotal(tier, groupSize) {
|
||||||
|
const hotel = COST_PER_PERSON.hotel[tier]
|
||||||
|
const golf = COST_PER_PERSON.golf[tier]
|
||||||
|
const nightlife = COST_PER_PERSON.nightlife[tier]
|
||||||
|
const excursion = COST_PER_PERSON.excursion[tier]
|
||||||
|
const flight = COST_PER_PERSON.flight
|
||||||
|
const transfer = COST_PER_PERSON.transfers[tier]
|
||||||
|
const food = COST_PER_PERSON.foodBuffer
|
||||||
|
|
||||||
|
const total = flight + hotel + golf + nightlife + excursion + transfer + food
|
||||||
|
const perPerson = Math.round(total * 100) / 100
|
||||||
|
return { total: perPerson * groupSize, perPerson }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWinningOption(options, categoryId) {
|
||||||
|
const catOpts = options.filter(o => o.categoryId === categoryId && o.approved)
|
||||||
|
if (!catOpts.length) return null
|
||||||
|
return catOpts.reduce((best, o) =>
|
||||||
|
(o.votes?.length || 0) > (best.votes?.length || 0) ? o : best
|
||||||
|
, catOpts[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTierFromCategoryVotes(options, categoryId) {
|
||||||
|
const winning = getWinningOption(options, categoryId)
|
||||||
|
if (!winning) return 'balanced'
|
||||||
|
return getOptionTier(winning.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dominantTier(tiers) {
|
||||||
|
const counts = { budget: 0, balanced: 0, splurge: 0 }
|
||||||
|
Object.values(tiers).forEach(t => { if (t) counts[t] = (counts[t] || 0) + 1 })
|
||||||
|
if (counts.splurge >= 2) return 'splurge'
|
||||||
|
if (counts.budget >= 2) return 'budget'
|
||||||
|
// Find the most common tier
|
||||||
|
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'balanced'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BudgetTab({ options, categories }) {
|
||||||
|
const votingCategories = ['hotel', 'golf', 'nightlife', 'excursion']
|
||||||
|
|
||||||
|
const leaders = useMemo(() => {
|
||||||
|
const result = {}
|
||||||
|
votingCategories.forEach(cat => {
|
||||||
|
result[cat] = getWinningOption(options, cat)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}, [options])
|
||||||
|
|
||||||
|
const tiers = useMemo(() => {
|
||||||
|
const t = {}
|
||||||
|
votingCategories.forEach(cat => {
|
||||||
|
t[cat] = getTierFromCategoryVotes(options, cat)
|
||||||
|
})
|
||||||
|
return t
|
||||||
|
}, [options])
|
||||||
|
|
||||||
|
const overallTier = useMemo(() => dominantTier(tiers), [tiers])
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
return GROUP_SIZES.map(size => {
|
||||||
|
const tier = overallTier
|
||||||
|
const { perPerson, total } = calcTierTotal(tier, size)
|
||||||
|
return { size, tier, perPerson, total }
|
||||||
|
})
|
||||||
|
}, [overallTier])
|
||||||
|
|
||||||
|
const tierLabel = { budget: '💰 Budget', balanced: '⚖️ Balanced', splurge: '💎 Splurge' }
|
||||||
|
const tierColor = { budget: '#22c55e', balanced: '#3b82f6', splurge: '#f97316' }
|
||||||
|
|
||||||
|
const hasAnyVotes = Object.values(leaders).some(o => o && o.votes?.length > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="budget-tab">
|
||||||
|
<div className="budget-header">
|
||||||
|
<h2>Trip Budget Calculator</h2>
|
||||||
|
<p>Costs update live based on leading votes in each category</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category leaders */}
|
||||||
|
<div className="budget-leaders">
|
||||||
|
<h3>Current Leaders by Category</h3>
|
||||||
|
<div className="leaders-grid">
|
||||||
|
{votingCategories.map(cat => {
|
||||||
|
const leader = leaders[cat]
|
||||||
|
const catTier = tiers[cat]
|
||||||
|
return (
|
||||||
|
<div key={cat} className="leader-card" style={{ '--cat-color': CATEGORY_LABELS[cat].split(' ')[0] }}>
|
||||||
|
<div className="leader-cat">{CATEGORY_LABELS[cat]}</div>
|
||||||
|
{leader && leader.votes?.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="leader-name">{leader.name}</div>
|
||||||
|
<div className="leader-tier" style={{ color: tierColor[catTier] }}>
|
||||||
|
{tierLabel[catTier]}
|
||||||
|
</div>
|
||||||
|
<div className="leader-votes">{leader.votes.length} vote{leader.votes.length !== 1 ? 's' : ''}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="leader-name no-votes">No votes yet</div>
|
||||||
|
<div className="leader-tier" style={{ color: '#888' }}>—</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tier selector — highlight dominant */}
|
||||||
|
<div className="budget-tiers">
|
||||||
|
{['budget', 'balanced', 'splurge'].map(tier => (
|
||||||
|
<div
|
||||||
|
key={tier}
|
||||||
|
className={`tier-card ${overallTier === tier ? 'active' : ''}`}
|
||||||
|
style={{ '--tier-color': tierColor[tier] }}
|
||||||
|
>
|
||||||
|
<div className="tier-label">{tierLabel[tier]}</div>
|
||||||
|
{overallTier === tier && <div className="tier-badge">Current</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cost breakdown */}
|
||||||
|
{hasAnyVotes ? (
|
||||||
|
<div className="budget-breakdown">
|
||||||
|
<h3>Estimated Cost — {tierLabel[overallTier]} Tier</h3>
|
||||||
|
<div className="breakdown-table">
|
||||||
|
<div className="breakdown-row header-row">
|
||||||
|
<span>Line Item</span>
|
||||||
|
{GROUP_SIZES.map(s => <span key={s}>{s} guys</span>)}
|
||||||
|
</div>
|
||||||
|
<div className="breakdown-row">
|
||||||
|
<span>✈️ Flight estimate</span>
|
||||||
|
{GROUP_SIZES.map(s => <span key={s}>${(COST_PER_PERSON.flight).toLocaleString()}</span>)}
|
||||||
|
</div>
|
||||||
|
<div className="breakdown-row">
|
||||||
|
<span>🏨 Hotel ({COST_PER_PERSON.hotel[overallTier]}/pp)</span>
|
||||||
|
{GROUP_SIZES.map(s => <span key={s}>${(COST_PER_PERSON.hotel[overallTier]).toLocaleString()}</span>)}
|
||||||
|
</div>
|
||||||
|
<div className="breakdown-row">
|
||||||
|
<span>⛳ Golf ({COST_PER_PERSON.golf[overallTier]}/pp)</span>
|
||||||
|
{GROUP_SIZES.map(s => <span key={s}>${(COST_PER_PERSON.golf[overallTier]).toLocaleString()}</span>)}
|
||||||
|
</div>
|
||||||
|
<div className="breakdown-row">
|
||||||
|
<span>🎧 Nightlife ({COST_PER_PERSON.nightlife[overallTier]}/pp)</span>
|
||||||
|
{GROUP_SIZES.map(s => <span key={s}>${(COST_PER_PERSON.nightlife[overallTier]).toLocaleString()}</span>)}
|
||||||
|
</div>
|
||||||
|
<div className="breakdown-row">
|
||||||
|
<span>🚤 Excursion ({COST_PER_PERSON.excursion[overallTier]}/pp)</span>
|
||||||
|
{GROUP_SIZES.map(s => <span key={s}>${(COST_PER_PERSON.excursion[overallTier]).toLocaleString()}</span>)}
|
||||||
|
</div>
|
||||||
|
<div className="breakdown-row">
|
||||||
|
<span>🚗 Transfers</span>
|
||||||
|
{GROUP_SIZES.map(s => <span key={s}>${(COST_PER_PERSON.transfers[overallTier]).toLocaleString()}</span>)}
|
||||||
|
</div>
|
||||||
|
<div className="breakdown-row">
|
||||||
|
<span>🍽️ Food + drinks buffer</span>
|
||||||
|
{GROUP_SIZES.map(s => <span key={s}>$${COST_PER_PERSON.foodBuffer}</span>)}
|
||||||
|
</div>
|
||||||
|
<div className="breakdown-row total-row">
|
||||||
|
<span>Est. Total / Person</span>
|
||||||
|
{totals.map(t => (
|
||||||
|
<span key={t.size} style={{ color: tierColor[overallTier], fontWeight: 700 }}>
|
||||||
|
${t.perPerson.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="breakdown-row group-row">
|
||||||
|
<span>Est. Group Total</span>
|
||||||
|
{totals.map(t => (
|
||||||
|
<span key={t.size} style={{ color: tierColor[overallTier], fontWeight: 700 }}>
|
||||||
|
${t.total.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="budget-note">
|
||||||
|
* Estimates based on current leading votes. Prices may vary. Add extra for bar tabs and tips.
|
||||||
|
Price data from {options.find(o => o.categoryId === 'hotel')?.details?.[0]?.match(/\d{4}-\d{2}-\d{2}/)?.[0] || 'April 2026'}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="budget-placeholder">
|
||||||
|
<div className="placeholder-icon">🗳️</div>
|
||||||
|
<p>No votes cast yet. Vote in the Hotel, Golf, Nightlife, and Excursion tabs and the budget will update automatically.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user