Add unified Map tab with Leaflet, CARTO dark tiles, live vote counts, and venue search

This commit is contained in:
2026-04-29 14:57:04 +00:00
parent f47dac1e41
commit e7fa88567a

View File

@@ -3,8 +3,159 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cabo Bachelor Party — Vote</title> <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> <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;
}
.map-cat-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(0,212,255,0.1); }
.map-cat-btn:hover { border-color: #7a8499; color: #e0e6f0; }
.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 ─────────────────────────────────────────── */ /* ── Variables ─────────────────────────────────────────── */
:root { :root {
--bg: #0b0d14; --bg: #0b0d14;
@@ -694,6 +845,34 @@
<div class="empty-state"><div class="empty-emoji"></div>Loading options…</div> <div class="empty-state"><div class="empty-emoji"></div>Loading options…</div>
</div> </div>
<!-- Map view -->
<div id="map-view" style="display:none;">
<div id="cabo-map"></div>
<div class="map-overlay">
<div class="map-search-wrap">
<span style="color:#7a8499;font-size:0.8rem;">🔍</span>
<input type="text" id="map-search-input" placeholder="Search venues in Los Cabos…" autocomplete="off" />
<div id="map-search-results"></div>
</div>
<div class="map-cat-filters" id="map-cat-filters">
<button class="map-cat-btn active" onclick="mapFilterCat(null)">All</button>
<button class="map-cat-btn" onclick="mapFilterCat('hotel')">🏨 Hotels</button>
<button class="map-cat-btn" onclick="mapFilterCat('golf')">⛳ Golf</button>
<button class="map-cat-btn" onclick="mapFilterCat('nightlife')">🎧 Nightlife</button>
<button class="map-cat-btn" onclick="mapFilterCat('excursion')">🚤 Excursions</button>
<button class="map-cat-btn" onclick="mapFilterCat('itinerary')">🗺️ Itineraries</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>
</div>
<!-- Add option --> <!-- Add option -->
<div class="add-section"> <div class="add-section">
<h3> Suggest a Place</h3> <h3> Suggest a Place</h3>
@@ -822,16 +1001,19 @@
if (opt) { opt.votes = r.votes; opt.voters = r.voters; } if (opt) { opt.votes = r.votes; opt.voters = r.voters; }
}); });
render(); render();
if (mapInitialized) mapRefreshMarkers();
} else if (msg.type === 'option_added' || msg.type === 'option_approved') { } else if (msg.type === 'option_added' || msg.type === 'option_approved') {
if (!state.options.find(o => o.id === msg.option.id)) { if (!state.options.find(o => o.id === msg.option.id)) {
state.options.push(msg.option); state.options.push(msg.option);
renderTabs(); renderTabs();
render(); render();
if (mapInitialized) mapRefreshMarkers();
} }
} else if (msg.type === 'option_deleted') { } else if (msg.type === 'option_deleted') {
state.options = state.options.filter(o => o.id !== msg.id); state.options = state.options.filter(o => o.id !== msg.id);
renderTabs(); renderTabs();
render(); render();
if (mapInitialized) mapRefreshMarkers();
} else if (msg.type === 'polls_status') { } else if (msg.type === 'polls_status') {
state.pollsOpen = msg.open; state.pollsOpen = msg.open;
updatePollsBadge(); updatePollsBadge();
@@ -898,7 +1080,8 @@
// ── Tabs ─────────────────────────────────────────────────── // ── Tabs ───────────────────────────────────────────────────
function renderTabs() { function renderTabs() {
const bar = document.getElementById('tabsBar'); const bar = document.getElementById('tabsBar');
bar.innerHTML = state.categories.map(cat => ` const catsWithMap = [...state.categories, { id: 'map', name: 'Map', emoji: '🗺️' }];
bar.innerHTML = catsWithMap.map(cat => `
<div class="tab${cat.id === activeTab ? ' active' : ''}" <div class="tab${cat.id === activeTab ? ' active' : ''}"
role="tab" role="tab"
id="tab-${cat.id}" id="tab-${cat.id}"
@@ -909,7 +1092,7 @@
onkeydown="handleTabKey(event, '${cat.id}')"> onkeydown="handleTabKey(event, '${cat.id}')">
<span class="tab-emoji">${cat.emoji}</span> <span class="tab-emoji">${cat.emoji}</span>
${cat.name} ${cat.name}
<span class="tab-count" id="tab-count-${cat.id}">${cat.id === 'results' ? '' : state.options.filter(o => o.categoryId === cat.id).length}</span> <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> </div>
`).join(''); `).join('');
bar.setAttribute('role', 'tablist'); bar.setAttribute('role', 'tablist');
@@ -917,7 +1100,7 @@
} }
function handleTabKey(event, catId) { function handleTabKey(event, catId) {
const cats = state.categories.map(c => c.id); const cats = [...state.categories.map(c => c.id), 'map'];
const idx = cats.indexOf(catId); const idx = cats.indexOf(catId);
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
event.preventDefault(); event.preventDefault();
@@ -936,6 +1119,20 @@
activeTab = id; activeTab = id;
renderTabs(); renderTabs();
render(); 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'); document.getElementById('optionsList')?.setAttribute('aria-label', state.categories.find(c => c.id === id)?.name + ' options');
} }
@@ -1086,6 +1283,196 @@
setTimeout(() => toast.classList.remove('show'), 3000); setTimeout(() => toast.classList.remove('show'), 3000);
} }
// ── Map ───────────────────────────────────────────────────
let mapInstance = null;
let mapInitialized = false;
let mapMarkers = {};
let mapActiveCategory = null;
const CABOS_BBOX = '-109.85,22.85,-109.55,23.25';
function initMap() {
mapInitialized = true;
mapInstance = L.map('cabo-map', { zoomControl: true }).setView([23.065, -109.698], 12);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OpenStreetMap, &copy; CARTO',
maxZoom: 18
}).addTo(mapInstance);
mapInstance.zoomControl.setPosition('bottomright');
// Map search input
const searchInput = document.getElementById('map-search-input');
const searchResults = document.getElementById('map-search-results');
let searchTimeout;
searchInput.addEventListener('input', () => {
const q = searchInput.value.trim();
if (q.length < 2) { searchResults.classList.remove('show'); return; }
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => mapDoSearch(q), 400);
});
searchInput.addEventListener('keydown', e => {
if (e.key === 'Enter') {
const q = searchInput.value.trim();
if (q) { mapDoOSMSearch(q); 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]
});
}
function mapMakePopup(opt) {
const catColors = { hotel: '#3b82f6', golf: '#22c55e', nightlife: '#a855f7', excursion: '#06b6d4', itinerary: '#fbbf24' };
const catEmoji = { hotel: '🏨', golf: '⛳', nightlife: '🎧', excursion: '🚤', itinerary: '🗺️' };
const catNames = { hotel: 'Hotel', golf: 'Golf', nightlife: 'Nightlife', excursion: 'Excursion', itinerary: '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 toggleVoteFromMap(optionId) {
if (activeTab !== 'map') setTab('map');
toggleVote(optionId);
}
function mapRefreshMarkers() {
if (!mapInstance) return;
// Remove existing markers
Object.values(mapMarkers).forEach(m => mapInstance.removeLayer(m));
mapMarkers = {};
const filtered = mapActiveCategory
? state.options.filter(o => o.approved && o.categoryId === mapActiveCategory && o.lat && o.lng)
: state.options.filter(o => o.approved && o.lat && o.lng);
filtered.forEach(opt => {
const color = opt.categoryColor || '#888';
const catEmoji = { hotel: '🏨', golf: '⛳', nightlife: '🎧', excursion: '🚤', itinerary: '🗺️' };
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 if we have markers
const markerList = Object.values(mapMarkers);
if (markerList.length > 0) {
const group = L.featureGroup(markerList);
mapInstance.fitBounds(group.getBounds(), { padding: [40, 40] });
}
}
function mapFilterCat(catId) {
mapActiveCategory = catId;
document.querySelectorAll('.map-cat-btn').forEach(b => b.classList.remove('active'));
if (catId === null) {
document.querySelector('.map-cat-btn[onclick="mapFilterCat(null)"]').classList.add('active');
} else {
document.querySelector(`.map-cat-btn[onclick="mapFilterCat('${catId}')"]`).classList.add('active');
}
mapRefreshMarkers();
}
async function mapDoSearch(q) {
const results = document.getElementById('map-search-results');
results.innerHTML = '<div class="msr-item" style="color:#7a8499;">Searching…</div>';
results.classList.add('show');
// Local in-app venue match
const localMatches = state.options.filter(o =>
o.approved && o.lat && o.lng &&
(o.name.toLowerCase().includes(q.toLowerCase()) ||
(o.desc && o.desc.toLowerCase().includes(q.toLowerCase())))
).slice(0, 5);
let html = '';
if (localMatches.length > 0) {
const catColors = { hotel: '#3b82f6', golf: '#22c55e', nightlife: '#a855f7', excursion: '#06b6d4', itinerary: '#fbbf24' };
html += localMatches.map(opt => {
const color = opt.categoryColor || catColors[opt.categoryId] || '#888';
return `<div class="msr-item" onclick="mapFlyTo('${opt.id}')">
<span class="msr-cat" style="background:${color}20;color:${color}">${opt.categoryId}</span>
${opt.name} <span style="color:#7a8499;font-size:0.65rem;">(${opt.votes.length} votes)</span>
</div>`;
}).join('');
}
// OSM search
try {
const res = await 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(() => []);
if (res && res.length > 0) {
if (html) html += '<div class="msr-item" style="color:#7a8499;font-size:0.65rem;padding:4px 10px;">OSM results</div>';
res.forEach(r => {
html += `<div class="msr-item" onclick="mapFlyToOSM(${r.lat},${r.lon},'${r.display_name.replace(/'/g,"\\'")}')">
📍 ${r.display_name.split(',')[0]}
</div>`;
});
}
} catch(e) {}
if (!html) html = '<div class="msr-item" style="color:#7a8499;">No results found</div>';
results.innerHTML = html;
}
function mapFlyTo(optionId) {
const opt = state.options.find(o => o.id === optionId);
if (!opt || !mapMarkers[optionId]) return;
document.getElementById('map-search-results').classList.remove('show');
document.getElementById('map-search-input').value = opt.name;
mapMarkers[optionId].openPopup();
mapInstance.flyTo([opt.lat, opt.lng], 15, { duration: 1.0 });
}
function mapFlyToOSM(lat, lon, name) {
document.getElementById('map-search-results').classList.remove('show');
document.getElementById('map-search-input').value = name.split(',')[0];
mapInstance.flyTo([lat, lon], 15, { duration: 1.0 });
// Add temporary OSM marker
const osmMarker = L.marker([lat, lon], {
icon: mapMakeIcon('#fbbf24', '📍', 32)
}).addTo(mapInstance).bindPopup(`<div class="map-popup"><h4>📍 ${name.split(',')[0]}</h4><p style="color:#7a8499;font-size:0.7rem;">${name.split(',').slice(1,3).join(',').trim()}</p><a href="https://www.google.com/maps?q=${lat},${lon}" target="_blank" class="mp-link">📍 Open in Google Maps →</a></div>`).openPopup();
setTimeout(() => mapInstance.removeLayer(osmMarker), 8000);
}
function mapDoOSMSearch(q) {
mapFlyToOSM = (lat, lon, name) => {
document.getElementById('map-search-results').classList.remove('show');
document.getElementById('map-search-input').value = name.split(',')[0];
mapInstance.flyTo([lat, lon], 15, { duration: 1.0 });
};
mapDoSearch(q);
}
// ── WebSocket vote update — refresh map markers ──────────────
const _originalWsHandler = null; // will be overridden in wsMessage handler
// ── Kick off ────────────────────────────────────────────── // ── Kick off ──────────────────────────────────────────────
init(); init();
</script> </script>