Compare commits
5 Commits
3a3ff55893
...
feat/local
| Author | SHA1 | Date | |
|---|---|---|---|
| 43a466f7e8 | |||
| 39b9277236 | |||
| bc18555432 | |||
| a6e07258c6 | |||
| 6f4167e7ab |
372
public/admin.html
Normal file
372
public/admin.html
Normal file
@@ -0,0 +1,372 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cabo Voting — Admin</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0d14;
|
||||
--surface: #13161f;
|
||||
--surface2: #1a1e2a;
|
||||
--border: #252a38;
|
||||
--accent: #00d4ff;
|
||||
--text: #e0e6f0;
|
||||
--text-muted: #7a8499;
|
||||
--green: #34d399;
|
||||
--red: #f87171;
|
||||
--amber: #fbbf24;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; padding: 20px; }
|
||||
a { color: var(--accent); }
|
||||
h1 { font-size: 1.3rem; color: var(--accent); margin-bottom: 6px; }
|
||||
h2 { font-size: 1rem; color: var(--text-muted); margin-bottom: 16px; font-weight: 500; }
|
||||
|
||||
.container { max-width: 900px; margin: 0 auto; }
|
||||
|
||||
/* Password gate */
|
||||
#passwordGate {
|
||||
position: fixed; inset: 0; background: var(--bg);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
}
|
||||
#passwordGate.hidden { display: none; }
|
||||
#passwordGate .box {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 16px; padding: 32px; width: 320px; text-align: center;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||
}
|
||||
#passwordGate .box h2 { font-size: 1.2rem; color: var(--accent); margin-bottom: 8px; }
|
||||
#passwordGate .box p { color: var(--text-muted); font-size: 0.8rem; margin-bottom: 20px; }
|
||||
#passwordGate input {
|
||||
width: 100%; padding: 10px 14px; background: var(--surface2);
|
||||
border: 1px solid var(--border); border-radius: 8px; color: var(--text);
|
||||
font-size: 1rem; outline: none; margin-bottom: 12px; text-align: center;
|
||||
}
|
||||
#passwordGate input:focus { border-color: var(--accent); }
|
||||
#passwordGate button {
|
||||
width: 100%; padding: 10px; background: var(--accent); color: var(--bg);
|
||||
border: none; border-radius: 8px; font-size: 0.9rem; font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
#passwordGate button:hover { opacity: 0.85; }
|
||||
#passwordGate .error { color: var(--red); font-size: 0.78rem; margin-top: 8px; }
|
||||
|
||||
/* Header */
|
||||
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
|
||||
.header-links { display: flex; gap: 12px; font-size: 0.8rem; }
|
||||
.header-links a { color: var(--text-muted); }
|
||||
.header-links a:hover { color: var(--text); }
|
||||
|
||||
/* Stat cards */
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
||||
.stat-card {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 14px 16px; text-align: center;
|
||||
}
|
||||
.stat-card .val { font-size: 1.8rem; font-weight: 700; color: var(--accent); }
|
||||
.stat-card .label { font-size: 0.7rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }
|
||||
|
||||
/* Polls toggle */
|
||||
.polls-toggle {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 16px 20px; margin-bottom: 24px;
|
||||
}
|
||||
.polls-toggle .label { font-size: 0.85rem; font-weight: 600; }
|
||||
.polls-toggle .sub { font-size: 0.72rem; color: var(--text-muted); margin-top: 2px; }
|
||||
.btn-toggle {
|
||||
padding: 8px 20px; border: none; border-radius: 8px;
|
||||
font-size: 0.82rem; font-weight: 700; cursor: pointer; transition: opacity 0.2s;
|
||||
}
|
||||
.btn-toggle:hover { opacity: 0.85; }
|
||||
.btn-toggle.open { background: var(--red); color: #fff; }
|
||||
.btn-toggle.closed { background: var(--green); color: #000; }
|
||||
|
||||
/* Sections */
|
||||
.section { margin-bottom: 32px; }
|
||||
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.section-title { font-size: 0.85rem; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.badge {
|
||||
background: var(--surface2); border-radius: 10px; padding: 2px 8px;
|
||||
font-size: 0.65rem; color: var(--text-muted);
|
||||
}
|
||||
.badge.pending { background: rgba(251,191,36,0.15); color: var(--amber); }
|
||||
|
||||
/* Option rows */
|
||||
.option-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.option-row {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 12px 14px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.option-row.pending { border-color: rgba(251,191,36,0.4); }
|
||||
.option-row .cat-tag {
|
||||
font-size: 0.65rem; padding: 2px 7px; border-radius: 4px;
|
||||
background: var(--surface2); color: var(--text-muted); flex-shrink: 0;
|
||||
}
|
||||
.option-row .name { flex: 1; font-size: 0.85rem; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.option-row .meta { font-size: 0.7rem; color: var(--text-muted); flex-shrink: 0; }
|
||||
.option-row .votes-badge {
|
||||
background: rgba(0,212,255,0.1); color: var(--accent);
|
||||
border-radius: 6px; padding: 2px 8px; font-size: 0.7rem; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.btn {
|
||||
padding: 6px 14px; border: none; border-radius: 6px;
|
||||
font-size: 0.75rem; font-weight: 700; cursor: pointer; flex-shrink: 0; transition: opacity 0.2s;
|
||||
}
|
||||
.btn:hover { opacity: 0.85; }
|
||||
.btn-approve { background: var(--green); color: #000; }
|
||||
.btn-reject { background: var(--red); color: #fff; }
|
||||
.btn-delete { background: transparent; border: 1px solid var(--border); color: var(--text-muted); }
|
||||
.btn-delete:hover { border-color: var(--red); color: var(--red); }
|
||||
.btn-row { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(80px);
|
||||
background: var(--surface2); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 10px 20px; font-size: 0.82rem; z-index: 500;
|
||||
transition: transform 0.3s; color: var(--text);
|
||||
}
|
||||
.toast.show { transform: translateX(-50%) translateY(0); }
|
||||
.toast.success { border-color: var(--green); color: var(--green); }
|
||||
.toast.error { border-color: var(--red); color: var(--red); }
|
||||
|
||||
/* Loading */
|
||||
.loading { text-align: center; padding: 40px; color: var(--text-muted); font-size: 0.85rem; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.option-row { flex-wrap: wrap; }
|
||||
.btn-row { width: 100%; justify-content: flex-end; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Password Gate -->
|
||||
<div id="passwordGate">
|
||||
<div class="box">
|
||||
<h2>🔐 Admin Access</h2>
|
||||
<p>Enter the admin password to manage the voting app.</p>
|
||||
<input type="password" id="pwdInput" placeholder="Password" onkeydown="if(event.key==='Enter')tryLogin()" />
|
||||
<button onclick="tryLogin()">Unlock →</button>
|
||||
<div class="error" id="pwdError"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container" id="adminPanel" style="display:none">
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>🏄 Cabo Voting — Admin</h1>
|
||||
<h2 id="appStatus">Connecting…</h2>
|
||||
</div>
|
||||
<div class="header-links">
|
||||
<a href="/">← Back to Voting</a>
|
||||
<a href="/?view=results" target="_blank">View Results ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats">
|
||||
<div class="stat-card"><div class="val" id="statVoters">—</div><div class="label">Voters</div></div>
|
||||
<div class="stat-card"><div class="val" id="statOptions">—</div><div class="label">Options</div></div>
|
||||
<div class="stat-card"><div class="val" id="statVotes">—</div><div class="label">Total Votes</div></div>
|
||||
<div class="stat-card"><div class="val" id="statPending">—</div><div class="label">Pending</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Polls Toggle -->
|
||||
<div class="polls-toggle">
|
||||
<div>
|
||||
<div class="label" id="pollsLabel">Polls: OPEN</div>
|
||||
<div class="sub">Click to toggle open/closed state</div>
|
||||
</div>
|
||||
<button class="btn-toggle open" id="pollsBtn" onclick="togglePolls()">Close Polls</button>
|
||||
</div>
|
||||
|
||||
<!-- Pending Options -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">⏳ Pending Approvals</span>
|
||||
<span class="badge pending" id="pendingBadge">0</span>
|
||||
</div>
|
||||
<div class="option-list" id="pendingList"><div class="loading">Loading…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- All Options -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">📋 All Options</span>
|
||||
<span class="badge" id="allBadge">0</span>
|
||||
</div>
|
||||
<div class="option-list" id="allList"><div class="loading">Loading…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const API = '';
|
||||
const PWD_KEY = 'cabo_admin_pwd';
|
||||
const CORRECT_PWD = 'cabo2026';
|
||||
let allData = null;
|
||||
|
||||
function toast(msg, type='') {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'toast' + (type ? ' ' + type : '');
|
||||
t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), 3000);
|
||||
}
|
||||
|
||||
function tryLogin() {
|
||||
const pwd = document.getElementById('pwdInput').value;
|
||||
const err = document.getElementById('pwdError');
|
||||
if (pwd === CORRECT_PWD) {
|
||||
sessionStorage.setItem(PWD_KEY, pwd);
|
||||
document.getElementById('passwordGate').classList.add('hidden');
|
||||
document.getElementById('adminPanel').style.display = 'block';
|
||||
loadData();
|
||||
} else {
|
||||
err.textContent = 'Incorrect password — try "cabo2026"';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const [cats, opts] = await Promise.all([
|
||||
fetch(API + '/api/categories').then(r => r.json()),
|
||||
fetch(API + '/api/options?includeUnapproved=true').then(r => r.json()),
|
||||
]);
|
||||
allData = { categories: cats, options: opts };
|
||||
renderStats();
|
||||
renderPending();
|
||||
renderAll();
|
||||
document.getElementById('appStatus').textContent = 'Connected';
|
||||
} catch(e) {
|
||||
document.getElementById('appStatus').textContent = 'Connection error — is the server running?';
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const voters = new Set();
|
||||
let totalVotes = 0;
|
||||
let pending = 0;
|
||||
allData.options.forEach(o => {
|
||||
if (!o.approved) { pending++; return; }
|
||||
o.votes.forEach(v => voters.add(v.name));
|
||||
totalVotes += o.votes.length;
|
||||
});
|
||||
document.getElementById('statVoters').textContent = voters.size;
|
||||
document.getElementById('statOptions').textContent = allData.options.length - pending;
|
||||
document.getElementById('statVotes').textContent = totalVotes;
|
||||
document.getElementById('statPending').textContent = pending;
|
||||
}
|
||||
|
||||
function renderPending() {
|
||||
const pending = allData.options.filter(o => !o.approved);
|
||||
const list = document.getElementById('pendingList');
|
||||
document.getElementById('pendingBadge').textContent = pending.length;
|
||||
if (pending.length === 0) {
|
||||
list.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text-muted);font-size:0.8rem">No pending options ✓</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = pending.map(o => `
|
||||
<div class="option-row pending">
|
||||
<span class="cat-tag">${o.categoryId}</span>
|
||||
<div class="name">${o.name}</div>
|
||||
<div class="meta">by ${o.addedBy || 'unknown'}</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-approve" onclick="approve('${o.id}')">✓ Approve</button>
|
||||
<button class="btn btn-reject" onclick="reject('${o.id}')">✕ Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
const approved = allData.options.filter(o => o.approved);
|
||||
document.getElementById('allBadge').textContent = approved.length;
|
||||
document.getElementById('allList').innerHTML = approved.map(o => `
|
||||
<div class="option-row">
|
||||
<span class="cat-tag">${o.categoryId}</span>
|
||||
<div class="name">${o.name}</div>
|
||||
<div class="votes-badge">${o.votes.length} vote${o.votes.length !== 1 ? 's' : ''}</div>
|
||||
<div class="meta">${o.addedBy !== 'system' ? 'by ' + o.addedBy : 'system'}</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-delete" onclick="deleteOption('${o.id}')" title="Delete option">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function togglePolls() {
|
||||
try {
|
||||
// Get current state first
|
||||
const res = await fetch(API + '/api/results');
|
||||
const data = await res.json();
|
||||
const newState = !data.pollsOpen;
|
||||
await fetch(API + '/api/polls', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ open: newState })
|
||||
});
|
||||
updatePollsUI(newState);
|
||||
toast(newState ? 'Polls are now OPEN' : 'Polls are now CLOSED', 'success');
|
||||
} catch(e) {
|
||||
toast('Failed to toggle polls', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPollsState() {
|
||||
try {
|
||||
const res = await fetch(API + '/api/results');
|
||||
const data = await res.json();
|
||||
updatePollsUI(data.pollsOpen);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function updatePollsUI(open) {
|
||||
document.getElementById('pollsLabel').textContent = 'Polls: ' + (open ? 'OPEN' : 'CLOSED');
|
||||
const btn = document.getElementById('pollsBtn');
|
||||
btn.textContent = open ? 'Close Polls' : 'Open Polls';
|
||||
btn.className = 'btn-toggle ' + (open ? 'open' : 'closed');
|
||||
}
|
||||
|
||||
async function approve(id) {
|
||||
try {
|
||||
await fetch(API + '/api/options/' + id + '/approve', { method: 'POST' });
|
||||
toast('Option approved!', 'success');
|
||||
await loadData();
|
||||
} catch(e) { toast('Failed to approve', 'error'); }
|
||||
}
|
||||
|
||||
async function reject(id) {
|
||||
if (!confirm('Remove this option permanently?')) return;
|
||||
try {
|
||||
await fetch(API + '/api/options/' + id, { method: 'DELETE' });
|
||||
toast('Option removed', 'success');
|
||||
await loadData();
|
||||
} catch(e) { toast('Failed to remove option', 'error'); }
|
||||
}
|
||||
|
||||
async function deleteOption(id) {
|
||||
if (!confirm('Delete this option permanently?')) return;
|
||||
try {
|
||||
await fetch(API + '/api/options/' + id, { method: 'DELETE' });
|
||||
toast('Option deleted', 'success');
|
||||
await loadData();
|
||||
} catch(e) { toast('Failed to delete', 'error'); }
|
||||
}
|
||||
|
||||
// Check session password on load
|
||||
if (sessionStorage.getItem(PWD_KEY) === CORRECT_PWD) {
|
||||
document.getElementById('passwordGate').classList.add('hidden');
|
||||
document.getElementById('adminPanel').style.display = 'block';
|
||||
loadData();
|
||||
}
|
||||
loadPollsState();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1229
public/index.html
1229
public/index.html
File diff suppressed because it is too large
Load Diff
586
seed-data.js
Normal file
586
seed-data.js
Normal file
@@ -0,0 +1,586 @@
|
||||
const SEED_VERSION = 2;
|
||||
const PRICE_UPDATED_AT = '2026-04-29';
|
||||
|
||||
const CATEGORY_META = {
|
||||
hotel: { emoji: '🏨', color: '#3b82f6' },
|
||||
golf: { emoji: '⛳', color: '#22c55e' },
|
||||
nightlife: { emoji: '🎧', color: '#a855f7' },
|
||||
excursion: { emoji: '🚤', color: '#06b6d4' },
|
||||
itinerary: { emoji: '🗺️', color: '#fbbf24' },
|
||||
budget: { emoji: '💸', color: '#f97316' },
|
||||
results: { emoji: '🏆', color: '#facc15' },
|
||||
};
|
||||
|
||||
const BUDGET_SCENARIOS = [
|
||||
{
|
||||
id: 'budget-8',
|
||||
tier: 'Budget',
|
||||
groupSize: 8,
|
||||
perPerson: 1405,
|
||||
groupTotal: 11240,
|
||||
summary: 'Corazon + Palmilla + one shared activity + one nightlife night',
|
||||
notes: [
|
||||
'Flight estimate: $350',
|
||||
'Hotel estimate: $450',
|
||||
'Golf: Palmilla from $130',
|
||||
'Activity: public sail / whale watch about $95',
|
||||
'Food + drinks buffer: $275',
|
||||
'Private round-trip transfer share: about $33',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'budget-10',
|
||||
tier: 'Budget',
|
||||
groupSize: 10,
|
||||
perPerson: 1398,
|
||||
groupTotal: 13980,
|
||||
summary: 'Corazon + Palmilla + one shared activity + one nightlife night',
|
||||
notes: [
|
||||
'Flight estimate: $350',
|
||||
'Hotel estimate: $450',
|
||||
'Golf: Palmilla from $130',
|
||||
'Activity: public sail / whale watch about $95',
|
||||
'Food + drinks buffer: $275',
|
||||
'Private round-trip transfer share: about $26',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'budget-12',
|
||||
tier: 'Budget',
|
||||
groupSize: 12,
|
||||
perPerson: 1392,
|
||||
groupTotal: 16704,
|
||||
summary: 'Corazon + Palmilla + one shared activity + one nightlife night',
|
||||
notes: [
|
||||
'Flight estimate: $350',
|
||||
'Hotel estimate: $450',
|
||||
'Golf: Palmilla from $130',
|
||||
'Activity: public sail / whale watch about $95',
|
||||
'Food + drinks buffer: $275',
|
||||
'Private round-trip transfer share: about $22',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'balanced-8',
|
||||
tier: 'Balanced',
|
||||
groupSize: 8,
|
||||
perPerson: 1688,
|
||||
groupTotal: 13504,
|
||||
summary: 'Grand Fiesta Americana + golf + sunset sail + one nightlife night',
|
||||
notes: [
|
||||
'Flight estimate: $350',
|
||||
'All-inclusive stay: $850',
|
||||
'Golf: Cabo del Sol / similar from $180',
|
||||
'Sunset sail: about $125',
|
||||
'Nightlife + covers: $100',
|
||||
'Transfer + resort buffer: about $83',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'balanced-10',
|
||||
tier: 'Balanced',
|
||||
groupSize: 10,
|
||||
perPerson: 1681,
|
||||
groupTotal: 16810,
|
||||
summary: 'Grand Fiesta Americana + golf + sunset sail + one nightlife night',
|
||||
notes: [
|
||||
'Flight estimate: $350',
|
||||
'All-inclusive stay: $850',
|
||||
'Golf: Cabo del Sol / similar from $180',
|
||||
'Sunset sail: about $125',
|
||||
'Nightlife + covers: $100',
|
||||
'Transfer + resort buffer: about $76',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'balanced-12',
|
||||
tier: 'Balanced',
|
||||
groupSize: 12,
|
||||
perPerson: 1677,
|
||||
groupTotal: 20124,
|
||||
summary: 'Grand Fiesta Americana + golf + sunset sail + one nightlife night',
|
||||
notes: [
|
||||
'Flight estimate: $350',
|
||||
'All-inclusive stay: $850',
|
||||
'Golf: Cabo del Sol / similar from $180',
|
||||
'Sunset sail: about $125',
|
||||
'Nightlife + covers: $100',
|
||||
'Transfer + resort buffer: about $72',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'splurge-8',
|
||||
tier: 'Splurge',
|
||||
groupSize: 8,
|
||||
perPerson: 2484,
|
||||
groupTotal: 19872,
|
||||
summary: 'Breathless or Secrets + premium golf + private charter + VIP table',
|
||||
notes: [
|
||||
'Flight estimate: $400',
|
||||
'Upscale all-inclusive stay: $1250',
|
||||
'Premium golf: Quivira / similar about $250',
|
||||
'Private whale or charter share: about $188',
|
||||
'VIP nightlife share: about $213',
|
||||
'Transfers + premium dinner buffer: about $183',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'splurge-10',
|
||||
tier: 'Splurge',
|
||||
groupSize: 10,
|
||||
perPerson: 2346,
|
||||
groupTotal: 23460,
|
||||
summary: 'Breathless or Secrets + premium golf + private charter + VIP table',
|
||||
notes: [
|
||||
'Flight estimate: $400',
|
||||
'Upscale all-inclusive stay: $1250',
|
||||
'Premium golf: Quivira / similar about $250',
|
||||
'Private whale or charter share: about $150',
|
||||
'VIP nightlife share: about $170',
|
||||
'Transfers + premium dinner buffer: about $126',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'splurge-12',
|
||||
tier: 'Splurge',
|
||||
groupSize: 12,
|
||||
perPerson: 2289,
|
||||
groupTotal: 27468,
|
||||
summary: 'Breathless or Secrets + premium golf + private charter + VIP table',
|
||||
notes: [
|
||||
'Flight estimate: $400',
|
||||
'Upscale all-inclusive stay: $1250',
|
||||
'Premium golf: Quivira / similar about $250',
|
||||
'Private whale or charter share: about $125',
|
||||
'VIP nightlife share: about $142',
|
||||
'Transfers + premium dinner buffer: about $122',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function createOption(option) {
|
||||
const categoryColor = CATEGORY_META[option.categoryId]?.color || '#888';
|
||||
const primaryUrl = option.links?.[0]?.url || option.url || null;
|
||||
return {
|
||||
approved: true,
|
||||
addedBy: 'system',
|
||||
votes: [],
|
||||
details: [],
|
||||
links: [],
|
||||
categoryColor,
|
||||
url: primaryUrl,
|
||||
...option,
|
||||
categoryColor,
|
||||
url: primaryUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSeedData() {
|
||||
return {
|
||||
seedVersion: SEED_VERSION,
|
||||
priceUpdatedAt: PRICE_UPDATED_AT,
|
||||
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: 'budget', name: 'Budget', emoji: '💸' },
|
||||
{ id: 'results', name: 'Results', emoji: '🏆' },
|
||||
],
|
||||
budgetScenarios: BUDGET_SCENARIOS,
|
||||
options: [
|
||||
createOption({
|
||||
id: 'hotel-corazon',
|
||||
seedKey: 'hotel-corazon',
|
||||
categoryId: 'hotel',
|
||||
name: 'Corazon Cabo Resort & Spa',
|
||||
desc: 'Best party-first base on Medano Beach. Walkable to downtown and Costco package pages currently show transfer-inclusive offers plus 4th or 5th night promos.',
|
||||
lat: 23.0639,
|
||||
lng: -109.6991,
|
||||
details: ['KAYAK recent rooms $173-$551/night', 'Costco package', 'Walk to marina nightlife'],
|
||||
links: [
|
||||
{ label: 'Official', url: 'https://www.corazoncabo.com/' },
|
||||
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSCORAZON20230510' },
|
||||
{ label: 'KAYAK', url: 'https://www.kayak.com/Cabo-San-Lucas-Hotels-Cabo-Villas-Beach-Resort-Spa.58565.ksp' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'hotel-breathless',
|
||||
seedKey: 'hotel-breathless',
|
||||
categoryId: 'hotel',
|
||||
name: 'Breathless Cabo San Lucas',
|
||||
desc: 'Adults-only, marina-facing, and the easiest all-inclusive pick for a true bachelor-party vibe.',
|
||||
lat: 23.0628,
|
||||
lng: -109.6981,
|
||||
details: ['Apple Vacations from $942 pp / 3 nights', 'KAYAK from $393/night', 'Adults-only'],
|
||||
links: [
|
||||
{ label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/breathless/mexico/cabo-san-lucas-resort-spa/' },
|
||||
{ label: 'Hyatt', url: 'https://www.hyatt.com/en-US/hotel/mexico/breathless-cabo-san-lucas/brcsl' },
|
||||
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSBREATHE20230330' },
|
||||
{ label: 'Apple Vacations', url: 'https://www.applevacations.com/hotels/breathless-cabo-san-lucas-resort-spa-all-inclusive-adults-only?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&los=3&mode=0&onsaleid=1398047&traveldate=2026-05-10' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'hotel-grand-fiesta',
|
||||
seedKey: 'hotel-grand-fiesta',
|
||||
categoryId: 'hotel',
|
||||
name: 'Grand Fiesta Americana Los Cabos',
|
||||
desc: 'Best overall balance for golf + all-inclusive + quality. Strong fit if the group wants one easy answer without going full splurge.',
|
||||
lat: 23.0949,
|
||||
lng: -109.7067,
|
||||
details: ['Apple Vacations from $859 pp / 3 nights', 'KAYAK from $209/night', 'Golf-friendly'],
|
||||
links: [
|
||||
{ label: 'Official', url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos' },
|
||||
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
|
||||
{ label: 'Apple Vacations', url: 'https://www.applevacations.com/HotelById?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=43000&los=3&mode=0&onsaleid=1614780&redirect=false&remotesourcecode=HBSHotel&traveldate=&vendorcode=APV' },
|
||||
{ label: 'KAYAK', url: 'https://www.kayak.com/Cabo-San-Lucas-Hotels-Grand-Fiesta-Americana-Los-Cabos-Golf-Spa.331383.ksp' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'hotel-secrets',
|
||||
seedKey: 'hotel-secrets',
|
||||
categoryId: 'hotel',
|
||||
name: 'Secrets Puerto Los Cabos',
|
||||
desc: 'Upscale adults-only pick with strong group-trip polish. Better for a luxe weekend than a chaos-first party hotel.',
|
||||
lat: 23.0227,
|
||||
lng: -109.7062,
|
||||
details: ['CheapCaribbean from $885 pp / 3 nights', '4-night examples from $1,108 pp', 'Adults-only'],
|
||||
links: [
|
||||
{ label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/secrets/mexico/puerto-los-cabos-golf-spa-resort/' },
|
||||
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSSECRETSPUERT20230330' },
|
||||
{ label: 'CheapCaribbean', url: 'https://www.cheapcaribbean.com/HotelById/?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=SJDSCRT&los=3&mode=0&onsaleid=2173329&redirect=false&remotesourcecode=LtmsHotel&traveldate=&vendorcode=CCV' },
|
||||
{ label: 'KAYAK', url: 'https://www.kayak.com/San-Jose-del-Cabo-Hotels-Secrets-Puerto-Los-Cabos-Adults-Only.551846.ksp' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'hotel-pacifica',
|
||||
seedKey: 'hotel-pacifica',
|
||||
categoryId: 'hotel',
|
||||
name: 'Pueblo Bonito Pacifica',
|
||||
desc: 'Luxury adults-only option with Quivira access. Best if the trip is really a premium golf weekend with nightlife as a side quest.',
|
||||
lat: 23.0474,
|
||||
lng: -109.7053,
|
||||
details: ['Adults-only', 'Quivira access', 'Luxury retreat'],
|
||||
links: [
|
||||
{ label: 'Official', url: 'https://www.pueblobonito.com/resorts/pacifica?resort=4' },
|
||||
{ label: 'Quivira FAQ', url: 'https://www.pueblobonito.com/resorts/pacifica/faq' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'golf-palmilla',
|
||||
seedKey: 'golf-palmilla',
|
||||
categoryId: 'golf',
|
||||
name: 'Palmilla Golf Club',
|
||||
desc: 'Best public price signal I found with transparent inclusions: green fee, shared cart, practice facilities, and bottled water.',
|
||||
lat: 23.0519,
|
||||
lng: -109.7058,
|
||||
details: ['From $130 pp', 'Shared cart included', 'Strong budget-track pick'],
|
||||
links: [
|
||||
{ label: 'Cabo Villas Golf', url: 'https://www.cabovillas.com/golf/palmilla' },
|
||||
{ label: 'Visit Los Cabos Golf Guide', url: 'https://www.visitloscabos.travel/golf/' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'golf-cabo-del-sol',
|
||||
seedKey: 'golf-cabo-del-sol',
|
||||
categoryId: 'golf',
|
||||
name: 'Cabo del Sol',
|
||||
desc: 'The most natural golf pairing for Grand Fiesta Americana and the balanced-track itinerary.',
|
||||
lat: 23.0569,
|
||||
lng: -109.6962,
|
||||
details: ['Use about $180 as current planning number', 'Best balanced-track fit', 'Ocean-desert layout'],
|
||||
links: [
|
||||
{ label: 'Official', url: 'https://cabodelsol.com/' },
|
||||
{ label: 'Visit Los Cabos Golf Guide', url: 'https://www.visitloscabos.travel/golf/' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'golf-quivira',
|
||||
seedKey: 'golf-quivira',
|
||||
categoryId: 'golf',
|
||||
name: 'Quivira Golf Club',
|
||||
desc: 'Premium golf move for the splurge weekend. Access is easiest through the Pueblo Bonito / Pacifica side of the destination.',
|
||||
lat: 23.0403,
|
||||
lng: -109.7221,
|
||||
details: ['Use about $250 for planning', 'Pairs with Pacifica', 'Signature ocean holes'],
|
||||
links: [
|
||||
{ label: 'Official', url: 'https://www.quiviraloscabos.com/golf/' },
|
||||
{ label: 'Pacifica FAQ', url: 'https://www.pueblobonito.com/resorts/pacifica/faq' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'golf-puerto-los-cabos',
|
||||
seedKey: 'golf-puerto-los-cabos',
|
||||
categoryId: 'golf',
|
||||
name: 'Puerto Los Cabos Golf',
|
||||
desc: 'Most natural pairing with Secrets Puerto Los Cabos if the group picks the upscale San Jose side.',
|
||||
lat: 23.0308,
|
||||
lng: -109.6964,
|
||||
details: ['Convenient from Secrets', 'Upscale east-cape feel', 'Good alternative to Quivira'],
|
||||
links: [
|
||||
{ label: 'Visit Los Cabos Golf Guide', url: 'https://www.visitloscabos.travel/golf/' },
|
||||
{ label: 'Secrets Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/secrets/mexico/puerto-los-cabos-golf-spa-resort/' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'nightlife-cabo-bash',
|
||||
seedKey: 'nightlife-cabo-bash',
|
||||
categoryId: 'nightlife',
|
||||
name: 'Cabo Bash VIP Nightlife',
|
||||
desc: 'Most turnkey option if you want the weekend hosted instead of DIY. They also coordinate yachts, villas, and day clubs.',
|
||||
lat: 23.0627,
|
||||
lng: -109.6989,
|
||||
details: ['Gold package for 16 guests: $1,700', 'Concierge coordination', 'Strongest bachelor specialist'],
|
||||
links: [
|
||||
{ label: 'Bachelor Parties', url: 'https://www.cabobash.com/bachelor.html' },
|
||||
{ label: 'Nightlife', url: 'https://www.cabobash.com/nightlife.html' },
|
||||
{ label: 'Day Clubs', url: 'https://www.cabobash.com/day-clubs.html' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'nightlife-cabo-agency',
|
||||
seedKey: 'nightlife-cabo-agency',
|
||||
categoryId: 'nightlife',
|
||||
name: 'The Cabo Agency VIP Tables',
|
||||
desc: 'Best if you want to book specific tables instead of a full concierge weekend.',
|
||||
lat: 23.0692,
|
||||
lng: -109.6993,
|
||||
details: ['Cabo Wabo VIP table $155 with $100 credit', 'Booth $400 with $300 credit', 'A la carte nightlife'],
|
||||
links: [
|
||||
{ label: 'VIP Tables', url: 'https://www.thecaboagency.com/cabo_vip_tables.php' },
|
||||
{ label: 'Entertainment Packages', url: 'https://www.thecaboagency.com/cabo_entertainment_packages.php' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'nightlife-taboo',
|
||||
seedKey: 'nightlife-taboo',
|
||||
categoryId: 'nightlife',
|
||||
name: 'Taboo Beach Club',
|
||||
desc: 'High-spend daytime flex. Better for a splashy afternoon than an all-weekend base.',
|
||||
lat: 23.0637,
|
||||
lng: -109.7001,
|
||||
details: ['Pool island for 4: $884', 'Cabana for 8: $2,060', 'Use for splurge tier'],
|
||||
links: [
|
||||
{ label: 'Cabo Bash Day Clubs', url: 'https://www.cabobash.com/day-clubs.html' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'nightlife-mango-deck',
|
||||
seedKey: 'nightlife-mango-deck',
|
||||
categoryId: 'nightlife',
|
||||
name: 'Mango Deck / Office Zone',
|
||||
desc: 'Best lower-spend party zone if you want daytime chaos close to Medano Beach without burning the whole budget.',
|
||||
lat: 23.0631,
|
||||
lng: -109.6995,
|
||||
details: ['Mango Deck deposit from $40 pp', 'Easy with Corazon', 'Budget-track friendly'],
|
||||
links: [
|
||||
{ label: 'Cabo Bash Day Clubs', url: 'https://www.cabobash.com/day-clubs.html' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'excursion-whale-public',
|
||||
seedKey: 'excursion-whale-public',
|
||||
categoryId: 'excursion',
|
||||
name: 'Cabo Adventures Whale Watching',
|
||||
desc: 'February is prime whale season, and this is the cleanest official public-tour price signal I found.',
|
||||
lat: 23.0626,
|
||||
lng: -109.7004,
|
||||
details: ['From $76', 'Prime season in February', 'Dock fee and transport extras may apply'],
|
||||
links: [
|
||||
{ label: 'Official', url: 'https://www.cabo-adventures.com/en/' },
|
||||
{ label: 'Whale Season Guide', url: 'https://www.visitloscabos.travel/blog/post/whale-watching-in-los-cabos-2025-the-ultimate-guide-to-an-unforgettable-season/' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'excursion-whale-private',
|
||||
seedKey: 'excursion-whale-private',
|
||||
categoryId: 'excursion',
|
||||
name: 'Private Whale Watching Charter',
|
||||
desc: 'Best splurge-group activity because the per-person hit gets much better as the group size rises.',
|
||||
lat: 23.0626,
|
||||
lng: -109.7004,
|
||||
details: ['From $1,504 total', 'About $188 pp at 8', 'About $125 pp at 12'],
|
||||
links: [
|
||||
{ label: 'Cabo Adventures Private Tours', url: 'https://www.cabo-adventures.com/en/tours/private-cabo/' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'excursion-atv',
|
||||
seedKey: 'excursion-atv',
|
||||
categoryId: 'excursion',
|
||||
name: 'ATV Desert Adventure',
|
||||
desc: 'Classic bachelor-party activity with a real extra-fee caveat worth budgeting up front.',
|
||||
lat: 23.0289,
|
||||
lng: -109.6689,
|
||||
details: ['About $78-$91', '$35 damage waiver per vehicle', 'Entrance fee noted at check-in'],
|
||||
links: [
|
||||
{ label: 'Tour Landing Page', url: 'https://www.cabo-adventures.com/en/tours/land-adventures/' },
|
||||
{ label: 'ATV Details', url: 'https://www.cabo-adventures.com/en/tour/atv-desert-adventure/' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'excursion-sail',
|
||||
seedKey: 'excursion-sail',
|
||||
categoryId: 'excursion',
|
||||
name: 'Sunset Sail / Public Yacht Day',
|
||||
desc: 'Best balanced activity if the group wants a solid Cabo moment without chartering the entire day.',
|
||||
lat: 23.0634,
|
||||
lng: -109.6978,
|
||||
details: ['Public sail from $109', 'Sunset sail $124.50', 'Private 38 foot sailboat from $855.94'],
|
||||
links: [
|
||||
{ label: 'Cabo Sailing', url: 'https://www.cabovillas.com/water-tours/cabo-sailing' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'itinerary-budget',
|
||||
seedKey: 'itinerary-budget',
|
||||
categoryId: 'itinerary',
|
||||
name: 'Budget Track: Corazon + Palmilla + Public Activity',
|
||||
desc: 'Best option if the group wants a real Cabo bachelor trip while keeping the all-in number close to the low-$1.4k range before extra bar tabs.',
|
||||
lat: 23.0639,
|
||||
lng: -109.6991,
|
||||
details: ['8 guys: about $1,405 pp', '10 guys: about $1,398 pp', '12 guys: about $1,392 pp'],
|
||||
links: [
|
||||
{ label: 'Corazon', url: 'https://www.corazoncabo.com/' },
|
||||
{ label: 'Palmilla', url: 'https://www.cabovillas.com/golf/palmilla' },
|
||||
{ label: 'Transfers', url: 'https://www.cabovillas.com/transportation' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'itinerary-balanced',
|
||||
seedKey: 'itinerary-balanced',
|
||||
categoryId: 'itinerary',
|
||||
name: 'Balanced Track: Grand Fiesta + Golf + Sail',
|
||||
desc: 'Best all-around answer for a group that wants fewer logistics, a nice resort, and one clean golf day.',
|
||||
lat: 23.0949,
|
||||
lng: -109.7067,
|
||||
details: ['8 guys: about $1,688 pp', '10 guys: about $1,681 pp', '12 guys: about $1,677 pp'],
|
||||
links: [
|
||||
{ label: 'Grand Fiesta', url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos' },
|
||||
{ label: 'Costco Package', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
|
||||
{ label: 'Sailing', url: 'https://www.cabovillas.com/water-tours/cabo-sailing' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'itinerary-splurge',
|
||||
seedKey: 'itinerary-splurge',
|
||||
categoryId: 'itinerary',
|
||||
name: 'Splurge Track: Breathless or Secrets + Premium Golf + VIP Night',
|
||||
desc: 'Best if the weekend is really about going big once, with the budget climbing above $2.2k per person depending on group size.',
|
||||
lat: 23.0628,
|
||||
lng: -109.6981,
|
||||
details: ['8 guys: about $2,484 pp', '10 guys: about $2,346 pp', '12 guys: about $2,289 pp'],
|
||||
links: [
|
||||
{ label: 'Breathless', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/breathless/mexico/cabo-san-lucas-resort-spa/' },
|
||||
{ label: 'Secrets', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/secrets/mexico/puerto-los-cabos-golf-spa-resort/' },
|
||||
{ label: 'VIP Tables', url: 'https://www.thecaboagency.com/cabo_vip_tables.php' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'itinerary-concierge',
|
||||
seedKey: 'itinerary-concierge',
|
||||
categoryId: 'itinerary',
|
||||
name: 'Concierge Route: Cabo Bash / Cabo Agency',
|
||||
desc: 'Best if the group wants to stop spreadsheeting and hand flights, villas, yachts, transfers, and nightlife to a specialist.',
|
||||
lat: 23.0633,
|
||||
lng: -109.6992,
|
||||
details: ['Most turnkey', 'Great for split budgets', 'Request quote for final pricing'],
|
||||
links: [
|
||||
{ label: 'Cabo Bash', url: 'https://www.cabobash.com/bachelor.html' },
|
||||
{ label: 'The Cabo Agency', url: 'https://www.thecaboagency.com/cabo_bachelor_party.php' },
|
||||
{ label: 'Blue Desert Package', url: 'https://www.bluedesertcabo.com/activities/packages/bachelor-party-vacation-package/' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'budget-option-budget',
|
||||
seedKey: 'budget-option-budget',
|
||||
categoryId: 'budget',
|
||||
name: 'Budget Track',
|
||||
desc: 'Corazon + one golf round + one public activity + one nightlife night. Best value if you want the trip fun but not financially reckless.',
|
||||
details: ['8: $1,405 pp', '10: $1,398 pp', '12: $1,392 pp'],
|
||||
links: [
|
||||
{ label: 'Corazon', url: 'https://www.corazoncabo.com/' },
|
||||
{ label: 'Palmilla', url: 'https://www.cabovillas.com/golf/palmilla' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'budget-option-balanced',
|
||||
seedKey: 'budget-option-balanced',
|
||||
categoryId: 'budget',
|
||||
name: 'Balanced Track',
|
||||
desc: 'Grand Fiesta all-inclusive + better golf + sunset sail + one nightlife push. Strongest overall bachelor-weekend value.',
|
||||
details: ['8: $1,688 pp', '10: $1,681 pp', '12: $1,677 pp'],
|
||||
links: [
|
||||
{ label: 'Grand Fiesta', url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos' },
|
||||
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
|
||||
],
|
||||
}),
|
||||
createOption({
|
||||
id: 'budget-option-splurge',
|
||||
seedKey: 'budget-option-splurge',
|
||||
categoryId: 'budget',
|
||||
name: 'Splurge Track',
|
||||
desc: 'Adults-only resort, premium golf, private charter, and VIP nightlife. This is the blowout version.',
|
||||
details: ['8: $2,484 pp', '10: $2,346 pp', '12: $2,289 pp'],
|
||||
links: [
|
||||
{ label: 'Breathless', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/breathless/mexico/cabo-san-lucas-resort-spa/' },
|
||||
{ label: 'Private Tour', url: 'https://www.cabo-adventures.com/en/tours/private-cabo/' },
|
||||
],
|
||||
}),
|
||||
],
|
||||
voters: [],
|
||||
pollsOpen: true,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeSeedData(existing = {}) {
|
||||
const seed = buildSeedData();
|
||||
const existingOptions = Array.isArray(existing.options) ? existing.options : [];
|
||||
const mergedSeedOptions = seed.options.map((seedOption) => {
|
||||
const match = existingOptions.find((option) => (
|
||||
option.seedKey === seedOption.seedKey
|
||||
|| (option.addedBy === 'system' && option.categoryId === seedOption.categoryId && option.name === seedOption.name)
|
||||
));
|
||||
|
||||
return {
|
||||
...seedOption,
|
||||
votes: Array.isArray(match?.votes) ? match.votes : [],
|
||||
approved: typeof match?.approved === 'boolean' ? match.approved : seedOption.approved,
|
||||
addedBy: match?.addedBy || seedOption.addedBy,
|
||||
};
|
||||
});
|
||||
|
||||
const preservedCustomOptions = existingOptions.filter((option) => {
|
||||
if (option.addedBy === 'system') return false;
|
||||
|
||||
return !mergedSeedOptions.some((seedOption) => (
|
||||
(seedOption.seedKey && option.seedKey === seedOption.seedKey)
|
||||
|| (seedOption.categoryId === option.categoryId && seedOption.name === option.name)
|
||||
));
|
||||
});
|
||||
|
||||
const existingCategories = Array.isArray(existing.categories) ? existing.categories : [];
|
||||
const preservedCustomCategories = existingCategories.filter(
|
||||
(category) => !seed.categories.some((seedCategory) => seedCategory.id === category.id),
|
||||
);
|
||||
|
||||
return {
|
||||
...existing,
|
||||
seedVersion: seed.seedVersion,
|
||||
priceUpdatedAt: seed.priceUpdatedAt,
|
||||
categories: [...seed.categories, ...preservedCustomCategories],
|
||||
budgetScenarios: seed.budgetScenarios,
|
||||
options: [...mergedSeedOptions, ...preservedCustomOptions],
|
||||
voters: Array.isArray(existing.voters) ? existing.voters : [],
|
||||
pollsOpen: typeof existing.pollsOpen === 'boolean' ? existing.pollsOpen : true,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SEED_VERSION,
|
||||
PRICE_UPDATED_AT,
|
||||
CATEGORY_META,
|
||||
buildSeedData,
|
||||
mergeSeedData,
|
||||
};
|
||||
348
server.js
348
server.js
@@ -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);
|
||||
@@ -15,162 +16,192 @@ 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 ──────────────────────────────────────────────
|
||||
app.get('/admin', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
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: 'Full Itineraries', 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 · $40–50 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 $20–30/day', 'Flexible dining $200', 'Nightlife $40–60', '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);
|
||||
@@ -178,28 +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 });
|
||||
});
|
||||
|
||||
// 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);
|
||||
app.delete('/api/options/:id', (req, res) => {
|
||||
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: 'vote_update', results: data.options.filter(o => o.approved).map(o => ({ id: o.id, votes: o.votes.length })) });
|
||||
broadcast({ type: 'option_deleted', id: req.params.id });
|
||||
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);
|
||||
@@ -207,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.
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user