193 lines
9.3 KiB
JavaScript
193 lines
9.3 KiB
JavaScript
// State
|
|
let currentResults = [];
|
|
|
|
// DOM
|
|
const searchForm = document.getElementById('searchForm');
|
|
const searchBtn = document.getElementById('searchBtn');
|
|
const loadingState = document.getElementById('loadingState');
|
|
const errorState = document.getElementById('errorState');
|
|
const emptyState = document.getElementById('emptyState');
|
|
const resultsArea = document.getElementById('resultsArea');
|
|
const resultsBody = document.getElementById('resultsBody');
|
|
const resultsQuery = document.getElementById('resultsQuery');
|
|
const resultsCount = document.getElementById('resultsCount');
|
|
const detailPanel = document.getElementById('detailPanel');
|
|
const recentSearches = document.getElementById('recentSearches');
|
|
|
|
function showState(state) {
|
|
[loadingState, errorState, emptyState, resultsArea].forEach(el => el?.classList.add('hidden'));
|
|
if (recentSearches) recentSearches.classList.toggle('hidden', state !== 'empty');
|
|
state === 'loading' && loadingState?.classList.remove('hidden');
|
|
state === 'error' && errorState?.classList.remove('hidden');
|
|
state === 'empty' && emptyState?.classList.remove('hidden');
|
|
state === 'results' && resultsArea?.classList.remove('hidden');
|
|
}
|
|
|
|
// Search
|
|
searchForm?.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(searchForm);
|
|
const data = Object.fromEntries(fd);
|
|
|
|
showState('loading');
|
|
searchBtn.disabled = true;
|
|
searchBtn.innerHTML = '<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg> Searching...';
|
|
|
|
try {
|
|
const res = await fetch('/api/search', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
});
|
|
const json = await res.json();
|
|
if (!res.ok) throw new Error(json.error || 'Search failed');
|
|
|
|
currentResults = json.results;
|
|
renderResults(json.results, json.query);
|
|
} catch (err) {
|
|
document.getElementById('errorMsg').textContent = err.message;
|
|
showState('error');
|
|
} finally {
|
|
searchBtn.disabled = false;
|
|
searchBtn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Search';
|
|
}
|
|
});
|
|
|
|
function renderResults(results, query) {
|
|
resultsQuery.textContent = query;
|
|
resultsCount.textContent = results.length;
|
|
resultsBody.innerHTML = '';
|
|
|
|
results.forEach((r, i) => {
|
|
const tr = document.createElement('tr');
|
|
tr.className = 'hover:bg-card-hover transition-colors cursor-pointer';
|
|
tr.onclick = () => showDetail(r);
|
|
tr.innerHTML = `
|
|
<td class="px-4 py-3">
|
|
<button onclick="event.stopPropagation(); toggleBookmark('${r.placeId}', ${i})"
|
|
class="bookmark-btn text-gray-500 hover:text-accent transition-colors" data-place="${r.placeId}">
|
|
${r.isBookmarked
|
|
? '<svg class="w-4 h-4 text-accent fill-accent" viewBox="0 0 24 24"><path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>'
|
|
: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>'}
|
|
</button>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<p class="font-medium text-white">${esc(r.title)}</p>
|
|
<p class="text-xs text-gray-500">${esc(r.categoryName || r.address || '')}</p>
|
|
</td>
|
|
<td class="px-4 py-3 text-gray-300">${esc(r.phone) || '—'}</td>
|
|
<td class="px-4 py-3 text-gray-300">${esc(r.email) || '—'}</td>
|
|
<td class="px-4 py-3">${r.website
|
|
? `<a href="${esc(r.website)}" target="_blank" onclick="event.stopPropagation()" class="text-accent hover:underline truncate block max-w-[180px]">${esc(r.website.replace(/^https?:\/\//, '').replace(/\/$/, ''))}</a>`
|
|
: '—'}</td>
|
|
<td class="px-4 py-3">${r.totalScore ? `<span class="text-yellow-400">★</span> ${r.totalScore.toFixed(1)}` : '—'}</td>
|
|
<td class="px-4 py-3 text-gray-400">${r.reviewsCount || '—'}</td>
|
|
`;
|
|
resultsBody.appendChild(tr);
|
|
});
|
|
|
|
showState('results');
|
|
}
|
|
|
|
function showDetail(place) {
|
|
detailPanel.innerHTML = `
|
|
<div class="slide-in">
|
|
<div class="p-4 border-b border-white/10 flex items-center justify-between sticky top-0 bg-sidebar z-10">
|
|
<h3 class="font-bold text-white truncate">${esc(place.title)}</h3>
|
|
<button onclick="detailPanel.classList.add('hidden')" class="text-gray-400 hover:text-white">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
${place.imageUrl ? `<img src="${esc(place.imageUrl)}" class="w-full h-48 object-cover" onerror="this.remove()">` : ''}
|
|
<div class="p-4 space-y-4">
|
|
<button onclick="toggleBookmarkFromPanel('${place.placeId}')"
|
|
class="w-full py-2 rounded-lg text-sm font-medium transition-colors panel-bookmark-btn
|
|
${place.isBookmarked ? 'bg-accent text-white' : 'bg-accent/20 text-accent hover:bg-accent/30'}" data-place="${place.placeId}">
|
|
${place.isBookmarked ? '★ Saved as Lead' : '☆ Save as Lead'}
|
|
</button>
|
|
|
|
<div class="space-y-3 text-sm">
|
|
${place.categoryName ? `<div><span class="text-gray-500">Category</span><p class="text-white">${esc(place.categoryName)}</p></div>` : ''}
|
|
${place.address ? `<div><span class="text-gray-500">Address</span><p class="text-white">${esc(place.address)}</p></div>` : ''}
|
|
${place.phone ? `<div><span class="text-gray-500">Phone</span><p class="text-white"><a href="tel:${esc(place.phone)}" class="text-accent hover:underline">${esc(place.phone)}</a></p></div>` : ''}
|
|
${place.email ? `<div><span class="text-gray-500">Email</span><p class="text-white"><a href="mailto:${esc(place.email)}" class="text-accent hover:underline">${esc(place.email)}</a></p></div>` : ''}
|
|
${place.website ? `<div><span class="text-gray-500">Website</span><p><a href="${esc(place.website)}" target="_blank" class="text-accent hover:underline break-all">${esc(place.website)}</a></p></div>` : ''}
|
|
${place.totalScore ? `<div><span class="text-gray-500">Rating</span><p class="text-white"><span class="text-yellow-400">★</span> ${place.totalScore.toFixed(1)} <span class="text-gray-500">(${place.reviewsCount} reviews)</span></p></div>` : ''}
|
|
${place.url ? `<div><a href="${esc(place.url)}" target="_blank" class="text-accent hover:underline text-xs">View on Google Maps →</a></div>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
detailPanel.classList.remove('hidden');
|
|
}
|
|
|
|
async function toggleBookmark(placeId, index) {
|
|
const place = currentResults[index];
|
|
if (!place) return;
|
|
|
|
if (place.isBookmarked) {
|
|
await fetch('/api/leads/' + encodeURIComponent(placeId), { method: 'DELETE' });
|
|
place.isBookmarked = false;
|
|
} else {
|
|
await fetch('/api/leads', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ place }),
|
|
});
|
|
place.isBookmarked = true;
|
|
}
|
|
updateBookmarkUI(placeId, place.isBookmarked);
|
|
}
|
|
|
|
async function toggleBookmarkFromPanel(placeId) {
|
|
const place = currentResults.find(r => r.placeId === placeId);
|
|
if (!place) return;
|
|
const index = currentResults.indexOf(place);
|
|
await toggleBookmark(placeId, index);
|
|
}
|
|
|
|
function updateBookmarkUI(placeId, isBookmarked) {
|
|
// Update table button
|
|
document.querySelectorAll(`.bookmark-btn[data-place="${placeId}"]`).forEach(btn => {
|
|
btn.innerHTML = isBookmarked
|
|
? '<svg class="w-4 h-4 text-accent fill-accent" viewBox="0 0 24 24"><path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>'
|
|
: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>';
|
|
});
|
|
// Update panel button
|
|
document.querySelectorAll(`.panel-bookmark-btn[data-place="${placeId}"]`).forEach(btn => {
|
|
btn.className = `w-full py-2 rounded-lg text-sm font-medium transition-colors panel-bookmark-btn ${isBookmarked ? 'bg-accent text-white' : 'bg-accent/20 text-accent hover:bg-accent/30'}`;
|
|
btn.textContent = isBookmarked ? '★ Saved as Lead' : '☆ Save as Lead';
|
|
});
|
|
}
|
|
|
|
// Load saved search
|
|
async function loadSearch(key) {
|
|
showState('loading');
|
|
try {
|
|
const res = await fetch('/api/search/' + encodeURIComponent(key));
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error);
|
|
currentResults = data.results || [];
|
|
renderResults(currentResults, data.query);
|
|
} catch (err) {
|
|
document.getElementById('errorMsg').textContent = err.message;
|
|
showState('error');
|
|
}
|
|
}
|
|
|
|
// Check URL for view param
|
|
const params = new URLSearchParams(window.location.search);
|
|
if (params.get('view')) loadSearch(params.get('view'));
|
|
|
|
// Close detail panel on Escape
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') detailPanel?.classList.add('hidden');
|
|
});
|
|
|
|
function esc(str) {
|
|
if (!str) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|