423 lines
17 KiB
JavaScript
423 lines
17 KiB
JavaScript
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());
|
||
|
||
// Admin panel
|
||
app.get('/admin', (req, res) => {
|
||
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
|
||
});
|
||
|
||
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);
|
||
});
|
||
}
|
||
|
||
// ── Category meta (shared with frontend for map colors) ────────
|
||
const CATEGORY_META = {
|
||
hotel: { emoji: '🏨', color: '#3b82f6' },
|
||
golf: { emoji: '⛳', color: '#22c55e' },
|
||
nightlife: { emoji: '🎧', color: '#a855f7' },
|
||
excursion: { emoji: '🚤', color: '#06b6d4' },
|
||
itinerary: { emoji: '🗺️', color: '#fbbf24' },
|
||
};
|
||
|
||
// ── 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', categoryColor: '#3b82f6',
|
||
desc: 'Premium all-inclusive · Golf packages · 5⭐ · ~$223/night',
|
||
url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos',
|
||
lat: 23.0949, lng: -109.7067,
|
||
addedBy: 'system', approved: true, votes: []
|
||
},
|
||
{
|
||
id: uuidv4(), categoryId: 'hotel',
|
||
name: 'Hotel Riu Palace', categoryColor: '#3b82f6',
|
||
desc: 'High-energy beachfront · 5⭐ · ~$250/night',
|
||
url: 'https://www.riu.com/en/hotel/los-cabos/hotel-riu-palace-cabo-san-lucas/',
|
||
lat: 23.0731, lng: -109.6987,
|
||
addedBy: 'system', approved: true, votes: []
|
||
},
|
||
{
|
||
id: uuidv4(), categoryId: 'hotel',
|
||
name: 'Marquis Los Cabos', categoryColor: '#3b82f6',
|
||
desc: 'Luxury adults-only · Infinity pool · ~$300/night',
|
||
url: 'https://www.marquisloscabos.com',
|
||
lat: 23.0567, lng: -109.6934,
|
||
addedBy: 'system', approved: true, votes: []
|
||
},
|
||
{
|
||
id: uuidv4(), categoryId: 'hotel',
|
||
name: 'Pueblo Bonito Pacifica', categoryColor: '#3b82f6',
|
||
desc: 'Exclusive Quivira Golf Club access · Adults-only · ~$280/night',
|
||
url: 'https://www.pueblobonito.com/pacifica',
|
||
lat: 23.0489, lng: -109.6889,
|
||
addedBy: 'system', approved: true, votes: []
|
||
},
|
||
{
|
||
id: uuidv4(), categoryId: 'hotel',
|
||
name: 'ME Cabo', categoryColor: '#3b82f6',
|
||
desc: 'Adults-only · Buzzing beach club · ~$200/night',
|
||
url: 'https://www.mecabo.com',
|
||
lat: 23.0667, lng: -109.6967,
|
||
addedBy: 'system', approved: true, votes: []
|
||
},
|
||
{
|
||
id: uuidv4(), categoryId: 'hotel',
|
||
name: 'Hacienda Encantada', categoryColor: '#3b82f6',
|
||
desc: 'Condo-feel all-inclusive · 2BR Suites · ~$180/night',
|
||
url: 'https://www.haciendaencantada.com',
|
||
lat: 23.0289, lng: -109.6789,
|
||
addedBy: 'system', approved: true, votes: []
|
||
},
|
||
// Golf
|
||
{
|
||
id: uuidv4(), categoryId: 'golf',
|
||
name: 'Quivira Golf Club', categoryColor: '#22c55e',
|
||
desc: 'Jack Nicklaus signature · Ocean views · $250/round',
|
||
url: 'https://quiviraloscabos.com',
|
||
lat: 23.0344, lng: -109.6834,
|
||
addedBy: 'system', approved: true, votes: []
|
||
},
|
||
{
|
||
id: uuidv4(), categoryId: 'golf',
|
||
name: 'Cabo Del Sol Golf', categoryColor: '#22c55e',
|
||
desc: 'Desert-ocean layout · 18 holes · $180/round',
|
||
url: 'https://www.cabodelsol.com/golf',
|
||
lat: 23.0567, lng: -109.6934,
|
||
addedBy: 'system', approved: true, votes: []
|
||
},
|
||
{
|
||
id: uuidv4(), categoryId: 'golf',
|
||
name: 'Solmar Golf Links', categoryColor: '#22c55e',
|
||
desc: 'Seaside championship course · $160/round',
|
||
url: 'https://www.solmargolflinks.com',
|
||
lat: 23.0611, lng: -109.6978,
|
||
addedBy: 'system', approved: true, votes: []
|
||
},
|
||
// Nightlife
|
||
{
|
||
id: uuidv4(), categoryId: 'nightlife',
|
||
name: 'El Squid Roe', categoryColor: '#a855f7',
|
||
desc: '3 floors · $40–50 cover · Open til 4am',
|
||
url: 'https://www.elsquidroe.com',
|
||
lat: 23.0694, lng: -109.6994,
|
||
addedBy: 'system', approved: true, votes: []
|
||
},
|
||
{
|
||
id: uuidv4(), categoryId: 'nightlife',
|
||
name: 'Mandala Nightclub', categoryColor: '#a855f7',
|
||
desc: 'VIP tables · $50 cover · High-energy',
|
||
url: 'https://www.mandalacabo.com',
|
||
lat: 23.0700, lng: -109.7000,
|
||
addedBy: 'system', approved: true, votes: []
|
||
},
|
||
{
|
||
id: uuidv4(), categoryId: 'nightlife',
|
||
name: 'Cabo Wabo Cantina', categoryColor: '#a855f7',
|
||
desc: "Sammy Hagar's · Live music · $30 cover",
|
||
url: 'https://www.cabowabocantina.com',
|
||
lat: 23.0692, lng: -109.6992,
|
||
addedBy: 'system', approved: true, votes: []
|
||
},
|
||
{
|
||
id: uuidv4(), categoryId: 'nightlife',
|
||
name: 'Crush Nightspot', categoryColor: '#a855f7',
|
||
desc: 'Upscale lounge · Craft cocktails · ~$30 cover',
|
||
url: 'https://www.crushcabo.com',
|
||
lat: 23.0706, lng: -109.7006,
|
||
addedBy: 'system', approved: true, votes: []
|
||
},
|
||
// Excursions
|
||
{
|
||
id: uuidv4(), categoryId: 'excursion',
|
||
name: 'Private Yacht to The Arch', categoryColor: '#06b6d4',
|
||
desc: 'Quivira Yacht Club · $250/person · 2hr',
|
||
url: 'https://www.viator.com/tours/Los-Cabos/Private-Luxury-Yacht-Charter/d637-11242P30',
|
||
lat: 23.0467, lng: -109.6844,
|
||
addedBy: 'system', approved: true, votes: []
|
||
},
|
||
{
|
||
id: uuidv4(), categoryId: 'excursion',
|
||
name: 'Wild Canyon Adventure', categoryColor: '#06b6d4',
|
||
desc: 'Zipline · Bungee jump · $80/person',
|
||
url: 'https://www.viator.com/tours/Los-Cabos/Wild-Canyon-Adventure/d637-11166P4',
|
||
lat: 22.9989, lng: -109.6589,
|
||
addedBy: 'system', approved: true, votes: []
|
||
},
|
||
{
|
||
id: uuidv4(), categoryId: 'excursion',
|
||
name: 'ATV Desert Adventure', categoryColor: '#06b6d4',
|
||
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',
|
||
lat: 23.0289, lng: -109.6689,
|
||
addedBy: 'system', approved: true, votes: []
|
||
},
|
||
{
|
||
id: uuidv4(), categoryId: 'excursion',
|
||
name: 'Sunset Sail Cruise', categoryColor: '#06b6d4',
|
||
desc: 'Medano Beach departure · $80/person · 2hr',
|
||
url: 'https://www.viator.com/tours/Los-Cabos/Sunset-Sail-Cruise-from-Cabo-San-Lucas/d637-11166P6',
|
||
lat: 23.0634, lng: -109.6978,
|
||
addedBy: 'system', approved: true, votes: []
|
||
},
|
||
{
|
||
id: uuidv4(), categoryId: 'excursion',
|
||
name: 'Cabo Shark Dive', categoryColor: '#06b6d4',
|
||
desc: 'Cage-free shark encounter · $150/person',
|
||
url: 'https://www.viator.com/tours/Los-Cabos/Cabo-Shark-Dive/d637-11166P8',
|
||
lat: 23.0450, lng: -109.6870,
|
||
addedBy: 'system', approved: true, votes: []
|
||
},
|
||
// Itineraries (no specific coords — shown as route in sidebar)
|
||
{
|
||
id: uuidv4(), categoryId: 'itinerary',
|
||
name: 'Plan A — Multi-Activity Action', categoryColor: '#fbbf24',
|
||
desc: 'ATV · VIP Cabana · Quivira Golf · Yacht · $1,870–$1,920/person',
|
||
url: null, lat: 23.0650, lng: -109.6980,
|
||
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', categoryColor: '#fbbf24',
|
||
desc: 'Staggered arrivals · Mix & match · $1,577–$1,870/person',
|
||
url: null, lat: 23.0667, lng: -109.6967,
|
||
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', categoryColor: '#fbbf24',
|
||
desc: 'Grand Fiesta Americana + Golf PKG · ~$1,600/person',
|
||
url: null, lat: 23.0949, lng: -109.7067,
|
||
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 ───────────────────────────────────────────────
|
||
|
||
app.get('/api/categories', (req, res) => {
|
||
res.json(data.categories);
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
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 });
|
||
});
|
||
|
||
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' });
|
||
|
||
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);
|
||
}
|
||
|
||
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) })) });
|
||
res.json({ success: true, voteCount: option.votes.length });
|
||
});
|
||
|
||
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 });
|
||
});
|
||
|
||
app.post('/api/options', (req, res) => {
|
||
const { categoryId, name, desc, url, voterName, lat, lng } = 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,
|
||
lat: lat || null,
|
||
lng: lng || null,
|
||
addedBy: voterName,
|
||
approved: false,
|
||
votes: [],
|
||
details: [],
|
||
categoryColor: CATEGORY_META[categoryId]?.color || '#888',
|
||
};
|
||
|
||
data.options.push(newOption);
|
||
saveData(data);
|
||
broadcast({ type: 'option_added', option: newOption });
|
||
res.json({ success: true, option: newOption });
|
||
});
|
||
|
||
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 });
|
||
});
|
||
|
||
app.delete('/api/options/:id', (req, res) => {
|
||
const idx = data.options.findIndex(o => o.id === req.params.id);
|
||
if (idx === -1) return res.status(404).json({ error: 'Not found' });
|
||
data.options.splice(idx, 1);
|
||
saveData(data);
|
||
broadcast({ type: 'option_deleted', id: req.params.id });
|
||
res.json({ success: true });
|
||
});
|
||
|
||
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) => {
|
||
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, lat, lng } = msg;
|
||
if (!categoryId || !name || !voterName) return;
|
||
const newOption = {
|
||
id: uuidv4(),
|
||
categoryId,
|
||
name: name.trim(),
|
||
desc: (desc || '').trim(),
|
||
url: url ? url.trim() : null,
|
||
lat: lat || null,
|
||
lng: lng || null,
|
||
addedBy: voterName,
|
||
approved: true,
|
||
votes: [],
|
||
details: [],
|
||
categoryColor: CATEGORY_META[categoryId]?.color || '#888',
|
||
};
|
||
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}`);
|
||
});
|