commit 6ed5f360ac58053bfbe78556b530acf5375ca2c4 Author: Christopher Mayor Date: Tue Apr 28 20:55:42 2026 -0700 Initial commit: Cabo Bachelor Party voting app - Node.js/Express + WebSocket real-time voting - Hotel, Golf, Nightlife, Excursion, Itinerary categories - Seed data with 20+ Cabo venues and 3 itineraries - Gitea issues: #1-16 for UI/UX improvements diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1044ac1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +data/ +.DS_Store diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..c1f1a0b --- /dev/null +++ b/public/index.html @@ -0,0 +1,712 @@ + + + + + + Cabo Bachelor Party — Vote + + + + + + + + +
+

📍 Cabo Bachelor Party — Vote

+
+ +
+
+ + +
+ + +
+
+
Connecting…
+
POLLS OPEN
+
+
+ +
+
Loading options…
+
+ + +
+

➕ Suggest a Place

+
+ + + +
+ + +
+
+
+
+ + +
+ + + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..490ca94 --- /dev/null +++ b/server.js @@ -0,0 +1,267 @@ +const express = require('express'); +const { WebSocketServer } = require('ws'); +const cors = require('cors'); +const http = require('http'); +const path = require('path'); +const fs = require('fs'); +const { v4: uuidv4 } = require('uuid'); + +const app = express(); +const server = http.createServer(app); +const wss = new WebSocketServer({ server }); + +const DATA_DIR = path.join(__dirname, 'data'); +const DATA_FILE = path.join(DATA_DIR, 'votes.json'); + +app.use(cors()); +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'public'))); + +// ── Data helpers ────────────────────────────────────────────── + +function loadData() { + if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); + if (!fs.existsSync(DATA_FILE)) { + const seed = buildSeedData(); + fs.writeFileSync(DATA_FILE, JSON.stringify(seed, null, 2)); + return seed; + } + return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); +} + +function saveData(data) { + fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2)); +} + +function broadcast(payload) { + const msg = JSON.stringify(payload); + wss.clients.forEach(client => { + if (client.readyState === 1) client.send(msg); + }); +} + +// ── Seed data ───────────────────────────────────────────────── + +function buildSeedData() { + return { + categories: [ + { id: 'hotel', name: 'Hotels', emoji: '🏨' }, + { id: 'golf', name: 'Golf', emoji: '⛳' }, + { id: 'nightlife', name: 'Nightlife', emoji: '🎧' }, + { id: 'excursion', name: 'Excursions', emoji: '🚤' }, + { id: 'itinerary', name: 'Full Itineraries', emoji: '🗺️' }, + ], + options: [ + // Hotels + { id: uuidv4(), categoryId: 'hotel', name: 'Grand Fiesta Americana', desc: 'Premium all-inclusive · Golf packages · 5⭐ · ~$223/night', url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos', addedBy: 'system', approved: true, votes: [] }, + { id: uuidv4(), categoryId: 'hotel', name: 'Hotel Riu Palace', desc: 'High-energy beachfront · 5⭐ · ~$250/night', url: 'https://www.riu.com/en/hotel/los-cabos/hotel-riu-palace-cabo-san-lucas/', addedBy: 'system', approved: true, votes: [] }, + { id: uuidv4(), categoryId: 'hotel', name: 'Marquis Los Cabos', desc: 'Luxury adults-only · Infinity pool · ~$300/night', url: 'https://www.marquisloscabos.com', addedBy: 'system', approved: true, votes: [] }, + { id: uuidv4(), categoryId: 'hotel', name: 'Pueblo Bonito Pacifica', desc: 'Exclusive Quivira Golf Club access · Adults-only · ~$280/night', url: 'https://www.pueblobonito.com/pacifica', addedBy: 'system', approved: true, votes: [] }, + { id: uuidv4(), categoryId: 'hotel', name: 'ME Cabo', desc: 'Adults-only · Buzzing beach club · ~$200/night', url: 'https://www.mecabo.com', addedBy: 'system', approved: true, votes: [] }, + { id: uuidv4(), categoryId: 'hotel', name: 'Hacienda Encantada', desc: 'Condo-feel all-inclusive · 2BR Suites · ~$180/night', url: 'https://www.haciendaencantada.com', addedBy: 'system', approved: true, votes: [] }, + // Golf + { id: uuidv4(), categoryId: 'golf', name: 'Quivira Golf Club', desc: 'Jack Nicklaus signature · Ocean views · $250/round', url: 'https://quiviraloscabos.com', addedBy: 'system', approved: true, votes: [] }, + { id: uuidv4(), categoryId: 'golf', name: 'Cabo Del Sol Golf', desc: 'Desert-ocean layout · 18 holes · $180/round', url: 'https://www.cabodelsol.com/golf', addedBy: 'system', approved: true, votes: [] }, + { id: uuidv4(), categoryId: 'golf', name: 'Solmar Golf Links', desc: 'Seaside championship course · $160/round', url: 'https://www.solmargolflinks.com', addedBy: 'system', approved: true, votes: [] }, + // Nightlife + { id: uuidv4(), categoryId: 'nightlife', name: 'El Squid Roe', desc: '3 floors · $40–50 cover · Open til 4am', url: 'https://www.elsquidroe.com', addedBy: 'system', approved: true, votes: [] }, + { id: uuidv4(), categoryId: 'nightlife', name: 'Mandala Nightclub', desc: 'VIP tables · $50 cover · High-energy', url: 'https://www.mandalacabo.com', addedBy: 'system', approved: true, votes: [] }, + { id: uuidv4(), categoryId: 'nightlife', name: 'Cabo Wabo Cantina', desc: "Sammy Hagar's · Live music · $30 cover", url: 'https://www.cabowabocantina.com', addedBy: 'system', approved: true, votes: [] }, + { id: uuidv4(), categoryId: 'nightlife', name: 'Crush Nightspot', desc: 'Upscale lounge · Craft cocktails · ~$30 cover', url: 'https://www.crushcabo.com', addedBy: 'system', approved: true, votes: [] }, + // Excursions + { id: uuidv4(), categoryId: 'excursion', name: 'Private Yacht to The Arch', desc: 'Quivira Yacht Club · $250/person · 2hr', url: 'https://www.viator.com/tours/Los-Cabos/Private-Luxury-Yacht-Charter/d637-11242P30', addedBy: 'system', approved: true, votes: [] }, + { id: uuidv4(), categoryId: 'excursion', name: 'Wild Canyon Adventure', desc: 'Zipline · Bungee jump · $80/person', url: 'https://www.viator.com/tours/Los-Cabos/Wild-Canyon-Adventure/d637-11166P4', addedBy: 'system', approved: true, votes: [] }, + { id: uuidv4(), categoryId: 'excursion', name: 'ATV Desert Adventure', desc: 'Cerro de la Zanta · $100/person · 3hr', url: 'https://www.viator.com/tours/Los-Cabos/ATV-Desert-Adventure-from-Cabo-San-Lucas/d637-11166P1', addedBy: 'system', approved: true, votes: [] }, + { id: uuidv4(), categoryId: 'excursion', name: 'Sunset Sail Cruise', desc: 'Medano Beach departure · $80/person · 2hr', url: 'https://www.viator.com/tours/Los-Cabos/Sunset-Sail-Cruise-from-Cabo-San-Lucas/d637-11166P6', addedBy: 'system', approved: true, votes: [] }, + { id: uuidv4(), categoryId: 'excursion', name: 'Cabo Shark Dive', desc: 'Cage-free shark encounter · $150/person', url: 'https://www.viator.com/tours/Los-Cabos/Cabo-Shark-Dive/d637-11166P8', addedBy: 'system', approved: true, votes: [] }, + // Itineraries + { id: uuidv4(), categoryId: 'itinerary', name: 'Plan A — Multi-Activity Action', desc: 'ATV · VIP Cabana · Quivira Golf · Yacht · $1,870–$1,920/person', url: null, addedBy: 'system', approved: true, votes: [], details: ['Quivira Golf $250', 'Private Yacht $250', 'ATV $100', 'VIP Cabanas $80', 'Nightlife $45', 'Transfers $30', 'Hotel 5 nights ~$1,115'] }, + { id: uuidv4(), categoryId: 'itinerary', name: 'Plan B — Flexible Drop-In', desc: 'Staggered arrivals · Mix & match · $1,577–$1,870/person', url: null, addedBy: 'system', approved: true, votes: [], details: ['ME Cabo 4 nights ~$1,000', 'Beach clubs $20–30/day', 'Flexible dining $200', 'Nightlife $40–60', 'Sunset cruise $80', 'Transfers $30'] }, + { id: uuidv4(), categoryId: 'itinerary', name: 'Plan C — Budget Golf Bundle', desc: 'Grand Fiesta Americana + Golf PKG · ~$1,600/person', url: null, addedBy: 'system', approved: true, votes: [], details: ['GFA golf package 5 nights ~$900', 'Quivira + Cabo Del Sol $150', 'Office Beach Club $25', 'Mandala $50', 'Transfers $25'] }, + ], + voters: [], + pollsOpen: true, + }; +} + +let data = loadData(); + +// ── API Routes ─────────────────────────────────────────────── + +// Get all categories +app.get('/api/categories', (req, res) => { + res.json(data.categories); +}); + +// Get options (optionally filter by category) +app.get('/api/options', (req, res) => { + const { category, includeUnapproved } = req.query; + let options = data.options; + if (category) options = options.filter(o => o.categoryId === category); + if (!includeUnapproved) options = options.filter(o => o.approved); + res.json(options); +}); + +// Get results summary (votes per option, grouped by category) +app.get('/api/results', (req, res) => { + const results = data.categories.map(cat => ({ + ...cat, + options: data.options + .filter(o => o.approved && o.categoryId === cat.id) + .map(o => ({ ...o, voteCount: o.votes.length })) + })); + res.json({ pollsOpen: data.pollsOpen, results, totalVoters: data.voters.length }); +}); + +// Vote for an option (one vote per person per category — replaces previous vote) +app.post('/api/vote', (req, res) => { + const { optionId, voterName } = req.body; + if (!voterName || !optionId) return res.status(400).json({ error: 'Missing fields' }); + if (!data.pollsOpen) return res.status(403).json({ error: 'Polls are closed' }); + + const option = data.options.find(o => o.id === optionId); + if (!option || !option.approved) return res.status(404).json({ error: 'Option not found' }); + + // Remove existing vote by this voter in the same category + const prevVote = data.options.find(o => o.votes.some(v => v.name === voterName) && o.categoryId === option.categoryId); + if (prevVote) { + prevVote.votes = prevVote.votes.filter(v => v.name !== voterName); + } + + // Add new vote + option.votes.push({ name: voterName, timestamp: Date.now() }); + + // Track voter + if (!data.voters.find(v => v.name === voterName)) { + data.voters.push({ name: voterName, joinedAt: Date.now() }); + } + + saveData(data); + broadcast({ type: 'vote_update', results: data.options.filter(o => o.approved).map(o => ({ id: o.id, votes: o.votes.length, voters: o.votes.map(v => v.name) })) }); + res.json({ success: true, voteCount: option.votes.length }); +}); + +// Remove vote +app.delete('/api/vote/:optionId', (req, res) => { + const { voterName } = req.body; + const option = data.options.find(o => o.id === req.params.optionId); + if (!option) return res.status(404).json({ error: 'Not found' }); + option.votes = option.votes.filter(v => v.name !== voterName); + saveData(data); + broadcast({ type: 'vote_update', results: data.options.filter(o => o.approved).map(o => ({ id: o.id, votes: o.votes.length, voters: o.votes.map(v => v.name) })) }); + res.json({ success: true }); +}); + +// Add a new option +app.post('/api/options', (req, res) => { + const { categoryId, name, desc, url, voterName } = req.body; + if (!categoryId || !name || !voterName) return res.status(400).json({ error: 'Missing required fields' }); + + const category = data.categories.find(c => c.id === categoryId); + if (!category) return res.status(404).json({ error: 'Category not found' }); + + const newOption = { + id: uuidv4(), + categoryId, + name: name.trim(), + desc: (desc || '').trim(), + url: url ? url.trim() : null, + addedBy: voterName, + approved: false, // needs approval + votes: [], + details: [], + }; + + data.options.push(newOption); + saveData(data); + broadcast({ type: 'option_added', option: newOption }); + res.json({ success: true, option: newOption }); +}); + +// Approve a pending option +app.post('/api/options/:id/approve', (req, res) => { + const option = data.options.find(o => o.id === req.params.id); + if (!option) return res.status(404).json({ error: 'Not found' }); + option.approved = true; + saveData(data); + broadcast({ type: 'option_approved', option }); + res.json({ success: true }); +}); + +// Remove vote from an option +app.delete('/api/vote/:optionId', (req, res) => { + const { voterName } = req.body; + const option = data.options.find(o => o.id === req.params.id); + if (!option) return res.status(404).json({ error: 'Not found' }); + option.votes = option.votes.filter(v => v.name !== voterName); + saveData(data); + broadcast({ type: 'vote_update', results: data.options.filter(o => o.approved).map(o => ({ id: o.id, votes: o.votes.length })) }); + res.json({ success: true }); +}); + +// Toggle polls open/closed +app.post('/api/polls', (req, res) => { + data.pollsOpen = req.body.open !== undefined ? req.body.open : !data.pollsOpen; + saveData(data); + broadcast({ type: 'polls_status', open: data.pollsOpen }); + res.json({ success: true, pollsOpen: data.pollsOpen }); +}); + +// ── WebSocket ──────────────────────────────────────────────── + +wss.on('connection', (ws) => { + // Send current state to new client + ws.send(JSON.stringify({ + type: 'init', + pollsOpen: data.pollsOpen, + categories: data.categories, + options: data.options.filter(o => o.approved), + results: data.options.filter(o => o.approved).map(o => ({ id: o.id, votes: o.votes.length, voters: o.votes.map(v => v.name) })), + totalVoters: data.voters.length, + })); + + ws.on('message', (raw) => { + try { + const msg = JSON.parse(raw); + if (msg.type === 'vote') { + const { optionId, voterName, remove } = msg; + if (!voterName || !optionId) return; + if (!data.pollsOpen) { ws.send(JSON.stringify({ type: 'error', message: 'Polls closed' })); return; } + const option = data.options.find(o => o.id === optionId); + if (!option || !option.approved) return; + if (remove) { + option.votes = option.votes.filter(v => v.name !== voterName); + } else { + const prev = data.options.find(o => o.votes.some(v => v.name === voterName) && o.categoryId === option.categoryId); + if (prev) prev.votes = prev.votes.filter(v => v.name !== voterName); + option.votes.push({ name: voterName, timestamp: Date.now() }); + } + if (!data.voters.find(v => v.name === voterName)) data.voters.push({ name: voterName, joinedAt: Date.now() }); + saveData(data); + broadcast({ type: 'vote_update', results: data.options.filter(o => o.approved).map(o => ({ id: o.id, votes: o.votes.length, voters: o.votes.map(v => v.name) })) }); + } else if (msg.type === 'add_option') { + const { categoryId, name, desc, url, voterName } = msg; + if (!categoryId || !name || !voterName) return; + const newOption = { + id: uuidv4(), + categoryId, + name: name.trim(), + desc: (desc || '').trim(), + url: url ? url.trim() : null, + addedBy: voterName, + approved: true, + votes: [], + details: [], + }; + data.options.push(newOption); + saveData(data); + broadcast({ type: 'option_added', option: newOption }); + } + } catch (e) { /* ignore malformed */ } + }); +}); + +const PORT = process.env.PORT || 3001; +server.listen(PORT, '0.0.0.0', () => { + console.log(`🏄 Cabo Voting App → http://0.0.0.0:${PORT}`); +});