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:
Alejandro Gutiérrez
2026-01-24 16:15:58 +00:00
parent 46cd54e275
commit 7666b7aea2

View File

@@ -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>
)} )}