Files
cabo-voting-app/server.js

479 lines
18 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());
// 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 · $4050 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 $2030/day', 'Flexible dining $200', 'Nightlife $4060', '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 });
});
// ── Yelp Fusion API proxy ───────────────────────────────────
// API key lives server-side — never exposed to the browser.
// Get your free key at: https://www.yelp.com/developers
const YELP_API_KEY = process.env.YELP_API_KEY || '';
app.get('/api/yelp', async (req, res) => {
const { term, location } = req.query;
if (!term) return res.status(400).json({ error: 'term is required' });
if (!YELP_API_KEY) {
return res.status(503).json({
error: 'YELP_API_KEY not configured on server. Add it as an environment variable.'
});
}
try {
const params = new URLSearchParams({
term: term + ' in ' + location,
location: location || 'Los Cabos Mexico',
limit: '15',
sort_by: 'rating',
categories: 'restaurants,nightlife,active,arts,health',
});
const response = await fetch(`https://api.yelp.com/v3/businesses/search?${params}`, {
headers: {
'Authorization': `Bearer ${YELP_API_KEY}`,
'Accept': 'application/json',
}
});
if (!response.ok) {
const errText = await response.text();
return res.status(response.status).json({ error: `Yelp API error: ${errText}` });
}
const data = await response.json();
// Return only the fields we need to keep payload small
const businesses = (data.businesses || []).map(b => ({
name: b.name,
image_url: b.image_url,
url: b.url,
rating: b.rating,
price: b.price,
coordinates: b.coordinates,
location: b.location,
categories: b.categories,
display_phone: b.display_phone,
distance: b.distance,
}));
res.json({ businesses, total: data.total });
} catch (err) {
console.error('Yelp proxy error:', err);
res.status(500).json({ error: 'Failed to fetch from Yelp' });
}
});
// ── 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}`);
});