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:
2026-04-28 20:55:42 -07:00
commit 6ed5f360ac
3 changed files with 982 additions and 0 deletions

712
public/index.html Normal file
View 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>