Add Cabo map tab with Leaflet integration and lat/lng seed data

This commit is contained in:
2026-04-29 14:56:57 +00:00
parent 39b9277236
commit f47dac1e41

236
server.js
View File

@@ -46,6 +46,15 @@ function broadcast(payload) {
}); });
} }
// ── 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 ───────────────────────────────────────────────── // ── Seed data ─────────────────────────────────────────────────
function buildSeedData() { function buildSeedData() {
@@ -60,31 +69,178 @@ function buildSeedData() {
], ],
options: [ options: [
// Hotels // 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',
{ 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: [] }, name: 'Grand Fiesta Americana', categoryColor: '#3b82f6',
{ 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: [] }, desc: 'Premium all-inclusive · Golf packages · 5⭐ · ~$223/night',
{ 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: [] }, url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos',
{ 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: [] }, 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 // 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',
{ id: uuidv4(), categoryId: 'golf', name: 'Solmar Golf Links', desc: 'Seaside championship course · $160/round', url: 'https://www.solmargolflinks.com', addedBy: 'system', approved: true, votes: [] }, 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 // 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',
{ 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: [] }, name: 'El Squid Roe', categoryColor: '#a855f7',
{ id: uuidv4(), categoryId: 'nightlife', name: 'Crush Nightspot', desc: 'Upscale lounge · Craft cocktails · ~$30 cover', url: 'https://www.crushcabo.com', addedBy: 'system', approved: true, votes: [] }, 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 // 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',
{ 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: [] }, name: 'Private Yacht to The Arch', categoryColor: '#06b6d4',
{ 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: [] }, desc: 'Quivira Yacht Club · $250/person · 2hr',
{ 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: [] }, url: 'https://www.viator.com/tours/Los-Cabos/Private-Luxury-Yacht-Charter/d637-11242P30',
// Itineraries lat: 23.0467, lng: -109.6844,
{ 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'] }, addedBy: 'system', approved: true, votes: []
{ 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'] }, {
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: [], voters: [],
pollsOpen: true, pollsOpen: true,
@@ -95,12 +251,10 @@ let data = loadData();
// ── API Routes ─────────────────────────────────────────────── // ── API Routes ───────────────────────────────────────────────
// Get all categories
app.get('/api/categories', (req, res) => { app.get('/api/categories', (req, res) => {
res.json(data.categories); res.json(data.categories);
}); });
// Get options (optionally filter by category)
app.get('/api/options', (req, res) => { app.get('/api/options', (req, res) => {
const { category, includeUnapproved } = req.query; const { category, includeUnapproved } = req.query;
let options = data.options; let options = data.options;
@@ -109,7 +263,6 @@ app.get('/api/options', (req, res) => {
res.json(options); res.json(options);
}); });
// Get results summary (votes per option, grouped by category)
app.get('/api/results', (req, res) => { app.get('/api/results', (req, res) => {
const results = data.categories.map(cat => ({ const results = data.categories.map(cat => ({
...cat, ...cat,
@@ -120,7 +273,6 @@ app.get('/api/results', (req, res) => {
res.json({ pollsOpen: data.pollsOpen, results, totalVoters: data.voters.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) => { app.post('/api/vote', (req, res) => {
const { optionId, voterName } = req.body; const { optionId, voterName } = req.body;
if (!voterName || !optionId) return res.status(400).json({ error: 'Missing fields' }); if (!voterName || !optionId) return res.status(400).json({ error: 'Missing fields' });
@@ -129,16 +281,13 @@ app.post('/api/vote', (req, res) => {
const option = data.options.find(o => o.id === optionId); const option = data.options.find(o => o.id === optionId);
if (!option || !option.approved) return res.status(404).json({ error: 'Option not found' }); 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); const prevVote = data.options.find(o => o.votes.some(v => v.name === voterName) && o.categoryId === option.categoryId);
if (prevVote) { if (prevVote) {
prevVote.votes = prevVote.votes.filter(v => v.name !== voterName); prevVote.votes = prevVote.votes.filter(v => v.name !== voterName);
} }
// Add new vote
option.votes.push({ name: voterName, timestamp: Date.now() }); option.votes.push({ name: voterName, timestamp: Date.now() });
// Track voter
if (!data.voters.find(v => v.name === voterName)) { if (!data.voters.find(v => v.name === voterName)) {
data.voters.push({ name: voterName, joinedAt: Date.now() }); data.voters.push({ name: voterName, joinedAt: Date.now() });
} }
@@ -148,7 +297,6 @@ app.post('/api/vote', (req, res) => {
res.json({ success: true, voteCount: option.votes.length }); res.json({ success: true, voteCount: option.votes.length });
}); });
// Remove vote
app.delete('/api/vote/:optionId', (req, res) => { app.delete('/api/vote/:optionId', (req, res) => {
const { voterName } = req.body; const { voterName } = req.body;
const option = data.options.find(o => o.id === req.params.optionId); const option = data.options.find(o => o.id === req.params.optionId);
@@ -159,9 +307,8 @@ app.delete('/api/vote/:optionId', (req, res) => {
res.json({ success: true }); res.json({ success: true });
}); });
// Add a new option
app.post('/api/options', (req, res) => { app.post('/api/options', (req, res) => {
const { categoryId, name, desc, url, voterName } = req.body; const { categoryId, name, desc, url, voterName, lat, lng } = req.body;
if (!categoryId || !name || !voterName) return res.status(400).json({ error: 'Missing required fields' }); if (!categoryId || !name || !voterName) return res.status(400).json({ error: 'Missing required fields' });
const category = data.categories.find(c => c.id === categoryId); const category = data.categories.find(c => c.id === categoryId);
@@ -173,10 +320,13 @@ app.post('/api/options', (req, res) => {
name: name.trim(), name: name.trim(),
desc: (desc || '').trim(), desc: (desc || '').trim(),
url: url ? url.trim() : null, url: url ? url.trim() : null,
lat: lat || null,
lng: lng || null,
addedBy: voterName, addedBy: voterName,
approved: false, // needs approval approved: false,
votes: [], votes: [],
details: [], details: [],
categoryColor: CATEGORY_META[categoryId]?.color || '#888',
}; };
data.options.push(newOption); data.options.push(newOption);
@@ -185,7 +335,6 @@ app.post('/api/options', (req, res) => {
res.json({ success: true, option: newOption }); res.json({ success: true, option: newOption });
}); });
// Approve a pending option
app.post('/api/options/:id/approve', (req, res) => { app.post('/api/options/:id/approve', (req, res) => {
const option = data.options.find(o => o.id === req.params.id); const option = data.options.find(o => o.id === req.params.id);
if (!option) return res.status(404).json({ error: 'Not found' }); if (!option) return res.status(404).json({ error: 'Not found' });
@@ -195,7 +344,6 @@ app.post('/api/options/:id/approve', (req, res) => {
res.json({ success: true }); res.json({ success: true });
}); });
// Delete an option
app.delete('/api/options/:id', (req, res) => { app.delete('/api/options/:id', (req, res) => {
const idx = data.options.findIndex(o => o.id === req.params.id); const idx = data.options.findIndex(o => o.id === req.params.id);
if (idx === -1) return res.status(404).json({ error: 'Not found' }); if (idx === -1) return res.status(404).json({ error: 'Not found' });
@@ -205,18 +353,6 @@ app.delete('/api/options/:id', (req, res) => {
res.json({ success: true }); 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) => { app.post('/api/polls', (req, res) => {
data.pollsOpen = req.body.open !== undefined ? req.body.open : !data.pollsOpen; data.pollsOpen = req.body.open !== undefined ? req.body.open : !data.pollsOpen;
saveData(data); saveData(data);
@@ -227,7 +363,6 @@ app.post('/api/polls', (req, res) => {
// ── WebSocket ──────────────────────────────────────────────── // ── WebSocket ────────────────────────────────────────────────
wss.on('connection', (ws) => { wss.on('connection', (ws) => {
// Send current state to new client
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: 'init', type: 'init',
pollsOpen: data.pollsOpen, pollsOpen: data.pollsOpen,
@@ -257,7 +392,7 @@ wss.on('connection', (ws) => {
saveData(data); 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) })) }); 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') { } else if (msg.type === 'add_option') {
const { categoryId, name, desc, url, voterName } = msg; const { categoryId, name, desc, url, voterName, lat, lng } = msg;
if (!categoryId || !name || !voterName) return; if (!categoryId || !name || !voterName) return;
const newOption = { const newOption = {
id: uuidv4(), id: uuidv4(),
@@ -265,10 +400,13 @@ wss.on('connection', (ws) => {
name: name.trim(), name: name.trim(),
desc: (desc || '').trim(), desc: (desc || '').trim(),
url: url ? url.trim() : null, url: url ? url.trim() : null,
lat: lat || null,
lng: lng || null,
addedBy: voterName, addedBy: voterName,
approved: true, approved: true,
votes: [], votes: [],
details: [], details: [],
categoryColor: CATEGORY_META[categoryId]?.color || '#888',
}; };
data.options.push(newOption); data.options.push(newOption);
saveData(data); saveData(data);