feat: map tab with Leaflet + seed-data.js refactor + Yelp proxy

- Add Leaflet map tab in public/index.html with CARTO dark tiles, category
  toggles, vote-count markers, and external venue search
- Extract seed data to seed-data.js with CATEGORY_META, buildSeedData(),
  mergeSeedData() helpers
- Refactor server.js: approvedOptionsWithVoteSummary(), buildRealtimeSnapshot(),
  createUserOption() helpers; Yelp API proxy at /api/yelp; /api/budgets endpoint
- Extract inline seed data from server.js to seed-data.js module
- Add budgetScenarios and priceUpdatedAt to realtime snapshot
This commit is contained in:
2026-04-29 22:16:47 -07:00
parent 39b9277236
commit 43a466f7e8
3 changed files with 1793 additions and 153 deletions

351
server.js
View File

@@ -5,6 +5,7 @@ const http = require('http');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const { CATEGORY_META, buildSeedData, mergeSeedData } = require('./seed-data');
const app = express();
const server = http.createServer(app);
@@ -16,168 +17,191 @@ 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'));
const existing = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
const merged = mergeSeedData(existing);
if (JSON.stringify(existing) !== JSON.stringify(merged)) {
fs.writeFileSync(DATA_FILE, JSON.stringify(merged, null, 2));
}
return merged;
}
function saveData(data) {
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
function saveData(nextData) {
fs.writeFileSync(DATA_FILE, JSON.stringify(nextData, null, 2));
}
function approvedOptionsWithVoteSummary() {
return data.options
.filter((option) => option.approved)
.map((option) => ({
id: option.id,
votes: option.votes.length,
voters: option.votes.map((vote) => vote.name),
}));
}
function broadcast(payload) {
const msg = JSON.stringify(payload);
wss.clients.forEach(client => {
wss.clients.forEach((client) => {
if (client.readyState === 1) client.send(msg);
});
}
// ── Seed data ─────────────────────────────────────────────────
function buildSeedData() {
function buildRealtimeSnapshot() {
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,
type: 'init',
pollsOpen: data.pollsOpen,
categories: data.categories,
options: data.options.filter((option) => option.approved),
results: approvedOptionsWithVoteSummary(),
totalVoters: data.voters.length,
budgetScenarios: data.budgetScenarios || [],
priceUpdatedAt: data.priceUpdatedAt || null,
};
}
function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, approved }) {
return {
id: uuidv4(),
seedKey: null,
categoryId,
name: name.trim(),
desc: (desc || '').trim(),
url: url ? url.trim() : null,
links: url ? [{ label: 'Website', url: url.trim() }] : [],
lat: lat || null,
lng: lng || null,
addedBy: voterName,
approved,
votes: [],
details: [],
categoryColor: CATEGORY_META[categoryId]?.color || '#888',
};
}
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);
if (category) options = options.filter((option) => option.categoryId === category);
if (!includeUnapproved) options = options.filter((option) => option.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,
const results = data.categories.map((category) => ({
...category,
options: data.options
.filter(o => o.approved && o.categoryId === cat.id)
.map(o => ({ ...o, voteCount: o.votes.length }))
.filter((option) => option.approved && option.categoryId === category.id)
.map((option) => ({ ...option, voteCount: option.votes.length })),
}));
res.json({ pollsOpen: data.pollsOpen, results, totalVoters: data.voters.length });
res.json({
pollsOpen: data.pollsOpen,
results,
totalVoters: data.voters.length,
budgetScenarios: data.budgetScenarios || [],
priceUpdatedAt: data.priceUpdatedAt || null,
});
});
app.get('/api/budgets', (req, res) => {
res.json({
updatedAt: data.priceUpdatedAt || null,
scenarios: data.budgetScenarios || [],
});
});
// 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);
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((candidate) => candidate.id === optionId);
if (!option || !option.approved) {
return res.status(404).json({ error: 'Option not found' });
}
const previousVote = data.options.find((candidate) => (
candidate.categoryId === option.categoryId
&& candidate.votes.some((vote) => vote.name === voterName)
));
if (previousVote) {
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName);
}
// Add new vote
option.votes.push({ name: voterName, timestamp: Date.now() });
// Track voter
if (!data.voters.find(v => v.name === voterName)) {
if (!data.voters.find((voter) => voter.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) })) });
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
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);
const option = data.options.find((candidate) => candidate.id === req.params.optionId);
if (!option) return res.status(404).json({ error: 'Not found' });
option.votes = option.votes.filter(v => v.name !== voterName);
option.votes = option.votes.filter((vote) => vote.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) })) });
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
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 { categoryId, name, desc, url, voterName, lat, lng } = req.body;
const category = data.categories.find(c => c.id === categoryId);
if (!categoryId || !name || !voterName) {
return res.status(400).json({ error: 'Missing required fields' });
}
const category = data.categories.find((candidate) => candidate.id === categoryId);
if (!category) return res.status(404).json({ error: 'Category not found' });
const newOption = {
id: uuidv4(),
const newOption = createUserOption({
categoryId,
name: name.trim(),
desc: (desc || '').trim(),
url: url ? url.trim() : null,
addedBy: voterName,
approved: false, // needs approval
votes: [],
details: [],
};
name,
desc,
url,
voterName,
lat,
lng,
approved: false,
});
data.options.push(newOption);
saveData(data);
@@ -185,38 +209,26 @@ app.post('/api/options', (req, res) => {
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);
const option = data.options.find((candidate) => candidate.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 });
});
// Delete an option
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);
const optionIndex = data.options.findIndex((candidate) => candidate.id === req.params.id);
if (optionIndex === -1) return res.status(404).json({ error: 'Not found' });
data.options.splice(optionIndex, 1);
saveData(data);
broadcast({ type: 'option_deleted', id: req.params.id });
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);
@@ -224,57 +236,120 @@ app.post('/api/polls', (req, res) => {
res.json({ success: true, pollsOpen: data.pollsOpen });
});
// ── WebSocket ────────────────────────────────────────────────
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 errorText = await response.text();
return res.status(response.status).json({ error: `Yelp API error: ${errorText}` });
}
const payload = await response.json();
const businesses = (payload.businesses || []).map((business) => ({
name: business.name,
image_url: business.image_url,
url: business.url,
rating: business.rating,
price: business.price,
coordinates: business.coordinates,
location: business.location,
categories: business.categories,
display_phone: business.display_phone,
distance: business.distance,
}));
res.json({ businesses, total: payload.total });
} catch (error) {
console.error('Yelp proxy error:', error);
res.status(500).json({ error: 'Failed to fetch from Yelp' });
}
});
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.send(JSON.stringify(buildRealtimeSnapshot()));
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 (!data.pollsOpen) {
ws.send(JSON.stringify({ type: 'error', message: 'Polls closed' }));
return;
}
const option = data.options.find((candidate) => candidate.id === optionId);
if (!option || !option.approved) return;
if (remove) {
option.votes = option.votes.filter(v => v.name !== voterName);
option.votes = option.votes.filter((vote) => vote.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);
const previousVote = data.options.find((candidate) => (
candidate.categoryId === option.categoryId
&& candidate.votes.some((vote) => vote.name === voterName)
));
if (previousVote) {
previousVote.votes = previousVote.votes.filter((vote) => vote.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() });
if (!data.voters.find((voter) => voter.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) })) });
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
} 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;
const newOption = {
id: uuidv4(),
const newOption = createUserOption({
categoryId,
name: name.trim(),
desc: (desc || '').trim(),
url: url ? url.trim() : null,
addedBy: voterName,
name,
desc,
url,
voterName,
lat,
lng,
approved: true,
votes: [],
details: [],
};
});
data.options.push(newOption);
saveData(data);
broadcast({ type: 'option_added', option: newOption });
}
} catch (e) { /* ignore malformed */ }
} catch {
// Ignore malformed websocket payloads.
}
});
});