3443 lines
122 KiB
HTML
3443 lines
122 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Cabo Bachelor Party — Vote & 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; }
|
||
#flight-origin-select {
|
||
background: transparent;
|
||
border: none;
|
||
border-left: 1px solid #252a38;
|
||
color: #e0e6f0;
|
||
font-size: 0.72rem;
|
||
font-weight: 700;
|
||
padding: 8px 10px;
|
||
outline: none;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
max-width: 96px;
|
||
}
|
||
#flight-origin-select option {
|
||
background: #13161f;
|
||
color: #e0e6f0;
|
||
}
|
||
.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); }
|
||
.flight-shortcuts {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
padding: 8px 10px 10px;
|
||
border-top: 1px solid #252a38;
|
||
background: rgba(9,11,16,0.55);
|
||
}
|
||
.flight-shortcut-btn {
|
||
border: 1px solid #252a38;
|
||
background: rgba(255,255,255,0.03);
|
||
color: #c9d2e3;
|
||
font-size: 0.67rem;
|
||
font-weight: 700;
|
||
letter-spacing: 0.2px;
|
||
border-radius: 999px;
|
||
padding: 5px 9px;
|
||
cursor: pointer;
|
||
transition: all 0.15s ease;
|
||
}
|
||
.flight-shortcut-btn:hover {
|
||
border-color: #00d4ff;
|
||
color: #ffffff;
|
||
background: rgba(0,212,255,0.08);
|
||
}
|
||
.flight-shortcut-btn.primary {
|
||
border-color: rgba(0,212,255,0.45);
|
||
color: #7de3ff;
|
||
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 .role-tag {
|
||
font-size: 0.62rem;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
background: rgba(52,211,153,0.12);
|
||
color: var(--green);
|
||
border: 1px solid rgba(52,211,153,0.22);
|
||
border-radius: 999px;
|
||
padding: 2px 8px;
|
||
}
|
||
.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,
|
||
.modal select {
|
||
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,
|
||
.modal select:focus { border-color: var(--accent); }
|
||
.modal select {
|
||
appearance: none;
|
||
cursor: pointer;
|
||
}
|
||
.auth-help {
|
||
margin-top: -6px;
|
||
margin-bottom: 14px;
|
||
color: var(--text-muted);
|
||
font-size: 0.72rem;
|
||
line-height: 1.4;
|
||
}
|
||
.auth-status {
|
||
min-height: 18px;
|
||
margin: -6px 0 12px;
|
||
font-size: 0.75rem;
|
||
color: var(--red);
|
||
text-align: left;
|
||
}
|
||
.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-controls {
|
||
margin-top: 12px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.budget-controls label {
|
||
font-size: 0.68rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.8px;
|
||
color: #fbc08c;
|
||
font-weight: 700;
|
||
}
|
||
.budget-controls select {
|
||
min-width: 180px;
|
||
padding: 8px 12px;
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||
background: rgba(11, 13, 20, 0.9);
|
||
color: #fff;
|
||
font-size: 0.84rem;
|
||
outline: none;
|
||
cursor: pointer;
|
||
}
|
||
.budget-controls select:focus {
|
||
border-color: rgba(251, 191, 36, 0.55);
|
||
box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.08);
|
||
}
|
||
.budget-selected-note {
|
||
font-size: 0.72rem;
|
||
color: #ffcf9c;
|
||
}
|
||
.budget-table-wrap {
|
||
margin-top: 16px;
|
||
overflow-x: auto;
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
background: rgba(11, 13, 20, 0.58);
|
||
}
|
||
.budget-table {
|
||
width: 100%;
|
||
min-width: 620px;
|
||
border-collapse: collapse;
|
||
}
|
||
.budget-table thead th {
|
||
position: sticky;
|
||
top: 0;
|
||
background: rgba(12, 15, 24, 0.96);
|
||
color: #ffd7b0;
|
||
text-align: left;
|
||
font-size: 0.68rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.6px;
|
||
padding: 12px 14px;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||
white-space: nowrap;
|
||
}
|
||
.budget-table tbody td {
|
||
padding: 12px 14px;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||
vertical-align: top;
|
||
}
|
||
.budget-table tbody tr:last-child td {
|
||
border-bottom: none;
|
||
}
|
||
.budget-table tbody tr.is-max {
|
||
background: rgba(249, 115, 22, 0.10);
|
||
}
|
||
.budget-table tbody tr.is-max td:first-child {
|
||
box-shadow: inset 3px 0 0 #f97316;
|
||
}
|
||
.budget-attendee-count {
|
||
width: 84px;
|
||
color: #fff3e8;
|
||
font-weight: 800;
|
||
white-space: nowrap;
|
||
}
|
||
.budget-cell {
|
||
min-width: 150px;
|
||
}
|
||
.budget-cell-label {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-bottom: 6px;
|
||
font-size: 0.64rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.6px;
|
||
color: #fbc08c;
|
||
}
|
||
.budget-tier-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 2px 7px;
|
||
border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
color: #fff;
|
||
font-weight: 700;
|
||
}
|
||
.budget-cell-price {
|
||
font-size: 1.02rem;
|
||
font-weight: 800;
|
||
color: #fff;
|
||
line-height: 1.25;
|
||
}
|
||
.budget-cell-total {
|
||
margin-top: 4px;
|
||
font-size: 0.72rem;
|
||
color: #ffcf9c;
|
||
}
|
||
.budget-cell-note {
|
||
margin-top: 6px;
|
||
font-size: 0.68rem;
|
||
line-height: 1.35;
|
||
color: #b7c0d4;
|
||
}
|
||
.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>
|
||
|
||
<!-- Guest Auth Modal -->
|
||
<div class="modal-overlay" id="authModal">
|
||
<div class="modal">
|
||
<div class="drag-handle"></div>
|
||
<h2>🔐 Guest Access</h2>
|
||
<p>Select your name and enter the last 4 digits of your phone number to unlock voting.</p>
|
||
<select id="guestNameSelect" aria-label="Guest name"></select>
|
||
<input type="password" id="guestPinInput" placeholder="Last 4 digits" maxlength="4" inputmode="numeric" autocomplete="one-time-code" />
|
||
<div class="auth-help" id="authRoleHelp">Jon is marked as the groom and Toph as the best man.</div>
|
||
<div class="auth-status" id="authError"></div>
|
||
<button onclick="submitAuth()">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 class="role-tag" id="voterDisplayRole" style="display:none;"></span></span>
|
||
<button onclick="changeName()" title="Switch guest">✕</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" />
|
||
<select id="flight-origin-select" aria-label="Flight origin airport">
|
||
<option value="LAX">LAX</option>
|
||
<option value="ONT">ONT</option>
|
||
</select>
|
||
<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 class="flight-shortcuts" aria-label="Flight search shortcuts">
|
||
<button class="flight-shortcut-btn primary" onclick="quickBook('flights-google')">Google Flights</button>
|
||
<button class="flight-shortcut-btn" onclick="quickBook('flights-kayak')">KAYAK</button>
|
||
<button class="flight-shortcut-btn" onclick="quickBook('flights-united')">United</button>
|
||
<button class="flight-shortcut-btn" onclick="quickBook('flights-delta')">Delta</button>
|
||
<button class="flight-shortcut-btn" onclick="quickBook('flights-alaska')">Alaska</button>
|
||
<button class="flight-shortcut-btn" onclick="quickBook('flights-expedia')">Expedia</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: '',
|
||
guestRole: localStorage.getItem('cabo_guest_role') || '',
|
||
guestAuthToken: localStorage.getItem('cabo_guest_auth_token') || '',
|
||
guestRoster: [],
|
||
categories: [],
|
||
options: [],
|
||
budgetScenarios: [],
|
||
priceUpdatedAt: '',
|
||
priceHistoryRunCount: 0,
|
||
sortMode: localStorage.getItem('cabo_sort_mode') || 'vote-desc',
|
||
budgetGuestCount: Number(localStorage.getItem('cabo_budget_guest_count') || 0),
|
||
priceSourceSelections: (() => {
|
||
try {
|
||
return JSON.parse(localStorage.getItem('cabo_price_source_selections') || '{}');
|
||
} catch {
|
||
return {};
|
||
}
|
||
})(),
|
||
pollsOpen: true,
|
||
totalVoters: 0,
|
||
wsConnected: false,
|
||
authReady: false,
|
||
};
|
||
let ws = null;
|
||
let activeTab = 'hotel';
|
||
let priceRefreshTimer = null;
|
||
let pendingVoteOptionId = null;
|
||
let pendingVoteRemove = false;
|
||
let pendingStableOptionOrder = null;
|
||
|
||
// ── Init ───────────────────────────────────────────────────
|
||
function init() {
|
||
// Check for ?view=results URL param and keep the results tab active.
|
||
const params = new URLSearchParams(location.search);
|
||
const viewResults = params.get('view') === 'results';
|
||
|
||
if (viewResults) {
|
||
activeTab = 'results';
|
||
}
|
||
|
||
connectWS();
|
||
renderTabs();
|
||
render();
|
||
schedulePriceRefresh();
|
||
renderGuestAuthOptions();
|
||
fetchGuestRoster();
|
||
restoreGuestSession();
|
||
|
||
// 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();
|
||
});
|
||
}
|
||
|
||
function getGuestRoleLabel(role) {
|
||
if (!role) return '';
|
||
if (role === 'groom') return 'Groom';
|
||
if (role === 'best-man') return 'Best Man';
|
||
if (role === 'guest') return '';
|
||
return role.replace(/-/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
|
||
}
|
||
|
||
function renderGuestAuthOptions() {
|
||
const select = document.getElementById('guestNameSelect');
|
||
if (!select) return;
|
||
|
||
const roster = Array.isArray(state.guestRoster) ? state.guestRoster : [];
|
||
const currentValue = select.value || state.voterName || '';
|
||
|
||
select.innerHTML = [
|
||
'<option value="" disabled selected>Select your name</option>',
|
||
...roster.map((guest) => {
|
||
const roleLabel = getGuestRoleLabel(guest.role);
|
||
const label = roleLabel ? `${guest.name} (${roleLabel})` : guest.name;
|
||
return `<option value="${escapeHtml(guest.name)}">${escapeHtml(label)}</option>`;
|
||
}),
|
||
].join('');
|
||
|
||
if (currentValue && roster.some((guest) => guest.name === currentValue)) {
|
||
select.value = currentValue;
|
||
}
|
||
}
|
||
|
||
async function fetchGuestRoster() {
|
||
try {
|
||
const res = await fetch('/api/auth/guests');
|
||
if (!res.ok) return;
|
||
const payload = await res.json();
|
||
state.guestRoster = Array.isArray(payload.guests) ? payload.guests : [];
|
||
syncBudgetGuestCount();
|
||
renderGuestAuthOptions();
|
||
render();
|
||
} catch {
|
||
// Keep the modal usable even if the roster endpoint is temporarily unavailable.
|
||
}
|
||
}
|
||
|
||
function showAuthModal(message = '') {
|
||
document.getElementById('authModal')?.classList.remove('hidden');
|
||
const err = document.getElementById('authError');
|
||
if (err) err.textContent = message;
|
||
renderGuestAuthOptions();
|
||
if (!state.guestRoster.length) fetchGuestRoster();
|
||
const pinInput = document.getElementById('guestPinInput');
|
||
if (pinInput) pinInput.value = '';
|
||
setTimeout(() => document.getElementById('guestNameSelect')?.focus(), 0);
|
||
}
|
||
|
||
function hideAuthModal() {
|
||
document.getElementById('authModal')?.classList.add('hidden');
|
||
const err = document.getElementById('authError');
|
||
if (err) err.textContent = '';
|
||
}
|
||
|
||
async function restoreGuestSession() {
|
||
if (!state.guestAuthToken) {
|
||
state.authReady = true;
|
||
showAuthModal();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('/api/auth/me', {
|
||
headers: { Authorization: `Bearer ${state.guestAuthToken}` },
|
||
});
|
||
if (!res.ok) throw new Error('invalid session');
|
||
const payload = await res.json();
|
||
applyGuestSession(payload.guest, state.guestAuthToken, true);
|
||
} catch {
|
||
clearGuestSession();
|
||
showAuthModal('Your access code is no longer valid. Please sign in again.');
|
||
} finally {
|
||
state.authReady = true;
|
||
}
|
||
}
|
||
|
||
function clearGuestSession() {
|
||
state.voterName = '';
|
||
state.guestRole = '';
|
||
state.guestAuthToken = '';
|
||
localStorage.removeItem('cabo_guest_name');
|
||
localStorage.removeItem('cabo_guest_role');
|
||
localStorage.removeItem('cabo_guest_auth_token');
|
||
document.getElementById('voterBadge').style.display = 'none';
|
||
}
|
||
|
||
function applyGuestSession(guest, token, persist = false) {
|
||
state.voterName = guest.name;
|
||
state.guestRole = guest.role || 'guest';
|
||
state.guestAuthToken = token;
|
||
|
||
if (persist) {
|
||
localStorage.setItem('cabo_guest_name', guest.name);
|
||
localStorage.setItem('cabo_guest_role', state.guestRole);
|
||
localStorage.setItem('cabo_guest_auth_token', token);
|
||
}
|
||
|
||
updateGuestBadge();
|
||
hideAuthModal();
|
||
render();
|
||
if (pendingVoteOptionId) {
|
||
openVoteConfirm(pendingVoteOptionId, pendingVoteRemove);
|
||
}
|
||
}
|
||
|
||
function updateGuestBadge() {
|
||
if (!state.voterName) {
|
||
document.getElementById('voterBadge').style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
document.getElementById('voterDisplayName').textContent = state.voterName;
|
||
const roleEl = document.getElementById('voterDisplayRole');
|
||
const roleLabel = getGuestRoleLabel(state.guestRole);
|
||
if (roleEl) {
|
||
roleEl.textContent = roleLabel;
|
||
roleEl.style.display = roleLabel ? 'inline-flex' : 'none';
|
||
}
|
||
document.getElementById('voterBadge').style.display = 'inline-flex';
|
||
}
|
||
|
||
// ── 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.guestRoster = msg.guestRoster || [];
|
||
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;
|
||
renderGuestAuthOptions();
|
||
updateGuestBadge();
|
||
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();
|
||
} else if (msg.type === 'error') {
|
||
showToast(msg.message || 'Request failed', 'error');
|
||
}
|
||
};
|
||
|
||
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) {
|
||
pendingStableOptionOrder = {
|
||
tabId: activeTab,
|
||
optionIds: getRenderedOptionIds(),
|
||
};
|
||
state.priceSourceSelections = {
|
||
...state.priceSourceSelections,
|
||
[optionId]: normalizeSourceKey(sourceKey),
|
||
};
|
||
localStorage.setItem('cabo_price_source_selections', JSON.stringify(state.priceSourceSelections));
|
||
render();
|
||
}
|
||
|
||
function setSortMode(sortMode) {
|
||
pendingStableOptionOrder = null;
|
||
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 getRenderedOptionIds() {
|
||
return [...document.querySelectorAll('#optionsList .option-card[data-option-id]')]
|
||
.map(card => card.dataset.optionId)
|
||
.filter(Boolean);
|
||
}
|
||
|
||
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 applyPendingStableOrder(sortedOptions) {
|
||
if (!pendingStableOptionOrder || pendingStableOptionOrder.tabId !== activeTab) {
|
||
pendingStableOptionOrder = null;
|
||
return sortedOptions;
|
||
}
|
||
|
||
const order = new Map(pendingStableOptionOrder.optionIds.map((optionId, index) => [optionId, index]));
|
||
pendingStableOptionOrder = null;
|
||
if (!order.size) return sortedOptions;
|
||
|
||
return [...sortedOptions].sort((a, b) => {
|
||
const aIndex = order.has(a.id) ? order.get(a.id) : Number.MAX_SAFE_INTEGER;
|
||
const bIndex = order.has(b.id) ? order.get(b.id) : Number.MAX_SAFE_INTEGER;
|
||
if (aIndex !== bIndex) return aIndex - bIndex;
|
||
return sortedOptions.indexOf(a) - sortedOptions.indexOf(b);
|
||
});
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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 formatSourcePrice(source) {
|
||
if (!source) return '';
|
||
return typeof source.latestPrice === 'number'
|
||
? formatCurrency(source.latestPrice, source.currency || 'USD')
|
||
: source.latestDisplayPrice || '';
|
||
}
|
||
|
||
function getPointContext(point) {
|
||
if (!point) return '';
|
||
const unitContext = point.tripNights && point.unitPrice
|
||
? `${formatCurrency(point.unitPrice, point.currency || 'USD')} x ${point.tripNights} nights`
|
||
: '';
|
||
return [unitContext, point.unitDisplayPrice || point.displayPrice || point.decisionNote || '']
|
||
.filter(Boolean)
|
||
.join(' · ');
|
||
}
|
||
|
||
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),
|
||
formatSourcePrice(source),
|
||
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),
|
||
formatSourcePrice(source),
|
||
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 = currentPrice || insights.displayPrice || 'Not yet tracked';
|
||
const priceContext = getPointContext(selectedPoint);
|
||
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
|
||
? [formatSourcePrice(selectedMeta) || 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>
|
||
${priceContext ? `<div class="option-source-sub">${escapeHtml(priceContext)}</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';
|
||
const latestPriceLabel = formatCurrency(latest.price, latest.currency) || 'Tracked price';
|
||
const latestContext = getPointContext(latest);
|
||
|
||
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(latestPriceLabel)}</div>
|
||
${latestContext ? `<div class="price-trend-sub">${escapeHtml(latestContext)}</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>${formatCurrency(point.price, point.currency) || 'Tracked price'}${getPointContext(point) ? ` · ${getPointContext(point)}` : ''} · ${formatTrackedDate(point.checkedAt) || 'automation run'}</title>
|
||
</circle>
|
||
`).join('')}
|
||
</svg>
|
||
<div class="price-trend-sub">Change since first check: ${deltaText}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ── Guest auth modal ───────────────────────────────────────
|
||
async function submitAuth() {
|
||
const name = document.getElementById('guestNameSelect').value.trim();
|
||
const pin = document.getElementById('guestPinInput').value.trim();
|
||
const err = document.getElementById('authError');
|
||
|
||
if (!name) {
|
||
if (err) err.textContent = 'Choose your name first.';
|
||
return;
|
||
}
|
||
if (!/^\d{4}$/.test(pin)) {
|
||
if (err) err.textContent = 'Enter the last 4 digits of your phone number.';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('/api/auth/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, pin }),
|
||
});
|
||
|
||
const payload = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
if (err) err.textContent = payload.error || 'That name and code did not match.';
|
||
return;
|
||
}
|
||
|
||
applyGuestSession(payload.guest, payload.token, true);
|
||
showToast(`Welcome, ${payload.guest.name}!`, 'success');
|
||
} catch {
|
||
if (err) err.textContent = 'Could not reach the server. Try again.';
|
||
}
|
||
}
|
||
|
||
function changeName() {
|
||
clearGuestSession();
|
||
showAuthModal();
|
||
}
|
||
|
||
function openVoteConfirm(optionId, remove = false) {
|
||
if (activeTab === 'results') return;
|
||
if (!state.voterName) {
|
||
pendingVoteOptionId = optionId;
|
||
pendingVoteRemove = remove;
|
||
showAuthModal();
|
||
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, authToken: state.guestAuthToken, 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 = applyPendingStableOrder(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' : ''}" data-option-id="${escapeHtml(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}
|
||
${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 groupedScenarios = getBudgetScenarioGroups();
|
||
const groupSizeLimit = getBudgetGroupSizeLimit();
|
||
const selectedGroupSize = getSelectedBudgetGuestCount(groupSizeLimit);
|
||
const rosterCount = Array.isArray(state.guestRoster) ? state.guestRoster.length : 0;
|
||
const groomCount = Array.isArray(state.guestRoster)
|
||
? state.guestRoster.filter((guest) => guest.role === 'groom').length
|
||
: 0;
|
||
const bestManCount = Array.isArray(state.guestRoster)
|
||
? state.guestRoster.filter((guest) => guest.role === 'best-man').length
|
||
: 0;
|
||
const selectedScenarios = {
|
||
budget: getDynamicBudgetScenario('Budget', selectedGroupSize, groupedScenarios),
|
||
balanced: getDynamicBudgetScenario('Balanced', selectedGroupSize, groupedScenarios),
|
||
splurge: getDynamicBudgetScenario('Splurge', selectedGroupSize, groupedScenarios),
|
||
};
|
||
|
||
return `
|
||
<section class="budget-board">
|
||
<h2>💸 Budget Cheat Sheet</h2>
|
||
<p>Pick the attendee count you want to model. The app will show only the live Budget, Balanced, and Splurge data for that one group size.</p>
|
||
<div class="budget-stamp">Confirmed roster: ${rosterCount || 'loading'} attendee${rosterCount === 1 ? '' : 's'}${groomCount ? ` · ${groomCount} groom${groomCount === 1 ? '' : 's'}` : ''}${bestManCount ? ` · ${bestManCount} best man${bestManCount === 1 ? '' : 's'}` : ''} · max size ${groupSizeLimit}</div>
|
||
<div class="budget-controls">
|
||
<label for="budgetGuestCountSelect">Attendees</label>
|
||
<select id="budgetGuestCountSelect" onchange="setBudgetGuestCount(this.value)">
|
||
${Array.from({ length: groupSizeLimit }, (_, index) => index + 1).map((groupSize) => `
|
||
<option value="${groupSize}"${groupSize === selectedGroupSize ? ' selected' : ''}>${groupSize} attendee${groupSize === 1 ? '' : 's'}</option>
|
||
`).join('')}
|
||
</select>
|
||
<div class="budget-selected-note">Showing live pricing for ${selectedGroupSize} attendee${selectedGroupSize === 1 ? '' : 's'}.</div>
|
||
</div>
|
||
<div class="budget-grid">
|
||
${[
|
||
selectedScenarios.budget,
|
||
selectedScenarios.balanced,
|
||
selectedScenarios.splurge,
|
||
].map((scenario) => renderBudgetCard(scenario, selectedGroupSize)).join('')}
|
||
</div>
|
||
</section>
|
||
`;
|
||
}
|
||
|
||
function getBudgetScenarioGroups() {
|
||
const tiers = { Budget: [], Balanced: [], Splurge: [] };
|
||
(Array.isArray(state.budgetScenarios) ? state.budgetScenarios : []).forEach((scenario) => {
|
||
if (!tiers[scenario.tier]) return;
|
||
tiers[scenario.tier].push(scenario);
|
||
});
|
||
|
||
Object.values(tiers).forEach((list) => list.sort((a, b) => a.groupSize - b.groupSize));
|
||
return tiers;
|
||
}
|
||
|
||
function getBudgetGroupSizeLimit() {
|
||
const rosterCount = Array.isArray(state.guestRoster) ? state.guestRoster.length : 0;
|
||
const seededMax = Math.max(...(Array.isArray(state.budgetScenarios) ? state.budgetScenarios : []).map((scenario) => scenario.groupSize || 0), 0);
|
||
return Math.max(rosterCount || 0, seededMax || 0, 1);
|
||
}
|
||
|
||
function getSelectedBudgetGuestCount(groupSizeLimit = getBudgetGroupSizeLimit()) {
|
||
const parsed = Number(state.budgetGuestCount);
|
||
if (Number.isFinite(parsed) && parsed >= 1) {
|
||
return Math.min(Math.floor(parsed), groupSizeLimit);
|
||
}
|
||
|
||
return groupSizeLimit;
|
||
}
|
||
|
||
function syncBudgetGuestCount() {
|
||
const nextCount = getSelectedBudgetGuestCount(getBudgetGroupSizeLimit());
|
||
state.budgetGuestCount = nextCount;
|
||
localStorage.setItem('cabo_budget_guest_count', String(nextCount));
|
||
}
|
||
|
||
function setBudgetGuestCount(value) {
|
||
const nextCount = Number.parseInt(value, 10);
|
||
if (!Number.isFinite(nextCount) || nextCount < 1) return;
|
||
state.budgetGuestCount = Math.min(nextCount, getBudgetGroupSizeLimit());
|
||
localStorage.setItem('cabo_budget_guest_count', String(state.budgetGuestCount));
|
||
render();
|
||
}
|
||
|
||
function getDynamicBudgetScenario(tier, groupSize, groupedScenarios) {
|
||
const scenarios = groupedScenarios[tier] || [];
|
||
if (!scenarios.length) {
|
||
return null;
|
||
}
|
||
|
||
const exact = scenarios.find((scenario) => scenario.groupSize === groupSize);
|
||
if (exact) {
|
||
return exact;
|
||
}
|
||
|
||
const [lower, upper] = getBudgetBrackets(scenarios, groupSize);
|
||
const lowerSize = lower?.groupSize || upper?.groupSize || groupSize;
|
||
const upperSize = upper?.groupSize || lower?.groupSize || groupSize;
|
||
const lowerPrice = typeof lower?.perPerson === 'number' ? lower.perPerson : (typeof upper?.perPerson === 'number' ? upper.perPerson : 0);
|
||
const upperPrice = typeof upper?.perPerson === 'number' ? upper.perPerson : lowerPrice;
|
||
const ratio = lowerSize === upperSize ? 0 : (groupSize - lowerSize) / (upperSize - lowerSize);
|
||
const perPerson = Math.max(0, lowerPrice + ((upperPrice - lowerPrice) * ratio));
|
||
const roundedPerPerson = Math.round(perPerson * 100) / 100;
|
||
const sourceScenario = upper || lower || scenarios[0];
|
||
const notes = [
|
||
`Scaled from live ${tier.toLowerCase()} automations to ${groupSize} attendees.`,
|
||
...(Array.isArray(sourceScenario?.notes) ? sourceScenario.notes.slice(0, 4) : []),
|
||
];
|
||
|
||
return {
|
||
id: `live-${tier.toLowerCase()}-${groupSize}`,
|
||
tier,
|
||
groupSize,
|
||
perPerson: roundedPerPerson,
|
||
groupTotal: Math.round(roundedPerPerson * groupSize),
|
||
summary: `${groupSize} guys about ${formatCurrency(roundedPerPerson)} pp`,
|
||
notes,
|
||
derived: true,
|
||
};
|
||
}
|
||
|
||
function getBudgetBrackets(scenarios, groupSize) {
|
||
if (scenarios.length === 1) {
|
||
return [scenarios[0], scenarios[0]];
|
||
}
|
||
|
||
if (groupSize <= scenarios[0].groupSize) {
|
||
return [scenarios[0], scenarios[1]];
|
||
}
|
||
|
||
if (groupSize >= scenarios[scenarios.length - 1].groupSize) {
|
||
return [scenarios[scenarios.length - 2], scenarios[scenarios.length - 1]];
|
||
}
|
||
|
||
for (let index = 0; index < scenarios.length - 1; index += 1) {
|
||
const lower = scenarios[index];
|
||
const upper = scenarios[index + 1];
|
||
if (groupSize >= lower.groupSize && groupSize <= upper.groupSize) {
|
||
return [lower, upper];
|
||
}
|
||
}
|
||
|
||
return [scenarios[0], scenarios[1]];
|
||
}
|
||
|
||
function renderBudgetCell(scenario) {
|
||
if (!scenario) {
|
||
return '<td class="budget-cell"><div class="budget-cell-price">n/a</div></td>';
|
||
}
|
||
|
||
const note = scenario.derived
|
||
? 'Interpolated from the latest live budget anchors.'
|
||
: 'From the latest live automation run.';
|
||
|
||
return `
|
||
<td class="budget-cell">
|
||
<div class="budget-cell-label">
|
||
<span class="budget-tier-pill">${escapeHtml(scenario.tier)}</span>
|
||
<span>${escapeHtml(scenario.summary || `${scenario.groupSize} guys`)}</span>
|
||
</div>
|
||
<div class="budget-cell-price">${escapeHtml(formatCurrency(scenario.perPerson))} pp</div>
|
||
<div class="budget-cell-total">${escapeHtml(formatCurrency(scenario.groupTotal))} total</div>
|
||
<div class="budget-cell-note">${escapeHtml(note)}</div>
|
||
</td>
|
||
`;
|
||
}
|
||
|
||
function renderBudgetCard(scenario, selectedGroupSize) {
|
||
if (!scenario) {
|
||
return `
|
||
<article class="budget-card">
|
||
<div class="budget-meta">
|
||
<span>${selectedGroupSize} attendee${selectedGroupSize === 1 ? '' : 's'}</span>
|
||
<span class="budget-tier budget-tier-pill">n/a</span>
|
||
</div>
|
||
<h3>No live budget</h3>
|
||
<div class="budget-price">n/a</div>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
const note = scenario.derived
|
||
? 'Interpolated from the latest live budget anchors.'
|
||
: 'From the latest live automation run.';
|
||
|
||
return `
|
||
<article class="budget-card">
|
||
<div class="budget-meta">
|
||
<span>${selectedGroupSize} attendee${selectedGroupSize === 1 ? '' : 's'}</span>
|
||
<span class="budget-tier ${scenario.tier.toLowerCase()}">${escapeHtml(scenario.tier)}</span>
|
||
</div>
|
||
<h3>${escapeHtml(scenario.tier)} Track</h3>
|
||
<div class="budget-price">${escapeHtml(formatCurrency(scenario.perPerson))}</div>
|
||
<div class="budget-total">${escapeHtml(formatCurrency(scenario.groupTotal))} group total</div>
|
||
<div class="budget-summary">${escapeHtml(scenario.summary || '')}</div>
|
||
<ul>
|
||
${[
|
||
...(Array.isArray(scenario.notes) ? scenario.notes.slice(0, 4) : []),
|
||
note,
|
||
].filter(Boolean).map((item) => `<li>${escapeHtml(item)}</li>`).join('')}
|
||
</ul>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
// ── 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) {
|
||
showAuthModal();
|
||
return;
|
||
}
|
||
|
||
wsSend({ type: 'add_option', categoryId: catId, name, desc, url, voterName: state.voterName, authToken: state.guestAuthToken });
|
||
|
||
// 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: '© OpenStreetMap, © 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';
|
||
const originSelect = document.getElementById('flight-origin-select');
|
||
const origin = originSelect?.value || (/ont\b/i.test(q) ? 'ONT' : /lax\b/i.test(q) ? 'LAX' : 'LAX');
|
||
const depart = '2027-02-03';
|
||
const ret = '2027-02-07';
|
||
const googleFlightsQuery = `Flights from ${origin} to SJD on ${depart} to ${ret}`;
|
||
const expediaFlightUrl = `https://www.expedia.com/Flights-Search?trip=roundtrip&leg1=from:${origin},to:SJD,departure:${depart}TANYT&leg2=from:SJD,to:${origin},departure:${ret}TANYT&passengers=adults:1&options=cabinclass:economy&mode=search`;
|
||
let url;
|
||
switch(type) {
|
||
case 'gmaps': url = `https://www.google.com/maps/search/${encodeURIComponent(q)}`; break;
|
||
case 'flights':
|
||
case 'flights-google':
|
||
url = `https://www.google.com/travel/flights/search?q=${encodeURIComponent(googleFlightsQuery)}&tfpla=on`;
|
||
break;
|
||
case 'flights-kayak':
|
||
url = `https://www.kayak.com/flights/${origin}-SJD/${depart}/${ret}?sort=bestflight_a`;
|
||
break;
|
||
case 'flights-expedia':
|
||
url = expediaFlightUrl;
|
||
break;
|
||
case 'flights-united':
|
||
url = `https://www.united.com/en-us/flights?f=1&from=${origin}&to=SJD&d=${depart}&tt=${ret}&px=1`;
|
||
break;
|
||
case 'flights-delta':
|
||
url = `https://www.delta.com/flight-search/search?tripType=roundtrip&departureAirportCode=${origin}&arrivalAirportCode=SJD&departureDate=${depart}&returnDate=${ret}&adultPassengerCount=1`;
|
||
break;
|
||
case 'flights-alaska':
|
||
url = `https://www.alaskaair.com/`;
|
||
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>
|