Map: independent category toggles + Yelp dynamic search with dashed markers

This commit is contained in:
2026-04-29 15:43:32 +00:00
parent e7fa88567a
commit 0d01b83af6

View File

@@ -68,9 +68,132 @@
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.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-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 */
#map-search-wrap {
display: flex;
align-items: center;
gap: 6px;
pointer-events: all;
background: rgba(19,22,31,0.95);
border: 1px solid #252a38;
border-radius: 8px;
display: flex;
align-items: center;
padding: 0;
max-width: 360px;
backdrop-filter: blur(8px);
overflow: hidden;
}
#map-search-wrap:focus-within {
border-color: var(--accent);
}
#map-search-input {
flex: 1;
background: transparent;
border: none;
color: #e0e6f0;
font-size: 0.82rem;
outline: none;
padding: 8px 10px;
}
#map-search-input::placeholder { color: #7a8499; }
.yelp-search-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
background: rgba(255,107,53,0.15);
border: none;
border-left: 1px solid #252a38;
color: #ff6b35;
font-size: 0.68rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.yelp-search-btn:hover { background: rgba(255,107,53,0.25); }
.yelp-search-btn.searching {
opacity: 0.7;
cursor: wait;
}
/* 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;
@@ -849,18 +972,25 @@
<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>
<!-- Row 1: Search + Yelp -->
<div id="map-search-wrap">
<span style="color:#7a8499;font-size:0.8rem;padding-left:8px;">🔍</span>
<input type="text" id="map-search-input" placeholder="Search venues in Los Cabos…" autocomplete="off" />
<div id="map-search-results"></div>
<button class="yelp-search-btn" id="yelp-search-btn" onclick="mapYelpSearch()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="#ff6b35"><path d="M12 2C8.13 2 5 5.13 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h1v1c0 .55.45 1 1 1h1.5c.28 0 .5-.22.5-.5v-1h1c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.87-3.13-7-7-7zm0 2c2.76 0 5 2.24 5 5 0 1.65-.8 3.1-2 3.92V14h-1v1.5c0 .28-.22.5-.5.5H13v1h-.5c-.28 0-.5-.22-.5-.5V13H11v-.08C9.8 10.1 9 8.65 9 7c0-2.76 2.24-5 5-5z"/></svg>
Yelp
</button>
</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>
<!-- 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">
@@ -870,6 +1000,7 @@
<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 results</div>
</div>
</div>
@@ -1287,12 +1418,14 @@
let mapInstance = null;
let mapInitialized = false;
let mapMarkers = {};
let mapActiveCategory = null;
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([23.065, -109.698], 12);
mapInstance = L.map('cabo-map', { zoomControl: true }).setView([CABOS_CENTER.lat, CABOS_CENTER.lng], 12);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OpenStreetMap, &copy; CARTO',
maxZoom: 18
@@ -1313,11 +1446,11 @@
searchInput.addEventListener('keydown', e => {
if (e.key === 'Enter') {
const q = searchInput.value.trim();
if (q) { mapDoOSMSearch(q); searchResults.classList.remove('show'); }
if (q) { mapYelpSearch(q); searchResults.classList.remove('show'); }
}
});
document.addEventListener('click', e => {
if (!e.target.closest('.map-search-wrap')) searchResults.classList.remove('show');
if (!e.target.closest('#map-search-wrap')) searchResults.classList.remove('show');
});
mapRefreshMarkers();
@@ -1332,10 +1465,19 @@
});
}
// 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 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);
@@ -1355,24 +1497,44 @@
</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 markers
// Remove existing venue 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);
// 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)
);
filtered.forEach(opt => {
const catEmoji = { hotel: '🏨', golf: '⛳', nightlife: '🎧', excursion: '🚤', itinerary: '🗺️' };
visible.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 })
@@ -1381,25 +1543,147 @@
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] });
// 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 });
}
}
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');
// ── Category filter toggles ───────────────────────────────
const ALL_CATS = ['hotel', 'golf', 'nightlife', 'excursion', 'itinerary'];
function mapToggleCat(catId) {
if (mapHiddenCats.has(catId)) {
mapHiddenCats.delete(catId);
} else {
document.querySelector(`.map-cat-btn[onclick="mapFilterCat('${catId}')"]`).classList.add('active');
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');
}
}
});
}
// ── External / Yelp search ────────────────────────────────
function mapClearExtMarkers() {
mapExtMarkers.forEach(m => mapInstance.removeLayer(m));
mapExtMarkers = [];
}
async function mapYelpSearch(q) {
const input = document.getElementById('map-search-input');
const btn = document.getElementById('yelp-search-btn');
const results = document.getElementById('map-search-results');
q = (q || input.value || '').trim();
if (!q) return;
btn.classList.add('searching');
btn.textContent = '...';
results.innerHTML = '<div class="msr-item" style="color:#ff6b35;">Searching Yelp...</div>';
results.classList.add('show');
try {
// Call our backend proxy (keeps API key server-side)
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 found. Try a different search.</div>';
} else {
// Build search results dropdown
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('');
// Store for later reference
window._mapYelpResults = data.businesses;
// Show all results as dashed markers on map
data.businesses.forEach((b, i) => {
if (!b.coordinates || !b.coordinates.latitude || !b.coordinates.longitude) return;
if (!b.location?.city?.toLowerCase().includes('cabo') &&
!b.location?.state?.includes('BS') &&
!b.location?.state?.includes('Baja')) return; // Only show results near Los Cabos
const emoji = '🍴';
const icon = mapMakeExtIcon('#ff6b35', emoji, 30);
const marker = L.marker([b.coordinates.latitude, b.coordinates.longitude], { icon })
.addTo(mapInstance)
.bindPopup(mapMakeExtPopup(b), { className: 'map-popup-wrapper' });
mapExtMarkers.push(marker);
});
// Fit map to show all results
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 search failed. Try again.</div>';
} finally {
btn.classList.remove('searching');
btn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="#ff6b35"><path d="M12 2C8.13 2 5 5.13 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h1v1c0 .55.45 1 1 1h1.5c.28 0 .5-.22.5-.5v-1h1c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.87-3.13-7-7-7zm0 2c2.76 0 5 2.24 5 5 0 1.65-.8 3.1-2 3.92V14h-1v1.5c0 .28-.22.5-.5.5H13v1h-.5c-.28 0-.5-.22-.5-.5V13H11v-.08C9.8 10.1 9 8.65 9 7c0-2.76 2.24-5 5-5z"/></svg> Yelp`;
}
}
function mapShowExtResult(index) {
const businesses = window._mapYelpResults || [];
const b = businesses[index];
if (!b || !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 });
// Find and open the right marker
if (mapExtMarkers[index]) {
mapExtMarkers[index].openPopup();
}
}
// ── Local venue search (dropdown) ────────────────────────
async function mapDoSearch(q) {
const results = document.getElementById('map-search-results');
results.innerHTML = '<div class="msr-item" style="color:#7a8499;">Searching…</div>';
@@ -1408,6 +1692,7 @@
// Local in-app venue match
const localMatches = state.options.filter(o =>
o.approved && o.lat && o.lng &&
!mapHiddenCats.has(o.categoryId) &&
(o.name.toLowerCase().includes(q.toLowerCase()) ||
(o.desc && o.desc.toLowerCase().includes(q.toLowerCase())))
).slice(0, 5);
@@ -1415,6 +1700,7 @@
let html = '';
if (localMatches.length > 0) {
const catColors = { hotel: '#3b82f6', golf: '#22c55e', nightlife: '#a855f7', excursion: '#06b6d4', itinerary: '#fbbf24' };
html += '<div class="msr-item" style="color:#7a8499;font-size:0.65rem;padding:4px 10px;">Our venues</div>';
html += localMatches.map(opt => {
const color = opt.categoryColor || catColors[opt.categoryId] || '#888';
return `<div class="msr-item" onclick="mapFlyTo('${opt.id}')">
@@ -1428,7 +1714,7 @@
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>';
html += '<div class="msr-item" style="color:#7a8499;font-size:0.65rem;padding:4px 10px;">Web 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]}
@@ -1437,7 +1723,7 @@
}
} catch(e) {}
if (!html) html = '<div class="msr-item" style="color:#7a8499;">No results found</div>';
if (!html) html = '<div class="msr-item" style="color:#7a8499;">No results — try the Yelp button for full search</div>';
results.innerHTML = html;
}
@@ -1454,24 +1740,14 @@
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);
icon: mapMakeExtIcon('#fbbf24', '📍', 28)
}).addTo(mapInstance).bindPopup(`<div class="map-popup"><h4 style="color:#fbbf24;">📍 ${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" rel="noopener noreferrer" class="mp-link">📍 Open in Google Maps →</a></div>`).openPopup();
// Track as external so it doesn't clutter venue markers
mapExtMarkers.push(osmMarker);
}
// ── WebSocket vote update — refresh map markers ──────────────
const _originalWsHandler = null; // will be overridden in wsMessage handler
// ── Kick off ──────────────────────────────────────────────
init();