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

2073 lines
75 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 & Map</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
/* ── Map tab ─────────────────────────────────────────────── */
#map-view {
position: relative;
height: calc(100vh - 120px);
min-height: 400px;
}
#cabo-map {
position: absolute;
inset: 0;
z-index: 1;
}
.map-overlay {
position: absolute;
top: 10px;
left: 10px;
right: 10px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
}
.map-search-wrap {
pointer-events: all;
background: rgba(19,22,31,0.95);
border: 1px solid #252a38;
border-radius: 8px;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
max-width: 320px;
backdrop-filter: blur(8px);
}
.map-search-wrap input {
flex: 1;
background: transparent;
border: none;
color: #e0e6f0;
font-size: 0.82rem;
outline: none;
}
.map-search-wrap input::placeholder { color: #7a8499; }
.map-cat-filters {
display: flex;
gap: 4px;
flex-wrap: wrap;
pointer-events: all;
}
.map-cat-btn {
padding: 4px 8px;
border: 1px solid #252a38;
background: rgba(19,22,31,0.9);
color: #7a8499;
border-radius: 4px;
cursor: pointer;
font-size: 0.68rem;
font-weight: 600;
backdrop-filter: blur(8px);
transition: all 0.15s;
white-space: nowrap;
user-select: none;
}
.map-cat-btn.active {
border-color: var(--accent);
color: var(--accent);
background: rgba(0,212,255,0.12);
}
.map-cat-btn.hidden-cat {
opacity: 0.35;
border-color: #1a1e2a;
}
.map-cat-btn:hover { border-color: #7a8499; color: #e0e6f0; }
.map-cat-btn.hidden-cat:hover { opacity: 0.7; }
/* External / Yelp marker icon */
.map-ext-icon {
width: 30px;
height: 30px;
border-radius: 50%;
border: 2.5px dashed #ff6b35;
background: rgba(255,107,53,0.15);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
.map-ext-icon-new {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px dashed #fbbf24;
background: rgba(251,191,36,0.15);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
/* Yelp search button */
/* Multi-provider search bar */
#map-search-wrap {
display: flex;
align-items: center;
gap: 0;
pointer-events: all;
background: rgba(19,22,31,0.95);
border: 1px solid #252a38;
border-radius: 8px;
padding: 0;
max-width: 480px;
backdrop-filter: blur(8px);
overflow: hidden;
flex-shrink: 0;
}
#map-search-wrap:focus-within { border-color: var(--accent); }
#map-search-input {
flex: 1;
background: transparent;
border: none;
color: #e0e6f0;
font-size: 0.82rem;
padding: 8px 10px;
outline: none;
min-width: 120px;
}
#map-search-input::placeholder { color: #7a8499; }
.provider-tabs {
display: flex;
align-items: center;
border-left: 1px solid #252a38;
flex-shrink: 0;
}
.provider-tab {
padding: 5px 8px;
border: none;
background: transparent;
color: #555;
font-size: 0.68rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
border-right: 1px solid #252a38;
letter-spacing: 0.2px;
}
.provider-tab:last-child { border-right: none; }
.provider-tab:hover { color: #ccc; background: rgba(255,255,255,0.04); }
.provider-tab.active-yelp { color: #ff6b35; background: rgba(255,107,53,0.12); }
.provider-tab.active-osm { color: #fbbf24; background: rgba(251,191,36,0.10); }
.provider-tab.active-all { color: #00d4ff; background: rgba(0,212,255,0.08); }
#map-search-btn {
border: none;
background: transparent;
color: #00d4ff;
cursor: pointer;
padding: 6px 10px;
font-size: 0.9rem;
border-left: 1px solid #252a38;
flex-shrink: 0;
}
#map-search-btn:hover { background: rgba(0,212,255,0.06); }
/* Quick-book mini bar */
.map-quick-book {
display: flex;
align-items: center;
gap: 3px;
flex-wrap: wrap;
}
.qb-label { color: #444; font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; margin-right: 2px; }
.qb-btn {
display: flex; align-items: center; gap: 2px;
padding: 2px 5px;
border: 1px solid #252a38;
background: rgba(255,255,255,0.03);
color: #555;
border-radius: 3px;
font-size: 0.6rem; font-weight: 600;
cursor: pointer; white-space: nowrap; text-decoration: none;
transition: all 0.15s;
}
.qb-btn:hover { border-color: #444; color: #ccc; background: rgba(255,255,255,0.07); }
/* Legend: multi-provider dots */
.legend-dot-osm { background: #fbbf24; width: 9px; height: 9px; border-radius: 50%; display: inline-block; margin-right: 4px; border: 1px solid rgba(0,0,0,0.3); }
.legend-dot-all { background: conic-gradient(#ff6b35 33%, #fbbf24 33%, #fbbf24 66%, #00d4ff 66%); }
/* Filter bar layout */
.map-overlay {
position: absolute;
top: 10px;
left: 10px;
right: 10px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
}
.map-filter-row {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
pointer-events: all;
}
.map-filter-row .row-label {
color: #7a8499;
font-size: 0.62rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-right: 2px;
}
#map-search-wrap,
.map-filter-row {
background: rgba(19,22,31,0.93);
border: 1px solid #252a38;
border-radius: 8px;
padding: 6px 8px;
backdrop-filter: blur(8px);
}
.map-legend {
position: absolute;
bottom: 24px;
left: 10px;
background: rgba(19,22,31,0.93);
border: 1px solid #252a38;
border-radius: 8px;
padding: 8px 12px;
z-index: 1000;
font-size: 0.68rem;
backdrop-filter: blur(8px);
}
.map-legend h4 { color: #fff; margin-bottom: 5px; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.5px; }
.legend-item { display: flex; align-items: center; gap: 6px; margin: 2px 0; color: #aaa; }
.legend-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
/* Map popup overrides */
.leaflet-popup-content-wrapper { background: #13161f; color: #e0e6f0; border-radius: 10px; border: 1px solid #252a38; padding: 0; }
.leaflet-popup-content { font-size: 0.8rem; margin: 12px 14px !important; }
.leaflet-popup-tip { background: #13161f; }
.leaflet-container { background: #0a0a14; }
.leaflet-popup-close-button { color: #7a8499 !important; font-size: 16px !important; top: 6px !important; right: 8px !important; }
.leaflet-popup-close-button:hover { color: #fff !important; }
.map-popup { min-width: 180px; }
.map-popup h4 { color: #00d4ff; margin-bottom: 4px; font-size: 0.88rem; }
.map-popup p { color: #7a8499; font-size: 0.72rem; margin-bottom: 8px; line-height: 1.4; }
.map-popup .mpvote { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
.map-popup .mpvoters { font-size: 0.65rem; color: #7a8499; }
.map-popup .mpvoters span { color: #34d399; font-weight: 600; }
.map-popup-btn {
display: inline-block;
padding: 4px 12px;
background: #00d4ff;
color: #0b0d14 !important;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 700;
cursor: pointer;
text-decoration: none !important;
border: none;
}
.map-popup-btn.voted { background: #34d399; }
.map-popup-btn:hover { opacity: 0.85; }
.map-popup .mp-link {
display: block;
margin-top: 6px;
font-size: 0.65rem;
color: #00d4ff;
}
/* Search results dropdown in map */
#map-search-results {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: #13161f;
border: 1px solid #252a38;
border-radius: 6px;
max-height: 240px;
overflow-y: auto;
z-index: 2001;
display: none;
}
#map-search-results.show { display: block; }
.msr-item {
padding: 7px 10px;
font-size: 0.75rem;
cursor: pointer;
border-bottom: 1px solid #1a1e2a;
color: #ccc;
display: flex;
align-items: center;
gap: 6px;
}
.msr-item:last-child { border-bottom: none; }
.msr-item:hover { background: rgba(0,212,255,0.08); color: #00d4ff; }
.msr-item .msr-cat {
font-size: 0.6rem;
padding: 1px 5px;
border-radius: 3px;
font-weight: 700;
text-transform: uppercase;
white-space: nowrap;
}
/* ── 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;
--budget: #f97316;
}
/* ── 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; }
.option-links {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.option-pill {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid rgba(0, 212, 255, 0.2);
background: rgba(0, 212, 255, 0.08);
border-radius: 999px;
padding: 4px 8px;
font-size: 0.66rem;
color: var(--accent);
opacity: 0.92;
}
.option-pill:hover {
opacity: 1;
text-decoration: none;
border-color: rgba(0, 212, 255, 0.45);
}
/* 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); }
.vote-bar-fill.budget { background: var(--budget); }
.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);
}
.budget-board {
margin-bottom: 16px;
background:
radial-gradient(circle at top right, rgba(249, 115, 22, 0.16), transparent 38%),
linear-gradient(135deg, rgba(251, 191, 36, 0.08), rgba(19, 22, 31, 0.95) 48%, rgba(6, 182, 212, 0.08));
border: 1px solid rgba(249, 115, 22, 0.28);
border-radius: 18px;
padding: 18px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.24);
}
.budget-board h2 {
font-size: 1.05rem;
color: #ffd7b0;
margin-bottom: 6px;
}
.budget-board p {
font-size: 0.78rem;
color: #e1c9b8;
line-height: 1.5;
}
.budget-stamp {
display: inline-flex;
margin-top: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(249, 115, 22, 0.12);
color: #ffbe88;
font-size: 0.68rem;
letter-spacing: 0.3px;
}
.budget-grid {
margin: 16px 0 18px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 12px;
}
.budget-card {
background: rgba(11, 13, 20, 0.76);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 14px;
backdrop-filter: blur(10px);
}
.budget-card h3 {
font-size: 0.95rem;
margin-bottom: 4px;
color: #fff3e8;
}
.budget-card .budget-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
color: #fbc08c;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.budget-card .budget-price {
font-size: 1.5rem;
font-weight: 800;
color: #fff;
margin-bottom: 4px;
}
.budget-card .budget-total {
font-size: 0.72rem;
color: #ffcf9c;
margin-bottom: 10px;
}
.budget-card .budget-summary {
font-size: 0.76rem;
color: #d9e3f4;
line-height: 1.45;
margin-bottom: 10px;
}
.budget-card ul {
list-style: none;
display: grid;
gap: 5px;
}
.budget-card li {
font-size: 0.7rem;
color: #b7c0d4;
line-height: 1.4;
padding-left: 10px;
position: relative;
}
.budget-card li::before {
content: '';
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
position: absolute;
left: 0;
top: 0.55em;
opacity: 0.8;
}
.budget-tier {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
font-weight: 700;
font-size: 0.66rem;
letter-spacing: 0.3px;
}
.budget-tier.budget { background: rgba(52, 211, 153, 0.12); color: #7df0c0; }
.budget-tier.balanced { background: rgba(6, 182, 212, 0.12); color: #7fe7ff; }
.budget-tier.splurge { background: rgba(249, 115, 22, 0.14); color: #ffbe88; }
/* ── 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>
<!-- Map view -->
<div id="map-view" style="display:none;">
<div id="cabo-map"></div>
<div class="map-overlay">
<!-- Row 1: Multi-provider search -->
<div id="map-search-wrap">
<span style="color:#7a8499;font-size:0.8rem;padding-left:8px;flex-shrink:0;">🔍</span>
<input type="text" id="map-search-input" placeholder="Search Los Cabos…" autocomplete="off" />
<div class="provider-tabs">
<button class="provider-tab active-yelp" id="tab-yelp" onclick="setProvider('yelp')">🍴 Yelp</button>
<button class="provider-tab" id="tab-osm" onclick="setProvider('osm')">📍 OSM</button>
<button class="provider-tab" id="tab-all" onclick="setProvider('all')">⚡ All</button>
</div>
<button id="map-search-btn" onclick="mapDoSearch()"></button>
</div>
<div id="map-search-results"></div>
<!-- Row 2: Category filters -->
<div class="map-filter-row">
<span class="row-label">Show</span>
<button class="map-cat-btn active" id="cat-btn-hotel" onclick="mapToggleCat('hotel')">🏨 Hotels</button>
<button class="map-cat-btn active" id="cat-btn-golf" onclick="mapToggleCat('golf')">⛳ Golf</button>
<button class="map-cat-btn active" id="cat-btn-nightlife" onclick="mapToggleCat('nightlife')">🎧 Nightlife</button>
<button class="map-cat-btn active" id="cat-btn-excursion" onclick="mapToggleCat('excursion')">🚤 Excursions</button>
<button class="map-cat-btn active" id="cat-btn-itinerary" onclick="mapToggleCat('itinerary')">🗺️ Itineraries</button>
<button class="map-cat-btn" id="cat-btn-clear" onclick="mapClearAllCats()" style="margin-left:4px;border-color:#f87171;color:#f87171;">✕ Clear</button>
</div>
</div>
<div class="map-legend">
<h4>Legend</h4>
<div class="legend-item"><div class="legend-dot" style="background:#3b82f6"></div> Hotel</div>
<div class="legend-item"><div class="legend-dot" style="background:#22c55e"></div> Golf</div>
<div class="legend-item"><div class="legend-dot" style="background:#06b6d4"></div> Excursion</div>
<div class="legend-item"><div class="legend-dot" style="background:#a855f7"></div> Nightlife</div>
<div class="legend-item"><div class="legend-dot" style="background:#fbbf24"></div> Itinerary</div>
<div class="legend-item"><div class="legend-dot" style="background:#ff6b35;opacity:0.8"></div> Yelp</div>
<div class="legend-item"><div class="legend-dot legend-dot-osm"></div> OSM</div>
<div class="legend-item"><div class="legend-dot" style="background:conic-gradient(#ff6b35 33%, #fbbf24 33%, #fbbf24 66%, #00d4ff 66%);width:9px;height:9px;border-radius:50%;display:inline-block;border:1px solid rgba(0,0,0,0.3)"></div> All sources</div>
</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>
<option value="budget">💸 Budget Idea</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: [],
budgetScenarios: [],
priceUpdatedAt: '',
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.budgetScenarios = msg.budgetScenarios || [];
state.priceUpdatedAt = msg.priceUpdatedAt || '';
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.voters || []).map(name => ({ name })); }
});
render();
if (mapInitialized) mapRefreshMarkers();
} 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();
if (mapInitialized) mapRefreshMarkers();
}
} else if (msg.type === 'option_deleted') {
state.options = state.options.filter(o => o.id !== msg.id);
renderTabs();
render();
if (mapInitialized) mapRefreshMarkers();
} 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');
}
function getVoteEntries(opt) {
if (Array.isArray(opt.votes)) return opt.votes;
if (Array.isArray(opt.voters)) return opt.voters.map(name => ({ name }));
return [];
}
// ── 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');
const catsWithMap = [...state.categories, { id: 'map', name: 'Map', emoji: '🗺️' }];
bar.innerHTML = catsWithMap.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' ? '' : cat.id === 'map' ? '' : 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), 'map'];
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();
// Show/hide map vs options list
const mapView = document.getElementById('map-view');
const optionsList = document.getElementById('optionsList');
const addSection = document.querySelector('.add-section');
if (id === 'map') {
mapView.style.display = 'block';
optionsList.style.display = 'none';
if (addSection) addSection.style.display = 'none';
if (!mapInitialized) initMap();
} else {
mapView.style.display = 'none';
optionsList.style.display = 'block';
if (addSection) addSection.style.display = '';
}
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)'
: cat.id === 'budget'
? 'var(--budget)'
: '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) => getVoteEntries(b).length - getVoteEntries(a).length);
const maxVotes = sorted[0] ? getVoteEntries(sorted[0]).length : 1;
const budgetBoard = activeTab === 'budget' ? renderBudgetBoard() : '';
list.innerHTML = budgetBoard + sorted.map(opt => {
const catClass = opt.categoryId;
const voteEntries = getVoteEntries(opt);
const votePct = maxVotes > 0 ? (voteEntries.length / maxVotes * 100) : 0;
const hasVoted = state.voterName && voteEntries.some(v => v.name === state.voterName);
const voteList = voteEntries.map(v => v.name).join(', ');
const linkPills = opt.links && opt.links.length
? `<div class="option-links">${opt.links.map(link => `
<a href="${link.url}" target="_blank" rel="noopener noreferrer" class="option-pill" onclick="event.stopPropagation()">${link.label}</a>
`).join('')}</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>` : '');
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">${voteEntries.length} vote${voteEntries.length !== 1 ? 's' : ''}</div>
</div>
${opt.desc ? `<div class="option-desc">${opt.desc}</div>` : ''}
${linkPills}
${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('');
}
function renderBudgetBoard() {
if (!state.budgetScenarios.length) return '';
const tierOrder = { Budget: 0, Balanced: 1, Splurge: 2 };
const scenarios = [...state.budgetScenarios].sort((a, b) => {
if (a.groupSize !== b.groupSize) return a.groupSize - b.groupSize;
return (tierOrder[a.tier] ?? 99) - (tierOrder[b.tier] ?? 99);
});
return `
<section class="budget-board">
<h2>💸 Budget Cheat Sheet</h2>
<p>These are planning numbers for the group to compare tracks quickly before anyone starts buying flights. They use current live price signals and bake in the shared-cost difference between 8, 10, and 12 guys.</p>
<div class="budget-stamp">Pricing research last refreshed ${state.priceUpdatedAt || 'recently'}</div>
<div class="budget-grid">
${scenarios.map(scenario => `
<article class="budget-card">
<div class="budget-meta">
<span>${scenario.groupSize} guys</span>
<span class="budget-tier ${scenario.tier.toLowerCase()}">${scenario.tier}</span>
</div>
<h3>${scenario.tier} Track</h3>
<div class="budget-price">$${scenario.perPerson.toLocaleString()}</div>
<div class="budget-total">$${scenario.groupTotal.toLocaleString()} group total</div>
<div class="budget-summary">${scenario.summary}</div>
<ul>${scenario.notes.map(note => `<li>${note}</li>`).join('')}</ul>
</article>
`).join('')}
</div>
</section>
`;
}
// ── 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 = getVoteEntries(opt).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);
}
// ── Map ───────────────────────────────────────────────────
let mapInstance = null;
let mapInitialized = false;
let mapMarkers = {};
let mapHiddenCats = new Set(); // categories currently hidden
let mapExtMarkers = []; // temporary external / Yelp markers
const CABOS_BBOX = '-109.85,22.85,-109.55,23.25';
const CABOS_CENTER = { lat: 23.065, lng: -109.698 };
function initMap() {
mapInitialized = true;
mapInstance = L.map('cabo-map', { zoomControl: true }).setView([CABOS_CENTER.lat, CABOS_CENTER.lng], 12);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OpenStreetMap, &copy; CARTO',
maxZoom: 18
}).addTo(mapInstance);
mapInstance.zoomControl.setPosition('bottomright');
// Map search input
const searchInput = document.getElementById('map-search-input');
const searchResults = document.getElementById('map-search-results');
let searchTimeout;
searchInput.addEventListener('input', () => {
const q = searchInput.value.trim();
if (q.length < 2) { searchResults.classList.remove('show'); return; }
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => mapDoSearch(), 400);
});
searchInput.addEventListener('keydown', e => {
if (e.key === 'Enter') {
const q = searchInput.value.trim();
if (q) { mapDoSearch(); searchResults.classList.remove('show'); }
}
});
document.addEventListener('click', e => {
if (!e.target.closest('#map-search-wrap')) searchResults.classList.remove('show');
});
mapRefreshMarkers();
}
function mapMakeIcon(color, emoji, size = 34) {
return L.divIcon({
html: `<div style="background:${color};width:${size}px;height:${size}px;border-radius:50%;border:2.5px solid white;display:flex;align-items:center;justify-content:center;font-size:${Math.floor(size*0.4)}px;box-shadow:0 2px 10px rgba(0,0,0,0.5);">${emoji}</div>`,
className: '',
iconSize: [size, size],
iconAnchor: [size/2, size/2]
});
}
// Dashed-border icon for external / Yelp results
function mapMakeExtIcon(borderColor, emoji, size = 30) {
return L.divIcon({
html: `<div style="width:${size}px;height:${size}px;border-radius:50%;border:2.5px dashed ${borderColor};background:rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;font-size:${Math.floor(size*0.35)}px;box-shadow:0 2px 8px rgba(0,0,0,0.5);color:white;">${emoji}</div>`,
className: '',
iconSize: [size, size],
iconAnchor: [size/2, size/2]
});
}
function mapMakePopup(opt) {
const catColors = { hotel: '#3b82f6', golf: '#22c55e', nightlife: '#a855f7', excursion: '#06b6d4', itinerary: '#fbbf24' };
const catEmoji = { hotel: '🏨', golf: '⛳', nightlife: '🎧', excursion: '🚤', itinerary: '🗺️' };
const color = opt.categoryColor || catColors[opt.categoryId] || '#888';
const emoji = catEmoji[opt.categoryId] || '📍';
const hasVoted = state.voterName && opt.votes.some(v => v.name === state.voterName);
const voterList = opt.votes.length > 0 ? opt.votes.map(v => v.name).join(', ') : 'No votes yet';
return `<div class="map-popup">
<h4>${emoji} ${opt.name}</h4>
<p>${opt.desc || ''}</p>
<div class="mpvote">
<button class="map-popup-btn ${hasVoted ? 'voted' : ''}" onclick="toggleVoteFromMap('${opt.id}')">
${hasVoted ? '✓ Voted' : '👍 Vote'}
</button>
<span style="color:#7a8499;font-size:0.72rem;">${opt.votes.length} vote${opt.votes.length !== 1 ? 's' : ''}</span>
</div>
<div class="mpvoters">${opt.votes.length > 0 ? '👥 ' + voterList : 'Be the first to vote!'}</div>
${opt.url ? `<a href="${opt.url}" target="_blank" rel="noopener noreferrer" class="mp-link">🔗 Book / Visit →</a>` : ''}
</div>`;
}
function mapMakeExtPopup(item) {
const rating = item.rating ? `${item.rating}` : '';
const price = item.price ? `<span style="color:#fbbf24;margin-left:6px;">${item.price}</span>` : '';
const image = item.image_url ? `<img src="${item.image_url}" alt="" style="width:100%;height:80px;object-fit:cover;border-radius:6px;margin-bottom:8px;" />` : '';
const yelpUrl = item.url ? `<a href="${item.url}" target="_blank" rel="noopener noreferrer" class="mp-link" style="color:#ff6b35;">🍴 View on Yelp →</a>` : '';
const gmapsUrl = item.coordinates ? `<a href="https://www.google.com/maps?q=${item.coordinates.latitude},${item.coordinates.longitude}" target="_blank" rel="noopener noreferrer" class="mp-link">📍 Directions →</a>` : '';
return `<div class="map-popup">
${image}
<h4 style="color:#ff6b35;">🍴 ${item.name}</h4>
<p>${item.location ? item.location.address1 + ', ' + item.location.city : ''}</p>
<div style="margin-bottom:6px;font-size:0.72rem;color:#aaa;">${rating}${price}</div>
${yelpUrl}${gmapsUrl ? '<br>' + gmapsUrl : ''}
<div style="margin-top:6px;font-size:0.6rem;color:#555;text-align:right;">Powered by Yelp</div>
</div>`;
}
function toggleVoteFromMap(optionId) {
if (activeTab !== 'map') setTab('map');
toggleVote(optionId);
}
// ── Render venue markers (curated app data) ───────────────
function mapRefreshMarkers() {
if (!mapInstance) return;
// Remove existing venue markers
Object.values(mapMarkers).forEach(m => mapInstance.removeLayer(m));
mapMarkers = {};
// Show all approved venues whose category is NOT hidden
const visible = state.options.filter(o =>
o.approved && o.lat && o.lng && !mapHiddenCats.has(o.categoryId)
);
const catEmoji = { hotel: '🏨', golf: '⛳', nightlife: '🎧', excursion: '🚤', itinerary: '🗺️' };
visible.forEach(opt => {
const color = opt.categoryColor || '#888';
const emoji = catEmoji[opt.categoryId] || '📍';
const icon = mapMakeIcon(color, emoji, 34);
const marker = L.marker([opt.lat, opt.lng], { icon })
.addTo(mapInstance)
.bindPopup(mapMakePopup(opt), { className: 'map-popup-wrapper' });
mapMarkers[opt.id] = marker;
});
// Fit bounds to venue markers only (not external ones)
const venueMarkers = Object.values(mapMarkers);
if (venueMarkers.length > 0) {
const group = L.featureGroup(venueMarkers);
mapInstance.fitBounds(group.getBounds(), { padding: [40, 40], maxZoom: 14 });
}
}
// ── Category filter toggles ───────────────────────────────
const ALL_CATS = ['hotel', 'golf', 'nightlife', 'excursion', 'itinerary'];
function mapToggleCat(catId) {
if (mapHiddenCats.has(catId)) {
mapHiddenCats.delete(catId);
} else {
mapHiddenCats.add(catId);
}
mapSyncCatButtons();
mapRefreshMarkers();
}
function mapClearAllCats() {
ALL_CATS.forEach(c => mapHiddenCats.add(c));
mapSyncCatButtons();
mapRefreshMarkers();
}
function mapRestoreAllCats() {
mapHiddenCats.clear();
mapSyncCatButtons();
mapRefreshMarkers();
}
function mapSyncCatButtons() {
ALL_CATS.forEach(catId => {
const btn = document.getElementById('cat-btn-' + catId);
if (btn) {
if (mapHiddenCats.has(catId)) {
btn.classList.add('hidden-cat');
btn.classList.remove('active');
} else {
btn.classList.remove('hidden-cat');
btn.classList.add('active');
}
}
});
}
// ── Multi-provider search ───────────────────────────────
let currentProvider = 'yelp'; // 'yelp' | 'osm' | 'all'
function setProvider(p) {
currentProvider = p;
document.getElementById('tab-yelp').className = 'provider-tab' + (p === 'yelp' ? ' active-yelp' : '');
document.getElementById('tab-osm').className = 'provider-tab' + (p === 'osm' ? ' active-osm' : '');
document.getElementById('tab-all').className = 'provider-tab' + (p === 'all' ? ' active-all' : '');
}
function quickBook(type) {
const q = (document.getElementById('map-search-input').value || '').trim() || 'Los Cabos Mexico';
let url;
switch(type) {
case 'gmaps': url = `https://www.google.com/maps/search/${encodeURIComponent(q)}`; break;
case 'flights': url = `https://www.google.com/travel/flights/search?q=${encodeURIComponent(q)}&tfpla=on`; break;
case 'hotels': url = `https://www.kayak.com/hotels/${encodeURIComponent(q)}/2admins`; break;
case 'viator': url = `https://www.viator.com/search/${encodeURIComponent(q)}`; break;
case 'expedia': url = `https://www.expedia.com/Thotel-Search?destination=${encodeURIComponent(q)}`; break;
case 'tripadvisor': url = `https://www.tripadvisor.com/Search?q=${encodeURIComponent(q)}`; break;
default: return;
}
window.open(url, '_blank', 'noopener,noreferrer');
}
function mapClearExtMarkers() {
mapExtMarkers.forEach(m => mapInstance.removeLayer(m));
mapExtMarkers = [];
}
async function mapYelpSearch(q) {
const input = document.getElementById('map-search-input');
const results = document.getElementById('map-search-results');
q = (q || input.value || '').trim();
if (!q) return;
results.innerHTML = '<div class="msr-item" style="color:#ff6b35;">Searching Yelp…</div>';
results.classList.add('show');
try {
const res = await fetch(`/api/yelp?term=${encodeURIComponent(q)}&location=Los+Cabos+Mexico`);
const data = await res.json();
if (data.error) throw new Error(data.error);
mapClearExtMarkers();
if (!data.businesses || data.businesses.length === 0) {
results.innerHTML = '<div class="msr-item" style="color:#7a8499;">No Yelp results. Try OSM or All.</div>';
} else {
results.innerHTML = data.businesses.slice(0, 8).map((b, i) => {
const cat = b.categories?.[0]?.title || 'Place';
const rating = b.rating ? `${b.rating}` : '';
const price = b.price || '';
return `<div class="msr-item" onclick="mapShowExtResult(${i})">
<span style="font-size:0.7rem;">🍴</span>
<span><strong>${b.name}</strong> <span style="color:#7a8499;font-size:0.65rem;">· ${cat} · ${rating} ${price}</span></span>
</div>`;
}).join('');
window._mapYelpResults = data.businesses;
data.businesses.forEach((b, i) => {
if (!b.coordinates?.latitude || !b.coordinates?.longitude) return;
const city = b.location?.city?.toLowerCase() || '';
const state = b.location?.state || '';
if (!city.includes('cabo') && !state.includes('BS') && !state.includes('Baja')) return;
const icon = mapMakeExtIcon('#ff6b35', '🍴', 30);
const marker = L.marker([b.coordinates.latitude, b.coordinates.longitude], { icon })
.addTo(mapInstance)
.bindPopup(mapMakeExtPopup(b), { className: 'map-popup-wrapper' });
mapExtMarkers.push(marker);
});
if (mapExtMarkers.length > 0) {
const group = L.featureGroup(mapExtMarkers);
mapInstance.fitBounds(group.getBounds(), { padding: [50, 50], maxZoom: 13 });
}
}
} catch(err) {
console.error('Yelp search error:', err);
results.innerHTML = '<div class="msr-item" style="color:#f87171;">Yelp failed. Try OSM or All.</div>';
}
}
// ── OSM search ──────────────────────────────────────────
async function mapOSMSearch(q) {
const input = document.getElementById('map-search-input');
const results = document.getElementById('map-search-results');
q = (q || input.value || '').trim();
if (!q) return;
results.innerHTML = '<div class="msr-item" style="color:#fbbf24;">Searching OSM…</div>';
results.classList.add('show');
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(q + ', Los Cabos, Mexico')}&limit=8&viewbox=${CABOS_BBOX}&bounded=1`,
{ headers: { 'Accept': 'application/json' } }
).then(r => r.json()).catch(() => []);
mapClearExtMarkers();
if (!res || res.length === 0) {
results.innerHTML = '<div class="msr-item" style="color:#7a8499;">No OSM results. Try Yelp or All.</div>';
} else {
results.innerHTML = res.map((r, i) => {
const type = (r.type || 'place');
const addr = r.display_name.split(',').slice(1, 4).join(',').trim();
return `<div class="msr-item" onclick="mapShowOSMResult(${i},${r.lat},${r.lon},'${r.display_name.replace(/'/g,"\\'")}')">
<span style="font-size:0.7rem;">📍</span>
<span><strong>${r.display_name.split(',')[0]}</strong> <span style="color:#7a8499;font-size:0.65rem;">· ${type}</span></span>
</div>`;
}).join('');
window._mapOSMResults = res;
res.forEach(r => {
const icon = mapMakeExtIcon('#fbbf24', '📍', 28);
const marker = L.marker([parseFloat(r.lat), parseFloat(r.lon)], { icon })
.addTo(mapInstance)
.bindPopup(`<div class="map-popup"><h4>📍 ${r.display_name.split(',')[0]}</h4><p style="color:#7a8499;font-size:0.7rem;">${r.display_name.split(',').slice(1,3).join(',').trim()}</p><a href="https://www.google.com/maps?q=${r.lat},${r.lon}" target="_blank" rel="noopener noreferrer" class="mp-link">📍 Maps →</a></div>`);
mapExtMarkers.push(marker);
});
if (mapExtMarkers.length > 0) {
const group = L.featureGroup(mapExtMarkers);
mapInstance.fitBounds(group.getBounds(), { padding: [50, 50], maxZoom: 13 });
}
}
} catch(err) {
results.innerHTML = '<div class="msr-item" style="color:#f87171;">OSM failed. Try Yelp or All.</div>';
}
}
// ── All providers at once ───────────────────────────────
async function mapSearchAll(q) {
const input = document.getElementById('map-search-input');
const results = document.getElementById('map-search-results');
q = (q || input.value || '').trim();
if (!q) return;
results.innerHTML = '<div class="msr-item" style="color:#00d4ff;">Searching all sources…</div>';
results.classList.add('show');
try {
const [osmRes, yelpData] = await Promise.all([
fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(q + ', Los Cabos, Mexico')}&limit=5&viewbox=${CABOS_BBOX}&bounded=1`, { headers: { 'Accept': 'application/json' } }).then(r => r.json()).catch(() => []),
fetch(`/api/yelp?term=${encodeURIComponent(q)}&location=Los+Cabos+Mexico`).then(r => r.json()).catch(() => ({}))
]);
mapClearExtMarkers();
let html = '';
// Yelp results
const businesses = yelpData.businesses || [];
if (businesses.length > 0) {
window._mapYelpResults = businesses;
html += '<div class="msr-item" style="color:#7a8499;font-size:0.65rem;padding:4px 10px;">🍴 Yelp</div>';
html += businesses.slice(0, 5).map((b, i) => {
const rating = b.rating ? `${b.rating}` : '';
return `<div class="msr-item" onclick="mapShowExtResult(${i})">🍴 ${b.name} <span style="color:#7a8499;font-size:0.65rem;">${rating}</span></div>`;
}).join('');
businesses.forEach(b => {
if (!b.coordinates?.latitude || !b.coordinates?.longitude) return;
const icon = mapMakeExtIcon('#ff6b35', '🍴', 30);
const marker = L.marker([b.coordinates.latitude, b.coordinates.longitude], { icon })
.addTo(mapInstance)
.bindPopup(mapMakeExtPopup(b), { className: 'map-popup-wrapper' });
mapExtMarkers.push(marker);
});
}
// OSM results
if (osmRes && osmRes.length > 0) {
window._mapOSMResults = osmRes;
html += '<div class="msr-item" style="color:#7a8499;font-size:0.65rem;padding:4px 10px;">📍 OSM</div>';
html += osmRes.slice(0, 5).map((r, i) => {
return `<div class="msr-item" onclick="mapShowOSMResult(${i},${r.lat},${r.lon},'${r.display_name.replace(/'/g,"\\'")}')">📍 ${r.display_name.split(',')[0]}</div>`;
}).join('');
osmRes.forEach(r => {
const icon = mapMakeExtIcon('#fbbf24', '📍', 28);
const marker = L.marker([parseFloat(r.lat), parseFloat(r.lon)], { icon })
.addTo(mapInstance)
.bindPopup(`<div class="map-popup"><h4>📍 ${r.display_name.split(',')[0]}</h4><a href="https://www.google.com/maps?q=${r.lat},${r.lon}" target="_blank" rel="noopener noreferrer" class="mp-link">📍 Maps →</a></div>`);
mapExtMarkers.push(marker);
});
}
if (!html) html = '<div class="msr-item" style="color:#7a8499;">No results found anywhere.</div>';
results.innerHTML = html;
if (mapExtMarkers.length > 0) {
const group = L.featureGroup(mapExtMarkers);
mapInstance.fitBounds(group.getBounds(), { padding: [50, 50], maxZoom: 13 });
}
} catch(err) {
results.innerHTML = '<div class="msr-item" style="color:#f87171;">Search failed. Try again.</div>';
}
}
function mapShowExtResult(index) {
const businesses = window._mapYelpResults || [];
const b = businesses[index];
if (!b?.coordinates) return;
document.getElementById('map-search-results').classList.remove('show');
document.getElementById('map-search-input').value = b.name;
mapInstance.flyTo([b.coordinates.latitude, b.coordinates.longitude], 15, { duration: 1.0 });
if (mapExtMarkers[index]) mapExtMarkers[index].openPopup();
}
function mapShowOSMResult(index, lat, lon, name) {
document.getElementById('map-search-results').classList.remove('show');
document.getElementById('map-search-input').value = name.split(',')[0];
mapInstance.flyTo([parseFloat(lat), parseFloat(lon)], 15, { duration: 1.0 });
if (mapExtMarkers[index]) mapExtMarkers[index].openPopup();
}
// ── Unified search dispatcher ──────────────────────────
function mapDoSearch() {
const q = document.getElementById('map-search-input').value.trim();
if (!q) return;
if (currentProvider === 'yelp') mapYelpSearch(q);
else if (currentProvider === 'osm') mapOSMSearch(q);
else mapSearchAll(q);
}
// Update Enter key to use the dispatcher
document.getElementById('map-search-input').onkeydown = function(e) {
if (e.key === 'Enter') { mapDoSearch(); document.getElementById('map-search-results').classList.remove('show'); }
};
// ── WebSocket vote update — refresh map markers ──────────────
// ── Kick off ──────────────────────────────────────────────
init();
</script>
</body>
</html>