Initial commit: Lead Scraper - Google Maps

This commit is contained in:
Mambo
2026-02-11 01:48:37 +01:00
commit 831d63b7e8
13 changed files with 2406 additions and 0 deletions

192
public/js/app.js Normal file
View File

@@ -0,0 +1,192 @@
// 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;
}