Files
cabo-voting-app/public/index.html

1094 lines
37 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; }
/* Skip to content */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--accent);
color: var(--bg);
padding: 8px 16px;
z-index: 9999;
font-weight: 700;
border-radius: 0 0 8px 0;
}
.skip-link:focus { top: 0; }
/* Visible focus ring for accessibility */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* ── 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);
position: relative;
}
.modal .drag-handle {
display: none;
}
.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;
}
/* ── Results Leaderboard ───────────────────────────────── */
.results-header {
text-align: center;
margin-bottom: 16px;
}
.results-header h2 { font-size: 1.1rem; color: var(--accent); margin-bottom: 4px; }
.results-header p { font-size: 0.75rem; color: var(--text-muted); }
.results-category {
margin-bottom: 20px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 14px;
}
.results-category-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
font-size: 0.8rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.results-row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
border-bottom: 1px solid var(--border);
}
.results-row:last-child { border-bottom: none; }
.results-rank {
font-size: 0.85rem;
font-weight: 700;
width: 28px;
text-align: center;
flex-shrink: 0;
}
.results-rank.gold { color: #ffd700; }
.results-rank.silver { color: #c0c0c0; }
.results-rank.bronze { color: #cd7f32; }
.results-name {
flex: 1;
font-size: 0.82rem;
color: var(--text);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.results-name.winner {
color: var(--accent);
font-weight: 700;
}
.results-votes {
font-size: 0.75rem;
color: var(--text-muted);
flex-shrink: 0;
}
.results-bar-bg {
flex: 2;
height: 4px;
background: var(--surface2);
border-radius: 2px;
overflow: hidden;
}
.results-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.6s cubic-bezier(0.4,0,0.2,1);
}
.results-share {
text-align: center;
margin-top: 16px;
font-size: 0.72rem;
color: var(--text-muted);
}
/* ── 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; }
/* ── Bottom tab bar (mobile ≤640px) ──────────────────────── */
@media (max-width: 640px) {
body { padding-bottom: 68px; }
/* Compact sticky header */
header {
padding: 8px 14px;
gap: 4px;
}
header h1 { font-size: 0.95rem; }
header .meta { font-size: 0.72rem; }
/* Bottom tab bar */
.tabs {
position: fixed;
bottom: 0;
left: 0;
right: 0;
top: auto;
border-bottom: none;
border-top: 1px solid var(--border);
background: var(--surface);
z-index: 100;
display: flex;
padding-bottom: env(safe-area-inset-bottom);
/* hide scroll — show all 5 tabs in a row */
overflow-x: auto;
scrollbar-width: none;
box-shadow: 0 -4px 20px rgba(0,0,0,0.4);
}
.tabs::-webkit-scrollbar { display: none; }
.tab {
flex: 1;
min-width: 0;
padding: 8px 4px 10px;
border-bottom: none;
border-top: 3px solid transparent;
border-radius: 0;
font-size: 0.6rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.tab.active {
border-top-color: var(--accent);
background: rgba(0,212,255,0.08);
color: var(--accent);
}
.tab .tab-emoji { font-size: 1.2rem; }
.tab .tab-count {
position: absolute;
top: 4px;
right: 6px;
font-size: 0.55rem;
padding: 0 4px;
}
/* Main adjusts for bottom tabs */
main { padding: 12px; max-width: 100%; }
/* Option cards — larger tap targets */
.option-card { min-height: 72px; padding: 14px 16px; }
.option-name { font-size: 0.92rem; }
.option-desc { font-size: 0.76rem; }
/* Add arrow hint to cards */
.option-card::after {
content: '';
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
font-size: 1.2rem;
opacity: 0.4;
}
.option-card.voted::after { display: none; }
/* Touch feedback */
.option-card:active { transform: scale(0.98); opacity: 0.9; }
}
/* ── Desktop (≥641px) ──────────────────────────────────── */
@media (min-width: 641px) {
body { padding-bottom: 0; }
}
/* ── Mobile bottom sheet modal ───────────────────────────── */
@media (max-width: 640px) {
.modal-overlay { align-items: flex-end; justify-content: stretch; padding: 0; }
.modal {
width: 100%;
border-radius: 20px 20px 0 0;
padding: 12px 24px 32px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 -8px 40px rgba(0,0,0,0.6);
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.modal .drag-handle {
display: block;
width: 40px;
height: 4px;
background: var(--border);
border-radius: 2px;
margin: 0 auto 14px;
}
.modal h2 { font-size: 1.15rem; }
.modal p { font-size: 0.8rem; margin-bottom: 14px; }
.modal input, .modal button { margin-bottom: 10px; }
}
/* Touch highlight */
.option-card { -webkit-tap-highlight-color: rgba(0,212,255,0.12); }
/* ── Connection overlay ─────────────────────────────────── */
#wsOverlay {
display: none;
position: fixed;
inset: 0;
background: rgba(11,13,20,0.92);
z-index: 900;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 14px;
}
#wsOverlay.show { display: flex; }
#wsOverlay .spinner {
width: 40px; height: 40px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
#wsOverlay p { color: var(--text-muted); font-size: 0.85rem; text-align: center; }
#wsOverlay .sub { font-size: 0.72rem; color: var(--text-muted); opacity: 0.6; margin-top: -8px; }
</style>
</head>
<body>
<a class="skip-link" href="#optionsList">Skip to voting options</a>
<!-- Name Modal -->
<div class="modal-overlay" id="nameModal">
<div class="modal">
<div class="drag-handle"></div>
<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" role="tabpanel" aria-label="Voting options" aria-live="polite">
<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>
<!-- Connection lost overlay -->
<div id="wsOverlay">
<div class="spinner"></div>
<p>Connection lost</p>
<div class="sub">Attempting to reconnect…</div>
</div>
<!-- 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() {
// Check for ?view=results URL param — skip name modal, go to results
const params = new URLSearchParams(location.search);
const viewResults = params.get('view') === 'results';
if (state.voterName) {
applyVoterName(state.voterName);
// Session persists — skip name modal
document.getElementById('nameModal').classList.add('hidden');
}
// If view=results, skip name modal and go to results tab
if (viewResults) {
activeTab = 'results';
document.getElementById('nameModal').classList.add('hidden');
}
connectWS();
renderTabs();
render();
if (!viewResults) {
document.getElementById('voterNameInput').addEventListener('keydown', e => {
if (e.key === 'Enter') submitName();
});
document.getElementById('voterNameInput').focus();
}
// Number keys 1-6 to switch tabs
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const num = parseInt(e.key);
if (num >= 1 && num <= state.categories.length) {
setTab(state.categories[num - 1].id);
}
});
}
// ── WebSocket ──────────────────────────────────────────────
const RECONNECT_BASE = 1000; // 1s initial
const RECONNECT_MAX = 30000; // 30s cap
let reconnectDelay = RECONNECT_BASE;
let reconnectTimer = null;
let offlineVoteQueue = []; // votes cast while disconnected
function connectWS() {
if (ws) { ws.onclose = ws.onerror = null; ws.close(); }
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');
document.getElementById('wsOverlay').classList.remove('show');
reconnectDelay = RECONNECT_BASE; // reset backoff
// Replay queued votes
if (offlineVoteQueue.length > 0) {
const queue = [...offlineVoteQueue];
offlineVoteQueue = [];
queue.forEach(msg => wsSend(msg));
}
showToast('✓ Reconnected', 'success');
};
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 === 'option_deleted') {
state.options = state.options.filter(o => o.id !== msg.id);
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');
document.getElementById('wsOverlay').classList.add('show');
clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX);
connectWS();
}, reconnectDelay);
};
}
function wsSend(obj) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(obj));
} else {
// Queue vote if offline
if (obj.type === 'vote' && !offlineVoteQueue.find(m =>
m.type === 'vote' && m.optionId === obj.optionId && m.voterName === obj.voterName)) {
offlineVoteQueue.push(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' : ''}"
role="tab"
id="tab-${cat.id}"
aria-selected="${cat.id === activeTab}"
aria-controls="optionsList"
tabindex="${cat.id === activeTab ? 0 : -1}"
onclick="setTab('${cat.id}')"
onkeydown="handleTabKey(event, '${cat.id}')">
<span class="tab-emoji">${cat.emoji}</span>
${cat.name}
<span class="tab-count" id="tab-count-${cat.id}">${cat.id === 'results' ? '' : state.options.filter(o => o.categoryId === cat.id).length}</span>
</div>
`).join('');
bar.setAttribute('role', 'tablist');
bar.setAttribute('aria-label', 'Voting categories');
}
function handleTabKey(event, catId) {
const cats = state.categories.map(c => c.id);
const idx = cats.indexOf(catId);
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
event.preventDefault();
setTab(cats[(idx + 1) % cats.length]);
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
event.preventDefault();
setTab(cats[(idx - 1 + cats.length) % cats.length]);
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setTab(catId);
}
document.getElementById(`tab-${cats[(idx + 1) % cats.length]}`)?.focus();
}
function setTab(id) {
activeTab = id;
renderTabs();
render();
document.getElementById('optionsList')?.setAttribute('aria-label', state.categories.find(c => c.id === id)?.name + ' options');
}
// ── Render options ────────────────────────────────────────
function render() {
const list = document.getElementById('optionsList');
// ── Results tab ──────────────────────────────────────────
if (activeTab === 'results') {
const votingCats = state.categories.filter(c => c.id !== 'results');
const totalVotes = state.options.reduce((sum, o) => sum + o.votes.length, 0);
const statusText = state.pollsOpen
? `🏈 ${state.totalVoters} voter${state.totalVoters !== 1 ? 's' : ''} · ${totalVotes} total votes · Polls OPEN`
: `🏆 ${state.totalVoters} voter${state.totalVoters !== 1 ? 's' : ''} · ${totalVotes} total votes · Polls CLOSED`;
list.innerHTML = `
<div class="results-header">
<h2>🏆 Final Results</h2>
<p>${statusText}</p>
</div>
${votingCats.map(cat => {
const catOpts = state.options.filter(o => o.categoryId === cat.id && o.approved);
const sorted = [...catOpts].sort((a, b) => b.votes.length - a.votes.length);
const maxVotes = sorted[0]?.votes.length || 1;
if (sorted.length === 0) return '';
return `
<div class="results-category">
<div class="results-category-header">${cat.emoji} ${cat.name}</div>
${sorted.map((opt, i) => {
const pct = maxVotes > 0 ? (opt.votes.length / maxVotes * 100) : 0;
const rank = i + 1;
const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : rank;
const medalClass = rank === 1 ? 'gold' : rank === 2 ? 'silver' : rank === 3 ? 'bronze' : '';
const winner = rank === 1 ? 'winner' : '';
const barColor = cat.id === 'hotel' ? 'var(--hotel)' : cat.id === 'golf' ? 'var(--golf)' : cat.id === 'nightlife' ? 'var(--nightlife)' : cat.id === 'excursion' ? 'var(--excursion)' : 'var(--itinerary)';
return `
<div class="results-row">
<div class="results-rank ${medalClass}">${medal}</div>
<div class="results-name ${winner}">${opt.name}</div>
<div class="results-votes">${opt.votes.length}</div>
<div class="results-bar-bg">
<div class="results-bar-fill" style="width:${pct}%; background:${barColor}"></div>
</div>
</div>`;
}).join('')}
</div>`;
}).join('')}
<div class="results-share">Share this URL to show live results without voting</div>
`;
return;
}
// ── Regular voting tabs ─────────────────────────────────
const opts = state.options.filter(o => o.categoryId === activeTab && o.approved);
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 (activeTab === 'results') return; // no voting on results tab
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>