'use client'; import { useState, useEffect, useRef } from 'react'; import ReviewAnalytics from './ReviewAnalytics'; interface Review { author: string; rating: number; text: string | null; date_text: string; avatar_url: string | null; profile_url: string | null; review_id: string; } export interface JobStatus { job_id: string; status: 'pending' | 'running' | 'completed' | 'failed' | 'partial'; url: string; created_at: string; started_at: string | null; completed_at: string | null; updated_at: string | null; // Last update time for progress tracking reviews_count: number | null; total_reviews: number | null; scrape_time: number | null; error_message: string | null; // Business metadata for tracking and comparison business_name: string | null; business_address: string | null; business_category: string | null; rating_snapshot: number | null; total_reviews_snapshot: number | null; // Review topics extracted from Google Maps review_topics: { topic: string; count: number }[] | null; } interface ScraperTestProps { onJobsChange?: (jobs: JobStatus[]) => void; onSelectReviews?: (reviews: Review[], businessName: string, jobId: string) => void; } export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTestProps = {}) { // Split search fields const [businessNameQuery, setBusinessNameQuery] = useState(''); const [locationQuery, setLocationQuery] = useState(''); const [detectedLocation, setDetectedLocation] = useState<{ city: string; country: string } | null>(null); const [searchedQuery, setSearchedQuery] = useState(''); const [showMapClickModal, setShowMapClickModal] = useState(false); const [jobs, setJobs] = useState>(new Map()); const [activeJobId, setActiveJobId] = useState(null); const [reviews, setReviews] = useState([]); const [error, setError] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [showAnalytics, setShowAnalytics] = useState(false); const [isLoadingReviews, setIsLoadingReviews] = useState(false); const [showConfirmModal, setShowConfirmModal] = useState(false); const [isCheckingReviews, setIsCheckingReviews] = useState(false); const [hasReviews, setHasReviews] = useState(null); const [availableReviewCount, setAvailableReviewCount] = useState(null); const [businessName, setBusinessName] = useState(null); const [businessAddress, setBusinessAddress] = useState(null); const [businessRating, setBusinessRating] = useState(null); const [businessImage, setBusinessImage] = useState(null); const [businessCategory, setBusinessCategory] = useState(null); const [userFingerprint, setUserFingerprint] = useState<{ geolocation?: {lat: number, lng: number}, userAgent?: string, viewport?: {width: number, height: number}, timezone?: string, language?: string, platform?: string }>({}); const debounceRef = useRef(null); // Collect browser fingerprint on mount (no permissions needed) useEffect(() => { const collectFingerprint = async () => { const fingerprint: typeof userFingerprint = {}; // User agent fingerprint.userAgent = navigator.userAgent; // Screen/viewport size fingerprint.viewport = { width: window.screen.width, height: window.screen.height }; // Timezone fingerprint.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; // Language fingerprint.language = navigator.language; // Platform fingerprint.platform = navigator.platform; // Get approximate location from IP (no permission needed) try { const response = await fetch('https://ipapi.co/json/', { signal: AbortSignal.timeout(3000) }); if (response.ok) { const data = await response.json(); if (data.latitude && data.longitude) { fingerprint.geolocation = { lat: data.latitude, lng: data.longitude }; // Store detected city and country for location field if (data.city && data.country_name) { setDetectedLocation({ city: data.city, country: data.country_name }); // Auto-fill location field setLocationQuery(`${data.city}, ${data.country_name}`); } console.log('IP location:', data.city, data.country_name); } } } catch (error) { console.log('IP geolocation not available'); } setUserFingerprint(fingerprint); console.log('Browser fingerprint:', fingerprint); }; collectFingerprint(); }, []); const pollingIntervals = useRef>(new Map()); const abortControllerRef = useRef(null); // Build full search query from business name + location const buildSearchQuery = () => { const name = businessNameQuery.trim(); const location = locationQuery.trim(); if (name && location) { return `"${name}" ${location}`; } else if (name) { return name; } return ''; }; // Debounce: update map preview as user types (500ms after stopping) useEffect(() => { const query = buildSearchQuery(); if (query.length >= 2) { if (debounceRef.current) { clearTimeout(debounceRef.current); } debounceRef.current = setTimeout(() => { setSearchedQuery(query); }, 500); return () => { if (debounceRef.current) { clearTimeout(debounceRef.current); } }; } }, [businessNameQuery, locationQuery]); // Clear validation results when user starts typing a new search useEffect(() => { const currentQuery = buildSearchQuery(); // If current query is different from searchedQuery, clear results if (currentQuery !== searchedQuery && searchedQuery) { // Abort any pending validation request if (abortControllerRef.current) { abortControllerRef.current.abort(); } setHasReviews(null); setAvailableReviewCount(null); setBusinessName(null); setBusinessAddress(null); setBusinessRating(null); setBusinessImage(null); setBusinessCategory(null); } }, [businessNameQuery, locationQuery, searchedQuery]); // Notify parent when jobs change useEffect(() => { if (onJobsChange) { onJobsChange(Array.from(jobs.values())); } }, [jobs, onJobsChange]); // Check for reviews function (called manually when user clicks Validate) const checkReviews = async (query: string) => { // Abort any previous validation request if (abortControllerRef.current) { abortControllerRef.current.abort(); } setIsCheckingReviews(true); setHasReviews(null); setAvailableReviewCount(null); setBusinessName(null); setBusinessAddress(null); setBusinessRating(null); setBusinessImage(null); setBusinessCategory(null); setError(''); // Create new abort controller with 60 second timeout (validation can be slow) const controller = new AbortController(); abortControllerRef.current = controller; const timeoutId = setTimeout(() => controller.abort(), 60000); try { // Force English with hl=en parameter const url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}&hl=en`; const response = await fetch('/api/check-reviews', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, geolocation: userFingerprint.geolocation, browser_fingerprint: userFingerprint // Pass full fingerprint }), signal: controller.signal, }); clearTimeout(timeoutId); const data = await response.json(); if (response.ok && data.success) { setHasReviews(data.has_reviews); setAvailableReviewCount(data.total_reviews || 0); setBusinessName(data.name); setBusinessAddress(data.address); setBusinessRating(data.rating); setBusinessImage(data.image_url); setBusinessCategory(data.category); } else { console.error('Failed to get business info:', data.error); // Business not found setHasReviews(false); setAvailableReviewCount(0); } } catch (err) { clearTimeout(timeoutId); // Check if this is a timeout abort vs user-initiated abort if (err instanceof Error && err.name === 'AbortError') { // Check if it was a timeout (controller still matches) or user started new search if (abortControllerRef.current === controller) { // Timeout - show error console.error('Validation timed out'); setError('Validation timed out. Please try again.'); setHasReviews(false); setAvailableReviewCount(0); } else { // User started a new search - just return silently console.log('Validation cancelled (new validation started)'); return; } } else { console.error('Error getting business info:', err); // Error occurred setHasReviews(false); setAvailableReviewCount(0); } } finally { clearTimeout(timeoutId); // Always clear loading state (even on timeout) setIsCheckingReviews(false); } }; // Poll job status for all active jobs const startPolling = (jobId: string) => { // Don't start if already polling this job if (pollingIntervals.current.has(jobId)) return; const pollInterval = setInterval(async () => { try { const response = await fetch(`/api/jobs/${jobId}`); const data = await response.json(); // Update job in map setJobs(prev => { const newMap = new Map(prev); newMap.set(jobId, data); return newMap; }); // Stop polling if job is done (completed, failed, or partial) if (data.status === 'completed' || data.status === 'failed' || data.status === 'partial') { const interval = pollingIntervals.current.get(jobId); if (interval) { clearInterval(interval); pollingIntervals.current.delete(jobId); } } } catch (err) { console.error('Poll error for job', jobId, err); } }, 2000); // Poll every 2 seconds pollingIntervals.current.set(jobId, pollInterval); }; // Cleanup polling intervals and abort controllers on unmount useEffect(() => { return () => { pollingIntervals.current.forEach(interval => clearInterval(interval)); pollingIntervals.current.clear(); if (abortControllerRef.current) { abortControllerRef.current.abort(); } }; }, []); const handleSearch = () => { const query = buildSearchQuery(); if (query.length < 2 || !businessNameQuery.trim()) return; // Clear any pending debounce if (debounceRef.current) { clearTimeout(debounceRef.current); } // Immediately update map preview and trigger validation setSearchedQuery(query); checkReviews(query); }; const handlePreviewBusiness = (e: React.FormEvent) => { e.preventDefault(); setShowConfirmModal(true); }; const handleConfirmScrape = async () => { setError(''); setIsSubmitting(true); setShowConfirmModal(false); // Use the search query to create a Google Maps search URL (force English) const url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(searchedQuery)}&hl=en`; try { const response = await fetch('/api/scrape', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, business_name: businessName, business_address: businessAddress, rating_snapshot: businessRating, total_reviews_snapshot: availableReviewCount, geolocation: userFingerprint.geolocation, browser_fingerprint: userFingerprint, // Pass full fingerprint // Google Reviews scraper (this component is specific to Google Reviews) job_type: 'google-reviews', }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Failed to start scraping'); } // Add job to Map with initial status setJobs(prev => { const newMap = new Map(prev); newMap.set(data.job_id, { job_id: data.job_id, status: 'pending', url: url, created_at: new Date().toISOString(), started_at: null, completed_at: null, updated_at: new Date().toISOString(), reviews_count: null, total_reviews: null, scrape_time: null, error_message: null, business_name: businessName, business_address: businessAddress, business_category: businessCategory, rating_snapshot: businessRating, total_reviews_snapshot: availableReviewCount, review_topics: null, // Will be populated when job completes }); return newMap; }); // Set as active job and start polling setActiveJobId(data.job_id); startPolling(data.job_id); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to submit job'); } finally { setIsSubmitting(false); } }; const getStatusColor = (status: string) => { switch (status) { case 'completed': return 'text-green-700'; case 'running': return 'text-blue-700'; case 'failed': return 'text-red-700'; case 'partial': return 'text-orange-700'; default: return 'text-gray-800'; } }; const getStatusIcon = (status: string) => { switch (status) { case 'completed': return ( ); case 'running': return
; case 'failed': return ( ); case 'partial': return ( ); default: return ( ); } }; // Google Maps embed URL for iframe preview const embedUrl = searchedQuery ? `https://maps.google.com/maps?q=${encodeURIComponent(searchedQuery)}&output=embed&z=15` : ''; // Google Maps link for opening in new tab const googleMapsUrl = searchedQuery ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(searchedQuery)}` : ''; const searchInputRef = useRef(null); // Test URLs at different scales (split into business name + location) const testUrls = [ { name: 'πŸͺ Small (~79)', businessName: 'R. Fleitas Peluqueros', location: 'Las Palmas, Spain' }, { name: 'πŸš— Medium (~589)', businessName: 'ClickRent', location: 'Gran Canaria, Spain' }, { name: 'πŸ₯ Large (~2000+)', businessName: 'Hospital Universitario Doctor NegrΓ­n', location: 'Las Palmas, Spain' }, { name: 'πŸ›’ Alcampo', businessName: 'Alcampo Hipermarket', location: 'Las Palmas, Spain' }, ]; return (
{/* Test URL Quick Select */}
Quick Test:
{testUrls.map((test, idx) => ( ))}
{/* Search Interface - Split Fields */} <>
{/* Business Name Field */}
setBusinessNameQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && businessNameQuery.trim() && !isCheckingReviews) { e.preventDefault(); handleSearch(); } }} placeholder="e.g., Starbucks, McDonald's, Hilton Hotel..." className="w-full pl-12 pr-4 py-3 text-gray-900 bg-white border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:ring-4 focus:ring-blue-100 outline-none transition-all" />
{/* Location Field */}
setLocationQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && businessNameQuery.trim() && !isCheckingReviews) { e.preventDefault(); handleSearch(); } }} placeholder={detectedLocation ? `${detectedLocation.city}, ${detectedLocation.country}` : "City, Country (e.g., New York, USA)"} className="w-full pl-12 pr-4 py-3 text-gray-900 bg-white border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:ring-4 focus:ring-blue-100 outline-none transition-all" />
{/* Validate Button */}
{/* Map Preview Area */}
{searchedQuery ? ( <>