'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'; 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; rating_snapshot: number | null; total_reviews_snapshot: number | null; } interface ScraperTestProps { onJobsChange?: (jobs: JobStatus[]) => void; onSelectReviews?: (reviews: Review[], businessName: string, jobId: string) => void; } export default function ScraperTest({ onJobsChange, onSelectReviews }: ScraperTestProps = {}) { const [searchQuery, setSearchQuery] = useState(''); const [searchedQuery, setSearchedQuery] = useState(''); 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 debounceRef = useRef(null); const pollingIntervals = useRef>(new Map()); const abortControllerRef = useRef(null); // Debounce: update map preview as user types (500ms after stopping) useEffect(() => { if (searchQuery.trim().length >= 2) { if (debounceRef.current) { clearTimeout(debounceRef.current); } debounceRef.current = setTimeout(() => { setSearchedQuery(searchQuery.trim()); }, 500); return () => { if (debounceRef.current) { clearTimeout(debounceRef.current); } }; } }, [searchQuery]); // Clear validation results when user starts typing a new search useEffect(() => { // If searchQuery is different from searchedQuery, clear results if (searchQuery.trim() !== 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); } }, [searchQuery, 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 30 second timeout const controller = new AbortController(); abortControllerRef.current = controller; const timeoutId = setTimeout(() => controller.abort(), 30000); try { const url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; const response = await fetch('/api/check-reviews', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }), 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); // Ignore AbortError (happens when user starts a new validation) if (err instanceof Error && err.name === 'AbortError') { console.log('Validation cancelled (new validation started)'); return; } console.error('Error getting business info:', err); // Error occurred setHasReviews(false); setAvailableReviewCount(0); } finally { // Only clear loading state if this controller wasn't aborted if (!controller.signal.aborted) { 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 if (data.status === 'completed' || data.status === 'failed') { 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 = () => { if (searchQuery.trim().length < 2) return; const query = searchQuery.trim(); // 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 const url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(searchedQuery)}`; 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, }), }); 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, rating_snapshot: businessRating, total_reviews_snapshot: availableReviewCount, }); 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'; default: return 'text-gray-800'; } }; const getStatusIcon = (status: string) => { switch (status) { case 'completed': return ( ); case 'running': return
; case 'failed': return ( ); default: return ( ); } }; const embedUrl = searchedQuery ? `https://maps.google.com/maps?q=${encodeURIComponent(searchedQuery)}&output=embed&z=15` : ''; const [mapClicked, setMapClicked] = useState(false); const searchInputRef = useRef(null); const handleMapClick = () => { setMapClicked(true); }; const closeModal = () => { setMapClicked(false); }; const focusSearchBar = () => { setMapClicked(false); searchInputRef.current?.focus(); }; // Test URLs at different scales const testUrls = [ { name: 'πŸͺ Small (~79)', query: 'R. Fleitas Peluqueros Gran Canaria' }, { name: 'πŸš— Medium (~589)', query: 'ClickRent Gran Canaria' }, { name: 'πŸ₯ Large (~2000+)', query: 'Hospital Universitario Doctor NegrΓ­n Las Palmas' }, { name: 'πŸ›’ Alcampo', query: 'Alcampo Hipermarket Las Palmas' }, ]; return (
{/* Test URL Quick Select */}
Quick Test:
{testUrls.map((test, idx) => ( ))}
{/* Search Interface */} <>
setSearchQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && searchQuery.trim().length >= 2 && !isCheckingReviews) { e.preventDefault(); handleSearch(); } }} placeholder="Business name and location (e.g., Soho Club Vilnius)..." 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" />
{/* Map Preview with Click Overlay */}
{searchedQuery ? ( <>