Initial commit: Lead Scraper - Google Maps
This commit is contained in:
192
public/js/app.js
Normal file
192
public/js/app.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user