Initial commit: Cabo Bachelor Party voting app
- Node.js/Express + WebSocket real-time voting - Hotel, Golf, Nightlife, Excursion, Itinerary categories - Seed data with 20+ Cabo venues and 3 itineraries - Gitea issues: #1-16 for UI/UX improvements
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
data/
|
||||
.DS_Store
|
||||
712
public/index.html
Normal file
712
public/index.html
Normal file
@@ -0,0 +1,712 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cabo Bachelor Party — Vote</title>
|
||||
<style>
|
||||
/* ── Variables ─────────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #0b0d14;
|
||||
--surface: #13161f;
|
||||
--surface2: #1a1e2a;
|
||||
--border: #252a38;
|
||||
--accent: #00d4ff;
|
||||
--accent2: #fbbf24;
|
||||
--text: #e0e6f0;
|
||||
--text-muted: #7a8499;
|
||||
--green: #34d399;
|
||||
--red: #f87171;
|
||||
--hotel: #3b82f6;
|
||||
--golf: #22c55e;
|
||||
--nightlife: #a855f7;
|
||||
--excursion: #06b6d4;
|
||||
--itinerary: #fbbf24;
|
||||
}
|
||||
|
||||
/* ── Reset ──────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { font-size: 14px; }
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────── */
|
||||
header {
|
||||
background: linear-gradient(135deg, #13161f 0%, #1a1e2a 100%);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
header h1 { font-size: 1.1rem; color: var(--accent); font-weight: 700; }
|
||||
header .meta { font-size: 0.78rem; color: var(--text-muted); text-align: right; }
|
||||
.voter-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(0,212,255,0.12);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 20px;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
.voter-badge button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
.voter-badge button:hover { color: var(--red); }
|
||||
|
||||
/* ── Name Modal ─────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.modal-overlay.hidden { display: none; }
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
width: 360px;
|
||||
text-align: center;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||
}
|
||||
.modal h2 { font-size: 1.3rem; margin-bottom: 6px; color: var(--accent); }
|
||||
.modal p { color: var(--text-muted); font-size: 0.85rem; margin-bottom: 20px; }
|
||||
.modal 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;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.modal input:focus { border-color: var(--accent); }
|
||||
.modal 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;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.modal button:hover { opacity: 0.85; }
|
||||
|
||||
/* ── Tabs ───────────────────────────────────────────────── */
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.tabs::-webkit-scrollbar { display: none; }
|
||||
.tab {
|
||||
flex: 1;
|
||||
min-width: 90px;
|
||||
padding: 10px 8px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border-bottom: 3px solid transparent;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tab:hover { color: var(--text); background: rgba(255,255,255,0.03); }
|
||||
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.tab .tab-emoji { display: block; font-size: 1.3rem; margin-bottom: 2px; }
|
||||
.tab .tab-count {
|
||||
display: inline-block;
|
||||
background: var(--surface2);
|
||||
border-radius: 10px;
|
||||
padding: 1px 6px;
|
||||
font-size: 0.65rem;
|
||||
margin-top: 2px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tab.active .tab-count { background: rgba(0,212,255,0.15); color: var(--accent); }
|
||||
|
||||
/* ── Main content ──────────────────────────────────────── */
|
||||
main { flex: 1; overflow-y: auto; padding: 16px; max-width: 700px; margin: 0 auto; width: 100%; }
|
||||
|
||||
/* ── Status bar ─────────────────────────────────────────── */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
.status-dot.offline { background: var(--red); animation: none; }
|
||||
@keyframes pulse {
|
||||
0%,100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.polls-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.polls-badge.open { background: rgba(52,211,153,0.15); color: var(--green); }
|
||||
.polls-badge.closed { background: rgba(248,113,113,0.15); color: var(--red); }
|
||||
|
||||
/* ── Option cards ────────────────────────────────────────── */
|
||||
.options-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.option-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
transition: border-color 0.2s, transform 0.15s;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.option-card:hover { border-color: var(--accent); transform: translateY(-1px); }
|
||||
.option-card.voted {
|
||||
border-color: var(--accent);
|
||||
background: rgba(0,212,255,0.05);
|
||||
}
|
||||
.option-card.voted::before {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 12px;
|
||||
color: var(--accent);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.option-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.option-name {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: #fff;
|
||||
flex: 1;
|
||||
padding-right: 20px;
|
||||
}
|
||||
.option-votes {
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.option-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.option-link {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
color: var(--accent);
|
||||
opacity: 0.7;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.option-link:hover { opacity: 1; text-decoration: underline; }
|
||||
|
||||
/* Vote bar */
|
||||
.vote-bar-bg {
|
||||
height: 4px;
|
||||
background: var(--surface2);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vote-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
width: 0%;
|
||||
}
|
||||
.vote-bar-fill.hotel { background: var(--hotel); }
|
||||
.vote-bar-fill.golf { background: var(--golf); }
|
||||
.vote-bar-fill.nightlife { background: var(--nightlife); }
|
||||
.vote-bar-fill.excursion { background: var(--excursion); }
|
||||
.vote-bar-fill.itinerary { background: var(--itinerary); }
|
||||
|
||||
.voters-row {
|
||||
margin-top: 5px;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
/* Itinerary details */
|
||||
.itin-details {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.itin-tag {
|
||||
background: var(--surface2);
|
||||
border-radius: 4px;
|
||||
padding: 2px 7px;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Add Option ─────────────────────────────────────────── */
|
||||
.add-section {
|
||||
margin-top: 20px;
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
.add-section h3 {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.form-grid input, .form-grid textarea, .form-grid select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
resize: none;
|
||||
}
|
||||
.form-grid input:focus, .form-grid textarea:focus, .form-grid select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.form-grid textarea { min-height: 60px; }
|
||||
.form-grid select { cursor: pointer; }
|
||||
.btn-row { display: flex; gap: 8px; margin-top: 4px; }
|
||||
.btn-primary {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.85; }
|
||||
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-ghost {
|
||||
padding: 8px 14px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
border-radius: 8px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-ghost:hover { border-color: var(--text-muted); color: var(--text); }
|
||||
|
||||
/* ── Added by you ────────────────────────────────────────── */
|
||||
.pending-note {
|
||||
margin-top: 8px;
|
||||
font-size: 0.68rem;
|
||||
color: var(--accent);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── 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 cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.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); }
|
||||
|
||||
/* ── Empty / loading ─────────────────────────────────────── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.empty-state .empty-emoji { font-size: 2rem; margin-bottom: 10px; }
|
||||
|
||||
/* ── Mobile ──────────────────────────────────────────────── */
|
||||
@media (max-width: 480px) {
|
||||
header { flex-direction: column; gap: 6px; align-items: flex-start; }
|
||||
.meta { text-align: left; }
|
||||
main { padding: 12px; }
|
||||
.tab { min-width: 70px; font-size: 0.68rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Name Modal -->
|
||||
<div class="modal-overlay" id="nameModal">
|
||||
<div class="modal">
|
||||
<h2>🏄 Who's Voting?</h2>
|
||||
<p>Enter your name so groomsmen know who voted for what. Stored locally — only visible to the group.</p>
|
||||
<input type="text" id="voterNameInput" placeholder="e.g. Mike, Chris, Dave…" maxlength="30" autocomplete="off" />
|
||||
<button onclick="submitName()">Join the Vote →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<header>
|
||||
<h1>📍 Cabo Bachelor Party — Vote</h1>
|
||||
<div class="meta">
|
||||
<div class="voter-badge" id="voterBadge" style="display:none;">
|
||||
<span>👤 <span id="voterDisplayName"></span></span>
|
||||
<button onclick="changeName()" title="Change name">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs" id="tabsBar"></div>
|
||||
|
||||
<!-- Main -->
|
||||
<main>
|
||||
<div class="status-bar">
|
||||
<div><span class="status-dot" id="wsDot"></span><span id="wsStatus">Connecting…</span></div>
|
||||
<div><span class="polls-badge open" id="pollsBadge">POLLS OPEN</span></div>
|
||||
<div><span id="totalVotersCount"></span></div>
|
||||
</div>
|
||||
|
||||
<div class="options-list" id="optionsList">
|
||||
<div class="empty-state"><div class="empty-emoji">⏳</div>Loading options…</div>
|
||||
</div>
|
||||
|
||||
<!-- Add option -->
|
||||
<div class="add-section">
|
||||
<h3>➕ Suggest a Place</h3>
|
||||
<div class="form-grid">
|
||||
<input type="text" id="addName" placeholder="Name of the place (required)" maxlength="80" />
|
||||
<input type="text" id="addDesc" placeholder="Short description — price, vibe, what to expect…" maxlength="200" />
|
||||
<input type="url" id="addUrl" placeholder="Website URL (optional)" />
|
||||
<div class="btn-row">
|
||||
<select id="addCategory">
|
||||
<option value="hotel">🏨 Hotel</option>
|
||||
<option value="golf">⛳ Golf</option>
|
||||
<option value="nightlife">🎧 Nightlife</option>
|
||||
<option value="excursion">🚤 Excursion</option>
|
||||
<option value="itinerary">🗺️ Full Itinerary</option>
|
||||
</select>
|
||||
<button class="btn-primary" id="addBtn" onclick="submitNewOption()">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
// ── State ──────────────────────────────────────────────────
|
||||
let state = {
|
||||
voterName: localStorage.getItem('cabo_voter_name') || '',
|
||||
categories: [],
|
||||
options: [],
|
||||
pollsOpen: true,
|
||||
totalVoters: 0,
|
||||
wsConnected: false,
|
||||
};
|
||||
let ws = null;
|
||||
let activeTab = 'hotel';
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────
|
||||
function init() {
|
||||
if (state.voterName) {
|
||||
applyVoterName(state.voterName);
|
||||
}
|
||||
connectWS();
|
||||
renderTabs();
|
||||
document.getElementById('voterNameInput').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') submitName();
|
||||
});
|
||||
document.getElementById('voterNameInput').focus();
|
||||
}
|
||||
|
||||
// ── WebSocket ──────────────────────────────────────────────
|
||||
function connectWS() {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${location.host}`;
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
state.wsConnected = true;
|
||||
updateWsStatus('Connected');
|
||||
document.getElementById('wsDot').classList.remove('offline');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'init') {
|
||||
state.categories = msg.categories;
|
||||
state.options = msg.options;
|
||||
state.pollsOpen = msg.pollsOpen;
|
||||
state.totalVoters = msg.totalVoters;
|
||||
renderTabs();
|
||||
render();
|
||||
} else if (msg.type === 'vote_update') {
|
||||
msg.results.forEach(r => {
|
||||
const opt = state.options.find(o => o.id === r.id);
|
||||
if (opt) {
|
||||
opt.votes = r.votes;
|
||||
opt.voters = r.voters;
|
||||
}
|
||||
});
|
||||
render();
|
||||
} else if (msg.type === 'option_added' || msg.type === 'option_approved') {
|
||||
if (!state.options.find(o => o.id === msg.option.id)) {
|
||||
state.options.push(msg.option);
|
||||
renderTabs();
|
||||
render();
|
||||
}
|
||||
} else if (msg.type === 'polls_status') {
|
||||
state.pollsOpen = msg.open;
|
||||
updatePollsBadge();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = ws.onerror = () => {
|
||||
state.wsConnected = false;
|
||||
updateWsStatus('Reconnecting…');
|
||||
document.getElementById('wsDot').classList.add('offline');
|
||||
setTimeout(connectWS, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
function wsSend(obj) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
function updateWsStatus(text) {
|
||||
document.getElementById('wsStatus').textContent = text;
|
||||
}
|
||||
|
||||
function updatePollsBadge() {
|
||||
const badge = document.getElementById('pollsBadge');
|
||||
badge.textContent = state.pollsOpen ? 'POLLS OPEN' : 'POLLS CLOSED';
|
||||
badge.className = 'polls-badge ' + (state.pollsOpen ? 'open' : 'closed');
|
||||
}
|
||||
|
||||
// ── Name modal ────────────────────────────────────────────
|
||||
function submitName() {
|
||||
const name = document.getElementById('voterNameInput').value.trim();
|
||||
if (!name) return;
|
||||
state.voterName = name;
|
||||
localStorage.setItem('cabo_voter_name', name);
|
||||
applyVoterName(name);
|
||||
document.getElementById('nameModal').classList.add('hidden');
|
||||
render();
|
||||
}
|
||||
|
||||
function applyVoterName(name) {
|
||||
document.getElementById('voterDisplayName').textContent = name;
|
||||
document.getElementById('voterBadge').style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
function changeName() {
|
||||
document.getElementById('voterNameInput').value = state.voterName;
|
||||
document.getElementById('nameModal').classList.remove('hidden');
|
||||
document.getElementById('voterNameInput').focus();
|
||||
}
|
||||
|
||||
// ── Tabs ───────────────────────────────────────────────────
|
||||
function renderTabs() {
|
||||
const bar = document.getElementById('tabsBar');
|
||||
bar.innerHTML = state.categories.map(cat => `
|
||||
<div class="tab${cat.id === activeTab ? ' active' : ''}" onclick="setTab('${cat.id}')">
|
||||
<span class="tab-emoji">${cat.emoji}</span>
|
||||
${cat.name}
|
||||
<span class="tab-count" id="tab-count-${cat.id}">${state.options.filter(o => o.categoryId === cat.id).length}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function setTab(id) {
|
||||
activeTab = id;
|
||||
renderTabs();
|
||||
render();
|
||||
}
|
||||
|
||||
// ── Render options ────────────────────────────────────────
|
||||
function render() {
|
||||
const list = document.getElementById('optionsList');
|
||||
const opts = state.options.filter(o => o.categoryId === activeTab);
|
||||
|
||||
document.getElementById('totalVotersCount').textContent =
|
||||
state.totalVoters ? `👥 ${state.totalVoters} voter${state.totalVoters !== 1 ? 's' : ''}` : '';
|
||||
|
||||
if (opts.length === 0) {
|
||||
list.innerHTML = `<div class="empty-state"><div class="empty-emoji">🗳️</div>No options yet. Be the first to add one below!</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by votes desc
|
||||
const sorted = [...opts].sort((a, b) => b.votes.length - a.votes.length);
|
||||
const maxVotes = sorted[0] ? sorted[0].votes.length : 1;
|
||||
|
||||
list.innerHTML = sorted.map(opt => {
|
||||
const catClass = opt.categoryId;
|
||||
const votePct = maxVotes > 0 ? (opt.votes.length / maxVotes * 100) : 0;
|
||||
const hasVoted = state.voterName && opt.votes.some(v => v.name === state.voterName);
|
||||
const voteList = opt.votes.map(v => v.name).join(', ');
|
||||
|
||||
return `
|
||||
<div class="option-card${hasVoted ? ' voted' : ''}" onclick="toggleVote('${opt.id}')">
|
||||
<div class="option-top">
|
||||
<div class="option-name">${opt.name}</div>
|
||||
<div class="option-votes">${opt.votes.length} vote${opt.votes.length !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
${opt.desc ? `<div class="option-desc">${opt.desc}</div>` : ''}
|
||||
${opt.url ? `<a href="${opt.url}" target="_blank" rel="noopener noreferrer" class="option-link" onclick="event.stopPropagation()">🔗 ${opt.url.replace(/^https?:\/\//, '').split('/')[0]}</a>` : ''}
|
||||
${opt.details && opt.details.length ? `
|
||||
<div class="itin-details">${opt.details.map(d => `<span class="itin-tag">${d}</span>`).join('')}</div>
|
||||
` : ''}
|
||||
<div class="vote-bar-bg">
|
||||
<div class="vote-bar-fill ${catClass}" style="width:${votePct}%"></div>
|
||||
</div>
|
||||
${voteList ? `<div class="voters-row">👥 ${voteList}</div>` : '<div class="voters-row">No votes yet — be first!</div>'}
|
||||
${opt.addedBy && opt.addedBy !== 'system' ? `<div class="pending-note">Added by ${opt.addedBy}</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Voting ────────────────────────────────────────────────
|
||||
function toggleVote(optionId) {
|
||||
if (!state.voterName) {
|
||||
document.getElementById('nameModal').classList.remove('hidden');
|
||||
document.getElementById('voterNameInput').focus();
|
||||
return;
|
||||
}
|
||||
if (!state.pollsOpen) {
|
||||
showToast('Polls are currently closed', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const opt = state.options.find(o => o.id === optionId);
|
||||
if (!opt) return;
|
||||
|
||||
const alreadyVoted = opt.votes.some(v => v.name === state.voterName);
|
||||
|
||||
wsSend({ type: 'vote', optionId, voterName: state.voterName, remove: alreadyVoted });
|
||||
showToast(alreadyVoted ? `Removed vote for ${opt.name}` : `Voted for ${opt.name}!`);
|
||||
}
|
||||
|
||||
// ── Add option ────────────────────────────────────────────
|
||||
function submitNewOption() {
|
||||
const name = document.getElementById('addName').value.trim();
|
||||
const desc = document.getElementById('addDesc').value.trim();
|
||||
const url = document.getElementById('addUrl').value.trim();
|
||||
const catId = document.getElementById('addCategory').value;
|
||||
|
||||
if (!name) {
|
||||
showToast('Please enter a name for the place', 'error');
|
||||
return;
|
||||
}
|
||||
if (!state.voterName) {
|
||||
document.getElementById('nameModal').classList.remove('hidden');
|
||||
document.getElementById('voterNameInput').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
wsSend({ type: 'add_option', categoryId: catId, name, desc, url, voterName: state.voterName });
|
||||
|
||||
// Clear form
|
||||
document.getElementById('addName').value = '';
|
||||
document.getElementById('addDesc').value = '';
|
||||
document.getElementById('addUrl').value = '';
|
||||
showToast(`Submitted "${name}" for approval!`, 'success');
|
||||
}
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────
|
||||
function showToast(msg, type = '') {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = msg;
|
||||
toast.className = 'toast ' + type;
|
||||
requestAnimationFrame(() => toast.classList.add('show'));
|
||||
setTimeout(() => toast.classList.remove('show'), 3000);
|
||||
}
|
||||
|
||||
// ── Kick off ──────────────────────────────────────────────
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
267
server.js
Normal file
267
server.js
Normal file
@@ -0,0 +1,267 @@
|
||||
const express = require('express');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const cors = require('cors');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const DATA_FILE = path.join(DATA_DIR, 'votes.json');
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// ── Data helpers ──────────────────────────────────────────────
|
||||
|
||||
function loadData() {
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
if (!fs.existsSync(DATA_FILE)) {
|
||||
const seed = buildSeedData();
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify(seed, null, 2));
|
||||
return seed;
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
|
||||
}
|
||||
|
||||
function saveData(data) {
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
function broadcast(payload) {
|
||||
const msg = JSON.stringify(payload);
|
||||
wss.clients.forEach(client => {
|
||||
if (client.readyState === 1) client.send(msg);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Seed data ─────────────────────────────────────────────────
|
||||
|
||||
function buildSeedData() {
|
||||
return {
|
||||
categories: [
|
||||
{ id: 'hotel', name: 'Hotels', emoji: '🏨' },
|
||||
{ id: 'golf', name: 'Golf', emoji: '⛳' },
|
||||
{ id: 'nightlife', name: 'Nightlife', emoji: '🎧' },
|
||||
{ id: 'excursion', name: 'Excursions', emoji: '🚤' },
|
||||
{ id: 'itinerary', name: '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,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
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,
|
||||
options: data.options
|
||||
.filter(o => o.approved && o.categoryId === cat.id)
|
||||
.map(o => ({ ...o, voteCount: o.votes.length }))
|
||||
}));
|
||||
res.json({ pollsOpen: data.pollsOpen, results, totalVoters: data.voters.length });
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Add new vote
|
||||
option.votes.push({ name: voterName, timestamp: Date.now() });
|
||||
|
||||
// Track voter
|
||||
if (!data.voters.find(v => v.name === voterName)) {
|
||||
data.voters.push({ name: voterName, joinedAt: Date.now() });
|
||||
}
|
||||
|
||||
saveData(data);
|
||||
broadcast({ type: 'vote_update', results: data.options.filter(o => o.approved).map(o => ({ id: o.id, votes: o.votes.length, voters: o.votes.map(v => v.name) })) });
|
||||
res.json({ success: true, voteCount: option.votes.length });
|
||||
});
|
||||
|
||||
// Remove vote
|
||||
app.delete('/api/vote/:optionId', (req, res) => {
|
||||
const { voterName } = req.body;
|
||||
const option = data.options.find(o => o.id === req.params.optionId);
|
||||
if (!option) return res.status(404).json({ error: 'Not found' });
|
||||
option.votes = option.votes.filter(v => v.name !== voterName);
|
||||
saveData(data);
|
||||
broadcast({ type: 'vote_update', results: data.options.filter(o => o.approved).map(o => ({ id: o.id, votes: o.votes.length, voters: o.votes.map(v => v.name) })) });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// 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 category = data.categories.find(c => c.id === categoryId);
|
||||
if (!category) return res.status(404).json({ error: 'Category not found' });
|
||||
|
||||
const newOption = {
|
||||
id: uuidv4(),
|
||||
categoryId,
|
||||
name: name.trim(),
|
||||
desc: (desc || '').trim(),
|
||||
url: url ? url.trim() : null,
|
||||
addedBy: voterName,
|
||||
approved: false, // needs approval
|
||||
votes: [],
|
||||
details: [],
|
||||
};
|
||||
|
||||
data.options.push(newOption);
|
||||
saveData(data);
|
||||
broadcast({ type: 'option_added', option: newOption });
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
broadcast({ type: 'polls_status', open: data.pollsOpen });
|
||||
res.json({ success: true, pollsOpen: data.pollsOpen });
|
||||
});
|
||||
|
||||
// ── WebSocket ────────────────────────────────────────────────
|
||||
|
||||
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.on('message', (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw);
|
||||
if (msg.type === 'vote') {
|
||||
const { optionId, voterName, remove } = msg;
|
||||
if (!voterName || !optionId) return;
|
||||
if (!data.pollsOpen) { ws.send(JSON.stringify({ type: 'error', message: 'Polls closed' })); return; }
|
||||
const option = data.options.find(o => o.id === optionId);
|
||||
if (!option || !option.approved) return;
|
||||
if (remove) {
|
||||
option.votes = option.votes.filter(v => v.name !== voterName);
|
||||
} else {
|
||||
const prev = data.options.find(o => o.votes.some(v => v.name === voterName) && o.categoryId === option.categoryId);
|
||||
if (prev) prev.votes = prev.votes.filter(v => v.name !== voterName);
|
||||
option.votes.push({ name: voterName, timestamp: Date.now() });
|
||||
}
|
||||
if (!data.voters.find(v => v.name === voterName)) data.voters.push({ name: voterName, joinedAt: Date.now() });
|
||||
saveData(data);
|
||||
broadcast({ type: 'vote_update', results: data.options.filter(o => o.approved).map(o => ({ id: o.id, votes: o.votes.length, voters: o.votes.map(v => v.name) })) });
|
||||
} else if (msg.type === 'add_option') {
|
||||
const { categoryId, name, desc, url, voterName } = msg;
|
||||
if (!categoryId || !name || !voterName) return;
|
||||
const newOption = {
|
||||
id: uuidv4(),
|
||||
categoryId,
|
||||
name: name.trim(),
|
||||
desc: (desc || '').trim(),
|
||||
url: url ? url.trim() : null,
|
||||
addedBy: voterName,
|
||||
approved: true,
|
||||
votes: [],
|
||||
details: [],
|
||||
};
|
||||
data.options.push(newOption);
|
||||
saveData(data);
|
||||
broadcast({ type: 'option_added', option: newOption });
|
||||
}
|
||||
} catch (e) { /* ignore malformed */ }
|
||||
});
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🏄 Cabo Voting App → http://0.0.0.0:${PORT}`);
|
||||
});
|
||||
Reference in New Issue
Block a user