- Exponential backoff: 1s → 2s → 4s → … → 30s max - Full-screen overlay when disconnected (spinner + message) - Votes cast offline are queued and replayed on reconnect - Deduplicated queue: same voter+option only queued once - ✓ Reconnected toast on successful reconnect
1092 lines
37 KiB
HTML
1092 lines
37 KiB
HTML
<!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);
|
||
}
|
||
|
||
// 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>
|