Files
cabo-voting-app/public/index.html
2026-04-30 11:53:18 -07:00

2847 lines
102 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;
--flight: #38bdf8;
--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; }
.modal-actions {
display: flex;
gap: 10px;
margin-top: 8px;
}
.modal-actions button {
width: auto;
flex: 1;
}
.modal-actions .btn-secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
.modal-actions .btn-secondary:hover {
border-color: var(--accent);
color: var(--accent);
opacity: 1;
}
/* ── 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: default;
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-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin: 10px 0 2px;
flex-wrap: wrap;
}
.option-vote-btn {
border: 1px solid rgba(0,212,255,0.32);
background: rgba(0,212,255,0.10);
color: #dff9ff;
border-radius: 999px;
padding: 7px 12px;
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.2px;
cursor: pointer;
transition: transform 0.15s, border-color 0.2s, background 0.2s;
}
.option-vote-btn:hover {
border-color: rgba(0,212,255,0.55);
background: rgba(0,212,255,0.16);
transform: translateY(-1px);
}
.option-vote-btn.voted {
border-color: rgba(52,211,153,0.35);
background: rgba(52,211,153,0.12);
color: #d8fff0;
}
.option-vote-btn.voted:hover {
border-color: rgba(52,211,153,0.6);
background: rgba(52,211,153,0.18);
}
.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-facts {
display: flex;
flex-direction: column;
gap: 8px;
margin: 8px 0 2px;
}
.option-facts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 6px;
}
.option-fact {
padding: 8px 10px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.option-fact-label {
display: block;
font-size: 0.58rem;
text-transform: uppercase;
letter-spacing: 0.9px;
color: var(--text-muted);
margin-bottom: 3px;
}
.option-fact-value {
font-size: 0.73rem;
color: #fff;
line-height: 1.4;
}
.option-source-select {
width: 100%;
margin-top: 4px;
background: rgba(8, 11, 17, 0.9);
border: 1px solid rgba(0, 212, 255, 0.18);
color: #e6f7ff;
border-radius: 8px;
padding: 7px 8px;
font-size: 0.72rem;
outline: none;
appearance: none;
}
.option-source-select:focus {
border-color: rgba(0, 212, 255, 0.48);
box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.08);
}
.option-source-sub {
margin-top: 4px;
font-size: 0.63rem;
color: var(--text-muted);
line-height: 1.35;
}
.option-fact-note {
font-size: 0.68rem;
color: var(--text-muted);
line-height: 1.4;
}
.option-detail-section {
margin-top: 2px;
padding: 8px 10px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.option-detail-title {
font-size: 0.58rem;
text-transform: uppercase;
letter-spacing: 0.9px;
color: var(--text-muted);
margin-bottom: 5px;
}
.option-detail-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.option-detail-chip {
background: var(--surface2);
border-radius: 999px;
padding: 3px 8px;
font-size: 0.65rem;
color: var(--text-muted);
}
.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);
}
.price-trend {
margin: 10px 0 8px;
padding: 10px 12px;
border: 1px solid rgba(0, 212, 255, 0.16);
border-radius: 12px;
background:
linear-gradient(135deg, rgba(0, 212, 255, 0.08), rgba(19, 22, 31, 0.94) 62%, rgba(251, 191, 36, 0.04));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.price-trend-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.price-trend-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.9px;
color: var(--text-muted);
}
.price-trend-value {
margin-top: 2px;
font-size: 0.92rem;
font-weight: 700;
color: #fff;
}
.price-trend-sub {
margin-top: 2px;
font-size: 0.66rem;
color: var(--text-muted);
}
.price-trend-svg {
width: 100%;
height: 60px;
display: block;
margin-top: 8px;
overflow: visible;
}
.price-trend-empty {
margin-top: 8px;
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed rgba(255, 255, 255, 0.08);
border-radius: 10px;
color: var(--text-muted);
font-size: 0.7rem;
letter-spacing: 0.1px;
}
.price-trend-points {
fill: #0b0d14;
stroke: rgba(255, 255, 255, 0.85);
stroke-width: 1.5;
}
.price-trend-line {
stroke-width: 2.4;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 0 6px rgba(0, 212, 255, 0.12));
}
/* 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.flight { background: var(--flight); }
.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); }
.sort-bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.sort-bar label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.9px;
color: var(--text-muted);
font-weight: 700;
}
.sort-select {
min-width: 220px;
background: rgba(19,22,31,0.96);
border: 1px solid rgba(0,212,255,0.18);
color: #e6f7ff;
border-radius: 10px;
padding: 9px 10px;
font-size: 0.78rem;
outline: none;
appearance: none;
cursor: pointer;
}
.sort-select:focus {
border-color: rgba(0,212,255,0.48);
box-shadow: 0 0 0 2px rgba(0,212,255,0.08);
}
.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; }
.option-actions { justify-content: flex-start; }
}
/* ── 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>
<!-- Vote Confirmation Modal -->
<div class="modal-overlay hidden" id="voteConfirmModal">
<div class="modal">
<div class="drag-handle"></div>
<h2 id="voteConfirmTitle">Confirm Vote</h2>
<p id="voteConfirmText">Are you sure you want to vote for this option?</p>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="closeVoteConfirm()">Cancel</button>
<button type="button" id="voteConfirmActionBtn" onclick="confirmVote()">Vote</button>
</div>
</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="sort-bar" id="sortBar">
<label for="sortModeSelect">Sort by</label>
<select id="sortModeSelect" class="sort-select" onchange="setSortMode(this.value)">
<option value="vote-desc">Votes: High to Low</option>
<option value="vote-asc">Votes: Low to High</option>
<option value="price-asc">Price: Low to High</option>
<option value="price-desc">Price: High to Low</option>
</select>
</div>
<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="flight">✈️ Flight</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: '',
priceHistoryRunCount: 0,
sortMode: localStorage.getItem('cabo_sort_mode') || 'vote-desc',
priceSourceSelections: (() => {
try {
return JSON.parse(localStorage.getItem('cabo_price_source_selections') || '{}');
} catch {
return {};
}
})(),
pollsOpen: true,
totalVoters: 0,
wsConnected: false,
};
let ws = null;
let activeTab = 'hotel';
let priceRefreshTimer = null;
let pendingVoteOptionId = null;
let pendingVoteRemove = false;
// ── 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();
schedulePriceRefresh();
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);
}
});
document.addEventListener('visibilitychange', () => {
if (!document.hidden) refreshPriceState();
});
}
// ── 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.priceHistoryRunCount = msg.priceHistoryRunCount || 0;
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 schedulePriceRefresh() {
if (priceRefreshTimer) clearInterval(priceRefreshTimer);
priceRefreshTimer = setInterval(refreshPriceState, 10 * 60 * 1000);
}
async function refreshPriceState() {
try {
const [optionsRes, historyRes] = await Promise.all([
fetch('/api/options?includeUnapproved=true'),
fetch('/api/price-history'),
]);
if (!optionsRes.ok || !historyRes.ok) return;
const [options, history] = await Promise.all([
optionsRes.json(),
historyRes.json(),
]);
state.options = options;
state.priceUpdatedAt = history.latestCheckedAt || state.priceUpdatedAt;
state.priceHistoryRunCount = history.totalRuns || state.priceHistoryRunCount;
renderTabs();
render();
if (mapInitialized) mapRefreshMarkers();
} catch (error) {
console.warn('Price refresh failed:', error);
}
}
function getVoteEntries(opt) {
if (Array.isArray(opt.votes)) return opt.votes;
if (Array.isArray(opt.voters)) return opt.voters.map(name => ({ name }));
return [];
}
function formatCurrency(value, currency = 'USD') {
if (typeof value !== 'number' || Number.isNaN(value)) return '';
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency,
maximumFractionDigits: value >= 100 ? 0 : 2,
}).format(value);
}
function formatTrackedDate(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function formatBookingType(bookingType, priceBasis) {
const typeLabels = {
package: 'Package',
standalone: 'Standalone',
calculated: 'Calculated',
};
const basisLabels = {
perTraveler: 'per traveler',
perNight: 'per night',
perPerson: 'per person',
perGroup: 'per group',
totalPackage: 'total package',
perRound: 'per round',
perTable: 'per table',
};
const typeLabel = typeLabels[bookingType] || '';
const basisLabel = basisLabels[priceBasis] || priceBasis || '';
return [typeLabel, basisLabel].filter(Boolean).join(' · ');
}
function normalizeSourceKey(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'unknown-source';
}
function getAvailableSources(opt) {
if (Array.isArray(opt.availableSources) && opt.availableSources.length) {
return opt.availableSources.map(source => ({
...source,
sourceKey: normalizeSourceKey(source.sourceKey || source.sourceLabel || source.source),
}));
}
const defaultKey = normalizeSourceKey(opt.currentSourceKey || opt.automationInsights?.source || 'unknown-source');
const defaultLabel = opt.automationInsights?.source || 'Unknown source';
return [{
sourceKey: defaultKey,
sourceLabel: defaultLabel,
sourceUrl: opt.automationInsights?.sourceUrl || null,
bookingType: opt.automationInsights?.bookingType || null,
priceBasis: opt.automationInsights?.priceBasis || null,
pointCount: Array.isArray(opt.priceHistory) ? opt.priceHistory.length : 0,
latestCheckedAt: opt.latestPricePoint?.checkedAt || null,
latestPrice: opt.latestPricePoint?.price ?? null,
latestDisplayPrice: opt.latestPricePoint?.displayPrice || null,
currency: opt.latestPricePoint?.currency || 'USD',
}];
}
function getOptionSelectedSourceKey(opt) {
const availableSources = getAvailableSources(opt);
const stored = state.priceSourceSelections?.[opt.id];
const normalizedStored = stored ? normalizeSourceKey(stored) : '';
if (normalizedStored && availableSources.some(source => source.sourceKey === normalizedStored)) {
return normalizedStored;
}
const defaultKey = normalizeSourceKey(opt.currentSourceKey || opt.defaultSourceKey || availableSources[0]?.sourceKey);
return availableSources.some(source => source.sourceKey === defaultKey)
? defaultKey
: availableSources[0]?.sourceKey || 'unknown-source';
}
function getOptionSourceSeries(opt, sourceKey = getOptionSelectedSourceKey(opt)) {
const normalizedKey = normalizeSourceKey(sourceKey);
if (opt.priceHistoryBySource && Array.isArray(opt.priceHistoryBySource[normalizedKey])) {
return opt.priceHistoryBySource[normalizedKey];
}
return Array.isArray(opt.priceHistory) ? opt.priceHistory : [];
}
function getOptionSourceMeta(opt, sourceKey = getOptionSelectedSourceKey(opt)) {
const normalizedKey = normalizeSourceKey(sourceKey);
return getAvailableSources(opt).find(source => source.sourceKey === normalizedKey) || null;
}
function setOptionSource(optionId, sourceKey) {
state.priceSourceSelections = {
...state.priceSourceSelections,
[optionId]: normalizeSourceKey(sourceKey),
};
localStorage.setItem('cabo_price_source_selections', JSON.stringify(state.priceSourceSelections));
render();
}
function setSortMode(sortMode) {
const allowedModes = new Set(['vote-desc', 'vote-asc', 'price-asc', 'price-desc']);
state.sortMode = allowedModes.has(sortMode) ? sortMode : 'vote-desc';
localStorage.setItem('cabo_sort_mode', state.sortMode);
render();
}
function getOptionOrderIndexMap() {
return new Map(state.options.map((opt, index) => [opt.id, index]));
}
function getSelectedOptionPrice(opt) {
const selectedSeries = getOptionSourceSeries(opt);
const selectedPoint = selectedSeries.at(-1) || opt.latestPricePoint || null;
return typeof selectedPoint?.price === 'number' ? selectedPoint.price : null;
}
function sortOptionsByMode(opts, orderIndexMap) {
const mode = state.sortMode || 'vote-desc';
const ascending = mode.endsWith('asc');
const isVoteSort = mode.startsWith('vote');
return [...opts].sort((a, b) => {
const aIndex = orderIndexMap.get(a.id) ?? 0;
const bIndex = orderIndexMap.get(b.id) ?? 0;
if (isVoteSort) {
const aVotes = getVoteEntries(a).length;
const bVotes = getVoteEntries(b).length;
if (aVotes !== bVotes) {
return ascending ? aVotes - bVotes : bVotes - aVotes;
}
} else {
const aPrice = getSelectedOptionPrice(a);
const bPrice = getSelectedOptionPrice(b);
const aHasPrice = typeof aPrice === 'number';
const bHasPrice = typeof bPrice === 'number';
if (aHasPrice && bHasPrice && aPrice !== bPrice) {
return ascending ? aPrice - bPrice : bPrice - aPrice;
}
if (aHasPrice !== bHasPrice) {
return aHasPrice ? -1 : 1;
}
}
return aIndex - bIndex;
});
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function normalizeTextList(value) {
const items = Array.isArray(value) ? value : value == null ? [] : [value];
return [...new Set(items.flatMap(item => {
if (Array.isArray(item)) return item;
if (item && typeof item === 'object') {
return [
item.label,
item.name,
item.text,
item.title,
item.value,
item.summary,
item.description,
].filter(Boolean);
}
return [item];
})
.map(item => String(item).trim())
.filter(Boolean))];
}
function renderTextChips(items) {
const chips = normalizeTextList(items);
if (!chips.length) return '';
return `
<div class="option-detail-list">
${chips.map(item => `<span class="option-detail-chip">${escapeHtml(item)}</span>`).join('')}
</div>
`;
}
function renderSourceSelect(opt) {
const sources = getAvailableSources(opt);
if (sources.length <= 1) {
const source = sources[0] || null;
const sourceLabel = source?.sourceLabel || opt.automationInsights?.source || 'Unknown source';
const meta = source ? [
formatBookingType(source.bookingType, source.priceBasis),
source.latestDisplayPrice || (typeof source.latestPrice === 'number' ? formatCurrency(source.latestPrice, source.currency || 'USD') : ''),
source.pointCount ? `${source.pointCount} point${source.pointCount === 1 ? '' : 's'}` : '',
].filter(Boolean).join(' · ') : '';
return `
<div class="option-source-sub">${escapeHtml(sourceLabel)}${meta ? ` · ${escapeHtml(meta)}` : ''}</div>
`;
}
const selectedSourceKey = getOptionSelectedSourceKey(opt);
return `
<select class="option-source-select" onchange="setOptionSource('${opt.id}', this.value); event.stopPropagation()">
${sources.map(source => {
const labelParts = [
source.sourceLabel || 'Unknown source',
formatBookingType(source.bookingType, source.priceBasis),
source.latestDisplayPrice || (typeof source.latestPrice === 'number' ? formatCurrency(source.latestPrice, source.currency || 'USD') : ''),
source.pointCount ? `${source.pointCount} pt${source.pointCount === 1 ? '' : 's'}` : '',
].filter(Boolean);
return `<option value="${escapeHtml(source.sourceKey)}"${source.sourceKey === selectedSourceKey ? ' selected' : ''}>${escapeHtml(labelParts.join(' · '))}</option>`;
}).join('')}
</select>
`;
}
function renderOptionFacts(opt) {
const availableSources = getAvailableSources(opt);
const selectedSourceKey = getOptionSelectedSourceKey(opt);
const selectedSeries = getOptionSourceSeries(opt, selectedSourceKey);
const selectedMeta = getOptionSourceMeta(opt, selectedSourceKey);
const selectedPoint = selectedSeries.at(-1) || opt.latestPricePoint || null;
const insights = selectedPoint ? {
source: selectedMeta?.sourceLabel || selectedPoint.source || 'Automation feed',
sourceUrl: selectedMeta?.sourceUrl || selectedPoint.sourceUrl || null,
bookingType: selectedMeta?.bookingType || selectedPoint.bookingType || null,
priceBasis: selectedMeta?.priceBasis || selectedPoint.priceBasis || null,
availability: selectedPoint.availability || null,
decisionNote: selectedPoint.decisionNote || null,
displayPrice: selectedPoint.displayPrice || null,
currency: selectedPoint.currency || 'USD',
} : (opt.automationInsights || {});
const currentPrice = typeof selectedPoint?.price === 'number' ? formatCurrency(selectedPoint.price, insights.currency || 'USD') : '';
const priceLabel = selectedPoint?.displayPrice || insights.displayPrice || currentPrice || 'Not yet tracked';
const sourceLabel = selectedMeta?.sourceLabel || insights.source || 'Automation feed';
const bookingLabel = formatBookingType(
selectedMeta?.bookingType || insights.bookingType,
selectedMeta?.priceBasis || insights.priceBasis,
);
const statusLabel = selectedPoint?.availability || selectedPoint?.decisionNote || insights.availability || insights.decisionNote || 'Matched from live search';
const overviewItems = normalizeTextList(opt.details);
const autoHighlights = normalizeTextList(selectedPoint?.highlights || opt.automationInsights?.highlights);
const features = normalizeTextList(selectedPoint?.features || opt.automationInsights?.features);
const amenities = normalizeTextList(selectedPoint?.amenities || opt.automationInsights?.amenities);
const inclusions = normalizeTextList(selectedPoint?.inclusions || opt.automationInsights?.inclusions);
const limitations = normalizeTextList(selectedPoint?.limitations || opt.automationInsights?.limitations);
const sourceMetaLine = selectedMeta
? [selectedMeta.latestDisplayPrice || priceLabel, selectedMeta.pointCount ? `${selectedMeta.pointCount} point${selectedMeta.pointCount === 1 ? '' : 's'}` : '']
.filter(Boolean)
.join(' · ')
: '';
const sections = [];
sections.push(`
<div class="option-facts-grid">
<div class="option-fact">
<span class="option-fact-label">Current price</span>
<div class="option-fact-value">${escapeHtml(priceLabel)}</div>
</div>
<div class="option-fact">
<span class="option-fact-label">Source</span>
${renderSourceSelect(opt)}
${availableSources.length > 1 && sourceMetaLine ? `<div class="option-source-sub">${escapeHtml(sourceLabel)}${sourceMetaLine ? ` · ${escapeHtml(sourceMetaLine)}` : ''}</div>` : ''}
</div>
<div class="option-fact">
<span class="option-fact-label">Booking type</span>
<div class="option-fact-value">${escapeHtml(bookingLabel || 'Not classified')}</div>
</div>
<div class="option-fact">
<span class="option-fact-label">Status</span>
<div class="option-fact-value">${escapeHtml(statusLabel)}</div>
</div>
</div>
`);
if (overviewItems.length) {
sections.push(`
<div class="option-detail-section">
<div class="option-detail-title">Decision summary</div>
${renderTextChips([...overviewItems, ...autoHighlights])}
</div>
`);
} else if (autoHighlights.length) {
sections.push(`
<div class="option-detail-section">
<div class="option-detail-title">Decision summary</div>
${renderTextChips(autoHighlights)}
</div>
`);
}
if (features.length) {
sections.push(`
<div class="option-detail-section">
<div class="option-detail-title">Features</div>
${renderTextChips(features)}
</div>
`);
}
if (amenities.length) {
sections.push(`
<div class="option-detail-section">
<div class="option-detail-title">Amenities</div>
${renderTextChips(amenities)}
</div>
`);
}
if (inclusions.length) {
sections.push(`
<div class="option-detail-section">
<div class="option-detail-title">Inclusions</div>
${renderTextChips(inclusions)}
</div>
`);
}
if (limitations.length) {
sections.push(`
<div class="option-detail-section">
<div class="option-detail-title">Tradeoffs</div>
${renderTextChips(limitations)}
</div>
`);
}
return sections.length
? `<div class="option-facts">${sections.join('')}</div>`
: '';
}
function renderPriceTrend(opt) {
const selectedSourceKey = getOptionSelectedSourceKey(opt);
const series = getOptionSourceSeries(opt, selectedSourceKey)
.filter(point => typeof point.price === 'number' && !Number.isNaN(point.price));
const selectedMeta = getOptionSourceMeta(opt, selectedSourceKey);
if (series.length === 0) {
return `
<div class="price-trend-empty">
${selectedMeta?.sourceLabel ? `${escapeHtml(selectedMeta.sourceLabel)} price tracking appears after the next automation run.` : 'Price tracking appears after the next automation run.'}
</div>
`;
}
const width = 260;
const height = 60;
const paddingX = 6;
const paddingY = 8;
const innerWidth = width - (paddingX * 2);
const innerHeight = height - (paddingY * 2);
const totalRuns = Math.max(state.priceHistoryRunCount || series.length, 1);
const chartKey = String(opt.id || opt.name || 'price-trend')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const prices = series.map(point => point.price);
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
const range = maxPrice - minPrice || 1;
const points = series.map((point, index) => {
const runIndex = typeof point.runIndex === 'number' ? point.runIndex : index;
const x = paddingX + ((totalRuns === 1 ? 0.5 : runIndex / (totalRuns - 1)) * innerWidth);
const y = paddingY + innerHeight - ((point.price - minPrice) / range) * innerHeight;
return { ...point, x, y };
});
const path = points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join(' ');
const areaPath = [
`M ${points[0].x.toFixed(1)} ${height - paddingY}`,
...points.map((point) => `L ${point.x.toFixed(1)} ${point.y.toFixed(1)}`),
`L ${points[points.length - 1].x.toFixed(1)} ${height - paddingY}`,
'Z',
].join(' ');
const latest = points[points.length - 1];
const first = points[0];
const delta = latest.price - first.price;
const deltaText = delta === 0
? 'flat from first check'
: `${delta > 0 ? '+' : ''}${formatCurrency(Math.abs(delta), latest.currency)}`;
const checkedLabel = latest.checkedAt ? formatTrackedDate(latest.checkedAt) : '';
const sourceLabel = selectedMeta?.sourceLabel || latest.source || 'Tracked source';
return `
<div class="price-trend">
<div class="price-trend-header">
<div>
<div class="price-trend-label">Automation price trail</div>
<div class="price-trend-value">${escapeHtml(sourceLabel)} · ${escapeHtml(latest.displayPrice || formatCurrency(latest.price, latest.currency) || 'Tracked price')}</div>
</div>
<div class="price-trend-sub">${series.length} point${series.length === 1 ? '' : 's'}${checkedLabel ? ` · ${checkedLabel}` : ''}</div>
</div>
<svg class="price-trend-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" aria-label="Price trend line graph">
<defs>
<linearGradient id="priceTrendStroke-${chartKey}" x1="0%" x2="100%" y1="0%" y2="0%">
<stop offset="0%" stop-color="#00d4ff" />
<stop offset="100%" stop-color="#fbbf24" />
</linearGradient>
<linearGradient id="priceTrendFill-${chartKey}" x1="0%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.18" />
<stop offset="100%" stop-color="#00d4ff" stop-opacity="0.02" />
</linearGradient>
</defs>
<path d="${areaPath}" fill="url(#priceTrendFill-${chartKey})" opacity="0.8"></path>
<path d="${path}" class="price-trend-line" stroke="url(#priceTrendStroke-${chartKey})"></path>
${points.map((point, index) => `
<circle class="price-trend-points" cx="${point.x.toFixed(1)}" cy="${point.y.toFixed(1)}" r="${index === points.length - 1 ? 3.4 : 2.4}">
<title>${(point.displayPrice || formatCurrency(point.price, point.currency) || 'Tracked price')} · ${formatTrackedDate(point.checkedAt) || 'automation run'}</title>
</circle>
`).join('')}
</svg>
<div class="price-trend-sub">Change since first check: ${deltaText}</div>
</div>
`;
}
// ── 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();
if (pendingVoteOptionId) {
openVoteConfirm(pendingVoteOptionId, pendingVoteRemove);
}
}
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();
}
function openVoteConfirm(optionId, remove = false) {
if (activeTab === 'results') return;
if (!state.voterName) {
pendingVoteOptionId = optionId;
pendingVoteRemove = remove;
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);
pendingVoteOptionId = optionId;
pendingVoteRemove = remove || alreadyVoted;
const confirmTitle = document.getElementById('voteConfirmTitle');
const confirmText = document.getElementById('voteConfirmText');
const confirmBtn = document.getElementById('voteConfirmActionBtn');
const isRemoveAction = pendingVoteRemove;
if (confirmTitle) confirmTitle.textContent = isRemoveAction ? 'Remove Vote' : 'Confirm Vote';
if (confirmText) {
confirmText.textContent = isRemoveAction
? `You already voted for ${opt.name}. Confirm to remove your vote.`
: `Confirm your vote for ${opt.name}.`;
}
if (confirmBtn) confirmBtn.textContent = isRemoveAction ? 'Remove Vote' : 'Vote';
document.getElementById('voteConfirmModal').classList.remove('hidden');
}
function closeVoteConfirm() {
pendingVoteOptionId = null;
pendingVoteRemove = false;
document.getElementById('voteConfirmModal').classList.add('hidden');
}
function confirmVote() {
if (!pendingVoteOptionId) return;
const optionId = pendingVoteOptionId;
const remove = pendingVoteRemove;
const opt = state.options.find(o => o.id === optionId);
if (!opt) {
closeVoteConfirm();
return;
}
wsSend({ type: 'vote', optionId, voterName: state.voterName, remove });
showToast(remove ? `Removed vote for ${opt.name}` : `Voted for ${opt.name}!`);
closeVoteConfirm();
}
// ── 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');
const sortModeSelect = document.getElementById('sortModeSelect');
if (sortModeSelect && sortModeSelect.value !== state.sortMode) {
sortModeSelect.value = state.sortMode;
}
// ── 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 === 'flight'
? 'var(--flight)'
: 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;
}
const sorted = sortOptionsByMode(opts, getOptionOrderIndexMap());
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' : ''}">
<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}
${renderOptionFacts(opt)}
${renderPriceTrend(opt)}
<div class="option-actions">
<button
type="button"
class="option-vote-btn${hasVoted ? ' voted' : ''}"
title="Vote"
onclick="openVoteConfirm('${opt.id}', ${hasVoted ? 'true' : 'false'}); event.stopPropagation()"
>
Vote
</button>
</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">Automation pricing 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) {
openVoteConfirm(optionId);
}
// ── 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>