Files
cabo-voting-app/public/index.html
Christopher Mayor 6ed5f360ac 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
2026-04-28 20:55:42 -07:00

713 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>