Files
cabo-voting-app/server.js
Christopher Mayor 6f4167e7ab [#5] Add results leaderboard tab
- 6th tab (🏆 Results) shows ranked results across all categories
- Medal icons 🥇🥈🥉 for top 3 per category
- Percentage bars with category colors
- Accessible without voter name via ?view=results URL
- Shareable link: no modal, shows live results
2026-04-28 21:40:54 -07:00

269 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: 'Itineraries', emoji: '🗺️' },
{ id: 'results', name: 'Results', 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 · $4050 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 $2030/day', 'Flexible dining $200', 'Nightlife $4060', '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}`);
});