Fix: Replace broken Google Maps iframe with interactive preview + add scraper type selection
- Replace non-working Google Maps embed iframe with animated location preview - Add "Open in Google Maps" button to open location in new tab - Add scraper type selection dropdown fetching from /api/admin/scrapers - Show selected scraper info with formatted labels (Google Reviews v1.0.0) - Include scraper_version and scraper_variant in job submission Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import ReviewAnalytics from './ReviewAnalytics';
|
import ReviewAnalytics from './ReviewAnalytics';
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
|
interface ScraperType {
|
||||||
|
job_type: string;
|
||||||
|
version: string;
|
||||||
|
variant: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Review {
|
interface Review {
|
||||||
author: string;
|
author: string;
|
||||||
rating: number;
|
rating: number;
|
||||||
@@ -59,6 +68,11 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
|
|||||||
const [businessRating, setBusinessRating] = useState<number | null>(null);
|
const [businessRating, setBusinessRating] = useState<number | null>(null);
|
||||||
const [businessImage, setBusinessImage] = useState<string | null>(null);
|
const [businessImage, setBusinessImage] = useState<string | null>(null);
|
||||||
const [businessCategory, setBusinessCategory] = useState<string | null>(null);
|
const [businessCategory, setBusinessCategory] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Scraper type selection
|
||||||
|
const [availableScrapers, setAvailableScrapers] = useState<ScraperType[]>([]);
|
||||||
|
const [selectedScraper, setSelectedScraper] = useState<ScraperType | null>(null);
|
||||||
|
const [scrapersLoading, setScrapersLoading] = useState(true);
|
||||||
const [userFingerprint, setUserFingerprint] = useState<{
|
const [userFingerprint, setUserFingerprint] = useState<{
|
||||||
geolocation?: {lat: number, lng: number},
|
geolocation?: {lat: number, lng: number},
|
||||||
userAgent?: string,
|
userAgent?: string,
|
||||||
@@ -117,6 +131,49 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
|
|||||||
|
|
||||||
collectFingerprint();
|
collectFingerprint();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Fetch available scraper types on mount
|
||||||
|
const fetchScrapers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/admin/scrapers`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
// Transform to ScraperType format and filter to active scrapers
|
||||||
|
const scrapers: ScraperType[] = data
|
||||||
|
.filter((s: { deprecated_at: string | null; traffic_pct: number }) => !s.deprecated_at && s.traffic_pct > 0)
|
||||||
|
.map((s: { job_type: string; version: string; variant: string }) => ({
|
||||||
|
job_type: s.job_type,
|
||||||
|
version: s.version,
|
||||||
|
variant: s.variant,
|
||||||
|
// Format job_type nicely: google_reviews or google-reviews -> "Google Reviews"
|
||||||
|
label: `${s.job_type.split(/[-_]/).map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')} v${s.version}${s.variant !== 'stable' ? ` (${s.variant})` : ''}`,
|
||||||
|
}));
|
||||||
|
setAvailableScrapers(scrapers);
|
||||||
|
// Auto-select first scraper (usually google-reviews stable)
|
||||||
|
if (scrapers.length > 0 && !selectedScraper) {
|
||||||
|
setSelectedScraper(scrapers[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch scrapers:', err);
|
||||||
|
// Fallback to default google-reviews
|
||||||
|
const defaultScraper: ScraperType = {
|
||||||
|
job_type: 'google-reviews',
|
||||||
|
version: '1.0.0',
|
||||||
|
variant: 'stable',
|
||||||
|
label: 'Google Reviews v1.0.0',
|
||||||
|
};
|
||||||
|
setAvailableScrapers([defaultScraper]);
|
||||||
|
setSelectedScraper(defaultScraper);
|
||||||
|
} finally {
|
||||||
|
setScrapersLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedScraper]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchScrapers();
|
||||||
|
}, [fetchScrapers]);
|
||||||
|
|
||||||
const pollingIntervals = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
const pollingIntervals = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
@@ -322,6 +379,11 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
|
|||||||
const url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(searchedQuery)}&hl=en`;
|
const url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(searchedQuery)}&hl=en`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Use selected scraper or default to google-reviews
|
||||||
|
const jobType = selectedScraper?.job_type || 'google-reviews';
|
||||||
|
const scraperVersion = selectedScraper?.version;
|
||||||
|
const scraperVariant = selectedScraper?.variant;
|
||||||
|
|
||||||
const response = await fetch('/api/scrape', {
|
const response = await fetch('/api/scrape', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -333,6 +395,10 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
|
|||||||
total_reviews_snapshot: availableReviewCount,
|
total_reviews_snapshot: availableReviewCount,
|
||||||
geolocation: userFingerprint.geolocation,
|
geolocation: userFingerprint.geolocation,
|
||||||
browser_fingerprint: userFingerprint, // Pass full fingerprint
|
browser_fingerprint: userFingerprint, // Pass full fingerprint
|
||||||
|
// Include scraper selection
|
||||||
|
job_type: jobType,
|
||||||
|
scraper_version: scraperVersion,
|
||||||
|
scraper_variant: scraperVariant,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -419,26 +485,13 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const embedUrl = searchedQuery
|
// Google Maps link for opening in new tab
|
||||||
? `https://maps.google.com/maps?q=${encodeURIComponent(searchedQuery)}&output=embed&z=15`
|
const googleMapsUrl = searchedQuery
|
||||||
|
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(searchedQuery)}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const [mapClicked, setMapClicked] = useState(false);
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleMapClick = () => {
|
|
||||||
setMapClicked(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setMapClicked(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const focusSearchBar = () => {
|
|
||||||
setMapClicked(false);
|
|
||||||
searchInputRef.current?.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Test URLs at different scales
|
// Test URLs at different scales
|
||||||
const testUrls = [
|
const testUrls = [
|
||||||
{ name: '🏪 Small (~79)', query: 'R. Fleitas Peluqueros Gran Canaria' },
|
{ name: '🏪 Small (~79)', query: 'R. Fleitas Peluqueros Gran Canaria' },
|
||||||
@@ -449,6 +502,68 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-4xl mx-auto">
|
<div className="w-full max-w-4xl mx-auto">
|
||||||
|
{/* Scraper Type Selection */}
|
||||||
|
<div className="mb-4 p-4 bg-white border-2 border-gray-200 rounded-xl shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700">Scraper Type</label>
|
||||||
|
<p className="text-xs text-gray-500">Select the type of data to scrape</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scrapersLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-gray-400">
|
||||||
|
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin"></div>
|
||||||
|
<span className="text-sm">Loading...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={selectedScraper ? `${selectedScraper.job_type}:${selectedScraper.version}:${selectedScraper.variant}` : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const [jobType, version, variant] = e.target.value.split(':');
|
||||||
|
const scraper = availableScrapers.find(
|
||||||
|
s => s.job_type === jobType && s.version === version && s.variant === variant
|
||||||
|
);
|
||||||
|
if (scraper) setSelectedScraper(scraper);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-gray-50 border-2 border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:border-blue-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none transition-all cursor-pointer min-w-[200px]"
|
||||||
|
>
|
||||||
|
{availableScrapers.map((scraper) => (
|
||||||
|
<option
|
||||||
|
key={`${scraper.job_type}:${scraper.version}:${scraper.variant}`}
|
||||||
|
value={`${scraper.job_type}:${scraper.version}:${scraper.variant}`}
|
||||||
|
>
|
||||||
|
{scraper.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show selected scraper info */}
|
||||||
|
{selectedScraper && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-100 flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded font-medium">
|
||||||
|
{selectedScraper.job_type.split(/[-_]/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded">
|
||||||
|
v{selectedScraper.version}
|
||||||
|
</span>
|
||||||
|
{selectedScraper.variant !== 'stable' && (
|
||||||
|
<span className="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded font-medium">
|
||||||
|
{selectedScraper.variant}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Test URL Quick Select */}
|
{/* Test URL Quick Select */}
|
||||||
<div className="mb-4 p-3 bg-gray-50 border-2 border-gray-200 rounded-xl">
|
<div className="mb-4 p-3 bg-gray-50 border-2 border-gray-200 rounded-xl">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
@@ -536,78 +651,52 @@ export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTe
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Map Preview with Click Overlay */}
|
{/* Map Preview Area */}
|
||||||
<div className="mb-4 rounded-xl overflow-hidden border-2 border-gray-200 bg-gray-100 relative">
|
<div className="mb-4 rounded-xl overflow-hidden border-2 border-gray-200 bg-gradient-to-br from-blue-50 to-indigo-50 relative">
|
||||||
{searchedQuery ? (
|
{searchedQuery ? (
|
||||||
<>
|
<div className="h-[200px] flex items-center justify-center p-6">
|
||||||
<iframe
|
<div className="text-center max-w-md">
|
||||||
src={embedUrl}
|
{/* Map Pin Icon with animation */}
|
||||||
width="100%"
|
<div className="relative inline-block mb-4">
|
||||||
height="350"
|
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center shadow-lg">
|
||||||
style={{ border: 0, pointerEvents: 'none' }}
|
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
allowFullScreen
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={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" />
|
||||||
loading="lazy"
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
referrerPolicy="no-referrer-when-downgrade"
|
</svg>
|
||||||
title="Google Maps"
|
|
||||||
/>
|
|
||||||
{/* Click detection overlay - always present to capture clicks */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 cursor-pointer"
|
|
||||||
onClick={handleMapClick}
|
|
||||||
/>
|
|
||||||
{/* Modal centered on map card */}
|
|
||||||
{mapClicked && (
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 flex items-center justify-center backdrop-blur-md bg-gray-900/30 p-4"
|
|
||||||
onClick={closeModal}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="bg-white rounded-2xl p-6 sm:p-8 shadow-2xl w-full max-w-md border-2 border-blue-500 animate-fade-in"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="text-center mb-4 sm:mb-6">
|
|
||||||
<div className="text-4xl sm:text-5xl mb-2 sm:mb-3">🎯</div>
|
|
||||||
<p className="text-xl sm:text-2xl font-bold text-gray-900 mb-2">Want a specific business?</p>
|
|
||||||
<p className="text-xs sm:text-sm text-gray-600">
|
|
||||||
Search for the <strong>exact business name</strong> to scrape its reviews
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-3 mb-4">
|
|
||||||
<p className="text-xs text-blue-900 font-medium mb-1">💡 Example:</p>
|
|
||||||
<p className="text-sm font-semibold text-blue-800">"Starbucks Downtown Seattle"</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">instead of just "coffee"</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={focusSearchBar}
|
|
||||||
className="flex-1 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-bold transition-all flex items-center justify-center gap-2 shadow-md"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
||||||
</svg>
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={closeModal}
|
|
||||||
className="px-4 py-3 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg font-bold transition-all"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/* Pulse animation */}
|
||||||
|
<div className="absolute inset-0 w-16 h-16 bg-blue-400 rounded-full animate-ping opacity-20"></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
{/* Search Query Display */}
|
||||||
|
<p className="text-lg font-bold text-gray-800 mb-2">📍 {searchedQuery}</p>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">Click Validate to check this business on Google Maps</p>
|
||||||
|
|
||||||
|
{/* Open in Google Maps button */}
|
||||||
|
<a
|
||||||
|
href={googleMapsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-white border-2 border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:border-blue-500 hover:bg-blue-50 transition-all shadow-sm"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
|
||||||
|
</svg>
|
||||||
|
Open in Google Maps
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[350px] flex items-center justify-center text-gray-400">
|
<div className="h-[200px] flex items-center justify-center text-gray-400">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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 strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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 strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<p>Search for a business to see the map</p>
|
<p>Search for a business to see location preview</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user