diff --git a/client/src/App.jsx b/client/src/App.jsx new file mode 100644 index 0000000..de9d83e --- /dev/null +++ b/client/src/App.jsx @@ -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 ( + <> + 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 && ( + + )} + + ) +} diff --git a/client/src/components/BudgetTab.css b/client/src/components/BudgetTab.css new file mode 100644 index 0000000..f2ed28f --- /dev/null +++ b/client/src/components/BudgetTab.css @@ -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; +} diff --git a/client/src/components/BudgetTab.jsx b/client/src/components/BudgetTab.jsx new file mode 100644 index 0000000..9a3d887 --- /dev/null +++ b/client/src/components/BudgetTab.jsx @@ -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 ( +
+
+

Trip Budget Calculator

+

Costs update live based on leading votes in each category

+
+ + {/* Category leaders */} +
+

Current Leaders by Category

+
+ {votingCategories.map(cat => { + const leader = leaders[cat] + const catTier = tiers[cat] + return ( +
+
{CATEGORY_LABELS[cat]}
+ {leader && leader.votes?.length > 0 ? ( + <> +
{leader.name}
+
+ {tierLabel[catTier]} +
+
{leader.votes.length} vote{leader.votes.length !== 1 ? 's' : ''}
+ + ) : ( + <> +
No votes yet
+
+ + )} +
+ ) + })} +
+
+ + {/* Tier selector — highlight dominant */} +
+ {['budget', 'balanced', 'splurge'].map(tier => ( +
+
{tierLabel[tier]}
+ {overallTier === tier &&
Current
} +
+ ))} +
+ + {/* Cost breakdown */} + {hasAnyVotes ? ( +
+

Estimated Cost — {tierLabel[overallTier]} Tier

+
+
+ Line Item + {GROUP_SIZES.map(s => {s} guys)} +
+
+ ✈️ Flight estimate + {GROUP_SIZES.map(s => ${(COST_PER_PERSON.flight).toLocaleString()})} +
+
+ 🏨 Hotel ({COST_PER_PERSON.hotel[overallTier]}/pp) + {GROUP_SIZES.map(s => ${(COST_PER_PERSON.hotel[overallTier]).toLocaleString()})} +
+
+ ⛳ Golf ({COST_PER_PERSON.golf[overallTier]}/pp) + {GROUP_SIZES.map(s => ${(COST_PER_PERSON.golf[overallTier]).toLocaleString()})} +
+
+ 🎧 Nightlife ({COST_PER_PERSON.nightlife[overallTier]}/pp) + {GROUP_SIZES.map(s => ${(COST_PER_PERSON.nightlife[overallTier]).toLocaleString()})} +
+
+ 🚤 Excursion ({COST_PER_PERSON.excursion[overallTier]}/pp) + {GROUP_SIZES.map(s => ${(COST_PER_PERSON.excursion[overallTier]).toLocaleString()})} +
+
+ 🚗 Transfers + {GROUP_SIZES.map(s => ${(COST_PER_PERSON.transfers[overallTier]).toLocaleString()})} +
+
+ 🍽️ Food + drinks buffer + {GROUP_SIZES.map(s => $${COST_PER_PERSON.foodBuffer})} +
+
+ Est. Total / Person + {totals.map(t => ( + + ${t.perPerson.toLocaleString()} + + ))} +
+
+ Est. Group Total + {totals.map(t => ( + + ${t.total.toLocaleString()} + + ))} +
+
+

+ * 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'}. +

+
+ ) : ( +
+
🗳️
+

No votes cast yet. Vote in the Hotel, Golf, Nightlife, and Excursion tabs and the budget will update automatically.

+
+ )} +
+ ) +}