[cabo-voting-app] Multi-provider map search: Yelp/OSM/All tabs + quick-book aggregators + legend update

This commit is contained in:
Hermes Agent
2026-04-29 11:05:16 -07:00
parent 47acadc88c
commit 88ee723981

View File

@@ -109,54 +109,91 @@
}
/* Yelp search button */
/* Multi-provider search bar */
#map-search-wrap {
display: flex;
align-items: center;
gap: 6px;
gap: 0;
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;
max-width: 480px;
backdrop-filter: blur(8px);
overflow: hidden;
flex-shrink: 0;
}
#map-search-wrap:focus-within {
border-color: var(--accent);
}
#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;
outline: none;
min-width: 120px;
}
#map-search-input::placeholder { color: #7a8499; }
.yelp-search-btn {
.provider-tabs {
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;
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: background 0.15s;
transition: all 0.15s;
border-right: 1px solid #252a38;
letter-spacing: 0.2px;
}
.yelp-search-btn:hover { background: rgba(255,107,53,0.25); }
.yelp-search-btn.searching {
opacity: 0.7;
cursor: wait;
.provider-tab:last-child { border-right: none; }
.provider-tab:hover { color: #ccc; background: rgba(255,255,255,0.04); }
.provider-tab.active-yelp { color: #ff6b35; background: rgba(255,107,53,0.12); }
.provider-tab.active-osm { color: #fbbf24; background: rgba(251,191,36,0.10); }
.provider-tab.active-all { color: #00d4ff; background: rgba(0,212,255,0.08); }
#map-search-btn {
border: none;
background: transparent;
color: #00d4ff;
cursor: pointer;
padding: 6px 10px;
font-size: 0.9rem;
border-left: 1px solid #252a38;
flex-shrink: 0;
}
#map-search-btn:hover { background: rgba(0,212,255,0.06); }
/* Quick-book mini bar */
.map-quick-book {
display: flex;
align-items: center;
gap: 3px;
flex-wrap: wrap;
}
.qb-label { color: #444; font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; margin-right: 2px; }
.qb-btn {
display: flex; align-items: center; gap: 2px;
padding: 2px 5px;
border: 1px solid #252a38;
background: rgba(255,255,255,0.03);
color: #555;
border-radius: 3px;
font-size: 0.6rem; font-weight: 600;
cursor: pointer; white-space: nowrap; text-decoration: none;
transition: all 0.15s;
}
.qb-btn:hover { border-color: #444; color: #ccc; background: rgba(255,255,255,0.07); }
/* Legend: multi-provider dots */
.legend-dot-osm { background: #fbbf24; width: 9px; height: 9px; border-radius: 50%; display: inline-block; margin-right: 4px; border: 1px solid rgba(0,0,0,0.3); }
.legend-dot-all { background: conic-gradient(#ff6b35 33%, #fbbf24 33%, #fbbf24 66%, #00d4ff 66%); }
/* Filter bar layout */
.map-overlay {
@@ -972,16 +1009,18 @@
<div id="map-view" style="display:none;">
<div id="cabo-map"></div>
<div class="map-overlay">
<!-- Row 1: Search + Yelp -->
<!-- Row 1: Multi-provider search -->
<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>
<span style="color:#7a8499;font-size:0.8rem;padding-left:8px;flex-shrink:0;">🔍</span>
<input type="text" id="map-search-input" placeholder="Search Los Cabos…" autocomplete="off" />
<div class="provider-tabs">
<button class="provider-tab active-yelp" id="tab-yelp" onclick="setProvider('yelp')">🍴 Yelp</button>
<button class="provider-tab" id="tab-osm" onclick="setProvider('osm')">📍 OSM</button>
<button class="provider-tab" id="tab-all" onclick="setProvider('all')">⚡ All</button>
</div>
<button id="map-search-btn" onclick="mapDoSearch()"></button>
</div>
<div id="map-search-results"></div>
<!-- Row 2: Category filters -->
<div class="map-filter-row">
<span class="row-label">Show</span>
@@ -1000,7 +1039,9 @@
<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 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>
@@ -1441,12 +1482,12 @@
const q = searchInput.value.trim();
if (q.length < 2) { searchResults.classList.remove('show'); return; }
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => mapDoSearch(q), 400);
searchTimeout = setTimeout(() => mapDoSearch(), 400);
});
searchInput.addEventListener('keydown', e => {
if (e.key === 'Enter') {
const q = searchInput.value.trim();
if (q) { mapYelpSearch(q); searchResults.classList.remove('show'); }
if (q) { mapDoSearch(); searchResults.classList.remove('show'); }
}
});
document.addEventListener('click', e => {
@@ -1591,7 +1632,31 @@
});
}
// ── External / Yelp search ───────────────────────────────
// ── Multi-provider search ───────────────────────────────
let currentProvider = 'yelp'; // 'yelp' | 'osm' | 'all'
function setProvider(p) {
currentProvider = p;
document.getElementById('tab-yelp').className = 'provider-tab' + (p === 'yelp' ? ' active-yelp' : '');
document.getElementById('tab-osm').className = 'provider-tab' + (p === 'osm' ? ' active-osm' : '');
document.getElementById('tab-all').className = 'provider-tab' + (p === 'all' ? ' active-all' : '');
}
function quickBook(type) {
const q = (document.getElementById('map-search-input').value || '').trim() || 'Los Cabos Mexico';
let url;
switch(type) {
case 'gmaps': url = `https://www.google.com/maps/search/${encodeURIComponent(q)}`; break;
case 'flights': url = `https://www.google.com/travel/flights/search?q=${encodeURIComponent(q)}&tfpla=on`; break;
case 'hotels': url = `https://www.kayak.com/hotels/${encodeURIComponent(q)}/2admins`; break;
case 'viator': url = `https://www.viator.com/search/${encodeURIComponent(q)}`; break;
case 'expedia': url = `https://www.expedia.com/Thotel-Search?destination=${encodeURIComponent(q)}`; break;
case 'tripadvisor': url = `https://www.tripadvisor.com/Search?q=${encodeURIComponent(q)}`; break;
default: return;
}
window.open(url, '_blank', 'noopener,noreferrer');
}
function mapClearExtMarkers() {
mapExtMarkers.forEach(m => mapInstance.removeLayer(m));
mapExtMarkers = [];
@@ -1599,61 +1664,48 @@
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.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>';
results.innerHTML = '<div class="msr-item" style="color:#7a8499;">No Yelp results. Try OSM or All.</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>
<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
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 emoji = '🍴';
const icon = mapMakeExtIcon('#ff6b35', emoji, 30);
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);
});
// Fit map to show all results
if (mapExtMarkers.length > 0) {
const group = L.featureGroup(mapExtMarkers);
mapInstance.fitBounds(group.getBounds(), { padding: [50, 50], maxZoom: 13 });
@@ -1661,92 +1713,159 @@
}
} 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`;
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 || !b.coordinates) return;
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 });
// Find and open the right marker
if (mapExtMarkers[index]) {
mapExtMarkers[index].openPopup();
}
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>';
results.classList.add('show');
// 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);
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}')">
<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) {
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]}
</div>`;
});
}
} catch(e) {}
if (!html) html = '<div class="msr-item" style="color:#7a8499;">No results — try the Yelp button for full search</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) {
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([lat, lon], 15, { duration: 1.0 });
const osmMarker = L.marker([lat, lon], {
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);
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 ──────────────────────────────────────────────