Initial commit: Lead Scraper - Google Maps
This commit is contained in:
46
views/history.ejs
Normal file
46
views/history.ejs
Normal file
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="bg-dark">
|
||||
<head><%- include('partials/head') %></head>
|
||||
<body class="bg-dark text-gray-200 flex min-h-screen">
|
||||
<% const activePage = 'history'; %>
|
||||
<%- include('partials/sidebar') %>
|
||||
|
||||
<main class="flex-1 ml-56 p-6">
|
||||
<h2 class="text-lg font-bold mb-4">Search History</h2>
|
||||
|
||||
<% if (!searches || searches.length === 0) { %>
|
||||
<div class="flex flex-col items-center justify-center py-24 text-gray-500">
|
||||
<svg class="w-16 h-16 mb-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<p class="text-sm font-medium">No searches yet</p>
|
||||
<p class="text-xs mt-1">Your search history will appear here</p>
|
||||
<a href="/" class="mt-4 bg-accent hover:bg-accent-hover text-white px-4 py-2 rounded-lg text-sm transition-colors">Start Searching</a>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="space-y-2">
|
||||
<% searches.forEach(s => { %>
|
||||
<div class="bg-card border border-white/5 rounded-lg p-4 flex items-center justify-between hover:bg-card-hover transition-colors">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-white"><%= s.query %></p>
|
||||
<p class="text-xs text-gray-500 mt-1"><%= s.resultsCount %> results · <%= new Date(s.timestamp).toLocaleString() %></p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/?view=<%= encodeURIComponent(s.key) %>"
|
||||
class="bg-accent/20 text-accent hover:bg-accent/30 px-3 py-1.5 rounded text-xs font-medium transition-colors">View</a>
|
||||
<button onclick="deleteSearch('<%= s.key %>', this)"
|
||||
class="bg-red-500/10 text-red-400 hover:bg-red-500/20 px-3 py-1.5 rounded text-xs font-medium transition-colors">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% } %>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
async function deleteSearch(key, btn) {
|
||||
if (!confirm('Delete this search?')) return;
|
||||
await fetch('/api/search/' + encodeURIComponent(key), { method: 'DELETE' });
|
||||
btn.closest('.bg-card').remove();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
121
views/index.ejs
Normal file
121
views/index.ejs
Normal file
@@ -0,0 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="bg-dark">
|
||||
<head><%- include('partials/head') %></head>
|
||||
<body class="bg-dark text-gray-200 flex min-h-screen">
|
||||
<% const activePage = 'search'; %>
|
||||
<%- include('partials/sidebar') %>
|
||||
|
||||
<main class="flex-1 ml-56">
|
||||
<!-- Search Bar -->
|
||||
<div class="bg-sidebar/50 border-b border-white/5 p-4">
|
||||
<form id="searchForm" class="flex items-end gap-3 max-w-5xl">
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs text-gray-500 mb-1">Keyword</label>
|
||||
<input type="text" name="keyword" placeholder="e.g. Restaurants, Plumbers..."
|
||||
class="w-full bg-card border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-accent" required>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs text-gray-500 mb-1">City</label>
|
||||
<input type="text" name="city" placeholder="e.g. Austin"
|
||||
class="w-full bg-card border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-accent" required>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<label class="block text-xs text-gray-500 mb-1">State</label>
|
||||
<input type="text" name="state" placeholder="e.g. Texas"
|
||||
class="w-full bg-card border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-accent">
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<label class="block text-xs text-gray-500 mb-1">Max</label>
|
||||
<input type="number" name="maxResults" value="20" min="1" max="100"
|
||||
class="w-full bg-card border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-accent">
|
||||
</div>
|
||||
<button type="submit" id="searchBtn"
|
||||
class="bg-accent hover:bg-accent-hover text-white px-5 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2">
|
||||
<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
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="p-4 relative" id="contentArea">
|
||||
<!-- Loading State -->
|
||||
<div id="loadingState" class="hidden">
|
||||
<div class="flex flex-col items-center justify-center py-24 text-gray-500">
|
||||
<div class="flex gap-1 mb-4">
|
||||
<div class="w-3 h-3 bg-accent rounded-full pulse-dot"></div>
|
||||
<div class="w-3 h-3 bg-accent rounded-full pulse-dot"></div>
|
||||
<div class="w-3 h-3 bg-accent rounded-full pulse-dot"></div>
|
||||
</div>
|
||||
<p class="text-sm font-medium">Scraping Google Maps...</p>
|
||||
<p class="text-xs mt-1">This usually takes 30-120 seconds</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div id="errorState" class="hidden">
|
||||
<div class="flex flex-col items-center justify-center py-24">
|
||||
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-6 max-w-md text-center">
|
||||
<svg class="w-8 h-8 text-red-400 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
|
||||
<p class="text-red-400 text-sm font-medium" id="errorMsg"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="emptyState">
|
||||
<div class="flex flex-col items-center justify-center py-24 text-gray-500">
|
||||
<svg class="w-16 h-16 mb-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
<p class="text-sm font-medium">Search Google Maps for leads</p>
|
||||
<p class="text-xs mt-1">Enter a keyword and city to get started</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Table -->
|
||||
<div id="resultsArea" class="hidden">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="text-sm text-gray-400"><span id="resultsQuery"></span> — <span id="resultsCount"></span> results</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto rounded-lg border border-white/5">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-card text-gray-400 text-left text-xs uppercase tracking-wider">
|
||||
<th class="px-4 py-3 w-8"></th>
|
||||
<th class="px-4 py-3">Business</th>
|
||||
<th class="px-4 py-3">Phone</th>
|
||||
<th class="px-4 py-3">Email</th>
|
||||
<th class="px-4 py-3">Website</th>
|
||||
<th class="px-4 py-3">Rating</th>
|
||||
<th class="px-4 py-3">Reviews</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="resultsBody" class="divide-y divide-white/5"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Panel -->
|
||||
<div id="detailPanel" class="fixed top-0 right-0 w-96 h-full bg-sidebar border-l border-white/10 z-50 hidden overflow-y-auto scrollbar-thin">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Searches (shown on empty state) -->
|
||||
<% if (searches && searches.length > 0) { %>
|
||||
<div id="recentSearches" class="px-4 pb-4">
|
||||
<h3 class="text-xs text-gray-500 uppercase tracking-wider mb-2">Recent Searches</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
<% searches.slice(0, 6).forEach(s => { %>
|
||||
<button onclick="loadSearch('<%= s.key %>')"
|
||||
class="bg-card hover:bg-card-hover border border-white/5 rounded-lg p-3 text-left transition-colors">
|
||||
<p class="text-sm text-white font-medium truncate"><%= s.query %></p>
|
||||
<p class="text-xs text-gray-500 mt-1"><%= s.resultsCount %> results · <%= new Date(s.timestamp).toLocaleDateString() %></p>
|
||||
</button>
|
||||
<% }) %>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</main>
|
||||
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
77
views/leads.ejs
Normal file
77
views/leads.ejs
Normal file
@@ -0,0 +1,77 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="bg-dark">
|
||||
<head><%- include('partials/head') %></head>
|
||||
<body class="bg-dark text-gray-200 flex min-h-screen">
|
||||
<% const activePage = 'leads'; %>
|
||||
<%- include('partials/sidebar') %>
|
||||
|
||||
<main class="flex-1 ml-56 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-bold">Saved Leads</h2>
|
||||
<span class="text-xs text-gray-500"><%= leads.length %> leads</span>
|
||||
</div>
|
||||
|
||||
<% if (!leads || leads.length === 0) { %>
|
||||
<div class="flex flex-col items-center justify-center py-24 text-gray-500">
|
||||
<svg class="w-16 h-16 mb-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/></svg>
|
||||
<p class="text-sm font-medium">No saved leads</p>
|
||||
<p class="text-xs mt-1">Bookmark businesses from search results to save them here</p>
|
||||
<a href="/" class="mt-4 bg-accent hover:bg-accent-hover text-white px-4 py-2 rounded-lg text-sm transition-colors">Search Leads</a>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="overflow-x-auto rounded-lg border border-white/5">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-card text-gray-400 text-left text-xs uppercase tracking-wider">
|
||||
<th class="px-4 py-3">Business</th>
|
||||
<th class="px-4 py-3">Phone</th>
|
||||
<th class="px-4 py-3">Email</th>
|
||||
<th class="px-4 py-3">Website</th>
|
||||
<th class="px-4 py-3">Rating</th>
|
||||
<th class="px-4 py-3">Saved</th>
|
||||
<th class="px-4 py-3 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5">
|
||||
<% leads.forEach(l => { %>
|
||||
<tr class="hover:bg-card-hover transition-colors" id="lead-<%= l.placeId %>">
|
||||
<td class="px-4 py-3">
|
||||
<p class="font-medium text-white"><%= l.title %></p>
|
||||
<p class="text-xs text-gray-500"><%= l.categoryName %></p>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-300"><%= l.phone || '—' %></td>
|
||||
<td class="px-4 py-3 text-gray-300"><%= l.email || '—' %></td>
|
||||
<td class="px-4 py-3">
|
||||
<% if (l.website) { %>
|
||||
<a href="<%= l.website %>" target="_blank" class="text-accent hover:underline truncate block max-w-[200px]"><%= l.website.replace(/^https?:\/\//, '').replace(/\/$/, '') %></a>
|
||||
<% } else { %>—<% } %>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<% if (l.totalScore) { %>
|
||||
<span class="text-yellow-400">★</span> <%= l.totalScore.toFixed(1) %>
|
||||
<span class="text-gray-500">(<%= l.reviewsCount %>)</span>
|
||||
<% } else { %>—<% } %>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500"><%= new Date(l.savedAt).toLocaleDateString() %></td>
|
||||
<td class="px-4 py-3">
|
||||
<button onclick="removeLead('<%= l.placeId %>')" class="text-red-400 hover:text-red-300 transition-colors" title="Remove">
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% } %>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
async function removeLead(placeId) {
|
||||
await fetch('/api/leads/' + encodeURIComponent(placeId), { method: 'DELETE' });
|
||||
const row = document.getElementById('lead-' + placeId);
|
||||
if (row) row.remove();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
34
views/partials/head.ejs
Normal file
34
views/partials/head.ejs
Normal file
@@ -0,0 +1,34 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Lead Scraper — Google Maps</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
sidebar: '#1a1f1e',
|
||||
accent: '#4a7c59',
|
||||
'accent-hover': '#5a9469',
|
||||
dark: '#111413',
|
||||
card: '#1e2423',
|
||||
'card-hover': '#252b2a',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { font-family: 'Inter', system-ui, sans-serif; }
|
||||
.scrollbar-thin::-webkit-scrollbar { width: 6px; }
|
||||
.scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
|
||||
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
.slide-in { animation: slideIn 0.25s ease-out; }
|
||||
.fade-in { animation: fadeIn 0.3s ease-out; }
|
||||
@keyframes pulse-dot { 0%, 80%, 100% { transform: scale(0); } 40% { transform: scale(1); } }
|
||||
.pulse-dot { animation: pulse-dot 1.4s infinite ease-in-out both; }
|
||||
.pulse-dot:nth-child(1) { animation-delay: -0.32s; }
|
||||
.pulse-dot:nth-child(2) { animation-delay: -0.16s; }
|
||||
</style>
|
||||
26
views/partials/sidebar.ejs
Normal file
26
views/partials/sidebar.ejs
Normal file
@@ -0,0 +1,26 @@
|
||||
<aside class="w-56 bg-sidebar h-screen flex flex-col fixed left-0 top-0 z-40">
|
||||
<div class="p-5 border-b border-white/10">
|
||||
<h1 class="text-lg font-bold text-white flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
Lead Scraper
|
||||
</h1>
|
||||
<p class="text-xs text-gray-500 mt-1">Google Maps Leads</p>
|
||||
</div>
|
||||
<nav class="flex-1 p-3 space-y-1">
|
||||
<a href="/" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors <%= typeof activePage !== 'undefined' && activePage === 'search' ? 'bg-accent/20 text-accent' : 'text-gray-400 hover:bg-white/5 hover:text-white' %>">
|
||||
<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
|
||||
</a>
|
||||
<a href="/history" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors <%= typeof activePage !== 'undefined' && activePage === 'history' ? 'bg-accent/20 text-accent' : 'text-gray-400 hover:bg-white/5 hover:text-white' %>">
|
||||
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
History
|
||||
</a>
|
||||
<a href="/leads" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors <%= typeof activePage !== 'undefined' && activePage === 'leads' ? 'bg-accent/20 text-accent' : 'text-gray-400 hover:bg-white/5 hover:text-white' %>">
|
||||
<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>
|
||||
Leads
|
||||
</a>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-white/10">
|
||||
<p class="text-[10px] text-gray-600">Powered by Apify</p>
|
||||
</div>
|
||||
</aside>
|
||||
Reference in New Issue
Block a user