Add unified Map tab with Leaflet, CARTO dark tiles, live vote counts, and venue search
This commit is contained in:
@@ -3,8 +3,159 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
/* ── 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 ─────────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #0b0d14;
|
||||
@@ -694,6 +845,34 @@
|
||||
<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">
|
||||
<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 -->
|
||||
<div class="add-section">
|
||||
<h3>➕ Suggest a Place</h3>
|
||||
@@ -822,16 +1001,19 @@
|
||||
if (opt) { opt.votes = r.votes; opt.voters = r.voters; }
|
||||
});
|
||||
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();
|
||||
@@ -898,7 +1080,8 @@
|
||||
// ── Tabs ───────────────────────────────────────────────────
|
||||
function renderTabs() {
|
||||
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' : ''}"
|
||||
role="tab"
|
||||
id="tab-${cat.id}"
|
||||
@@ -909,7 +1092,7 @@
|
||||
onkeydown="handleTabKey(event, '${cat.id}')">
|
||||
<span class="tab-emoji">${cat.emoji}</span>
|
||||
${cat.name}
|
||||
<span class="tab-count" id="tab-count-${cat.id}">${cat.id === 'results' ? '' : state.options.filter(o => o.categoryId === cat.id).length}</span>
|
||||
<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');
|
||||
@@ -917,7 +1100,7 @@
|
||||
}
|
||||
|
||||
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);
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
@@ -936,6 +1119,20 @@
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -1086,6 +1283,196 @@
|
||||
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: '© 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(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 ──────────────────────────────────────────────
|
||||
init();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user