Files
whyrating-engine-legacy/web/components/ScraperTest.tsx
Alejandro Gutiérrez 43fd1515d2 Align artifacts with canonical URT v5.1 specification
Fixes inconsistencies discovered during audit against urt-taxonomy/:

- urt_profile ENUM: Add 'lite' and 'core' profiles (was missing)
- USN format: Use canonical regex from spec (was non-compliant)
- USN valence encoding: Add V0 (0) and V± (±) support
- USN grammar: Add Lite (URT:L:) and Core (URT:C:) formats
- Dimension codes: Fix temporal (TC/TR/TH/TF), evidence (ES/EI/EC),
  comparative (CR-N/CR-B/CR-W/CR-S) in decisions doc
- LLM contract: Full USN regex validation pattern

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 16:21:21 +00:00

1111 lines
51 KiB
TypeScript

'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 = {}) {
const [searchQuery, setSearchQuery] = useState('');
const [searchedQuery, setSearchedQuery] = useState('');
const [jobs, setJobs] = useState<Map<string, JobStatus>>(new Map());
const [activeJobId, setActiveJobId] = useState<string | null>(null);
const [reviews, setReviews] = useState<Review[]>([]);
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<boolean | null>(null);
const [availableReviewCount, setAvailableReviewCount] = useState<number | null>(null);
const [businessName, setBusinessName] = useState<string | null>(null);
const [businessAddress, setBusinessAddress] = useState<string | null>(null);
const [businessRating, setBusinessRating] = useState<number | null>(null);
const [businessImage, setBusinessImage] = useState<string | null>(null);
const [businessCategory, setBusinessCategory] = useState<string | null>(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<NodeJS.Timeout | null>(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
};
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<Map<string, NodeJS.Timeout>>(new Map());
const abortControllerRef = useRef<AbortController | null>(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 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 = () => {
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 (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 (
<svg className="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
);
case 'running':
return <div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />;
case 'failed':
return (
<svg className="w-5 h-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
);
case 'partial':
return (
<svg className="w-5 h-5 text-orange-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
);
default:
return (
<svg className="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" />
</svg>
);
}
};
// 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<HTMLInputElement>(null);
// 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 (
<div className="w-full max-w-4xl mx-auto">
{/* Test URL Quick Select */}
<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">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Quick Test:</span>
</div>
<div className="flex flex-wrap gap-2">
{testUrls.map((test, idx) => (
<button
key={idx}
onClick={() => {
setSearchQuery(test.query);
setSearchedQuery(test.query);
checkReviews(test.query);
}}
className="px-3 py-1.5 text-sm bg-white border-2 border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all font-medium text-gray-700"
>
{test.name}
</button>
))}
</div>
</div>
{/* Search Interface */}
<>
<div className="mb-4 flex gap-2">
<div className="relative flex-1">
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400">
<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>
</div>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => 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"
/>
</div>
<button
onClick={handleSearch}
disabled={searchQuery.trim().length < 2 || isCheckingReviews}
className={`px-6 py-3 font-semibold rounded-xl transition-all flex items-center gap-2 ${
hasReviews === true && searchQuery.trim() === searchedQuery
? 'bg-green-600 text-white hover:bg-green-700'
: hasReviews === false && searchQuery.trim() === searchedQuery
? 'bg-yellow-500 text-white hover:bg-yellow-600'
: 'bg-blue-600 text-white hover:bg-blue-700'
} disabled:bg-gray-300 disabled:cursor-not-allowed`}
>
{isCheckingReviews ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Validating...
</>
) : hasReviews === true && searchQuery.trim() === searchedQuery ? (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{availableReviewCount?.toLocaleString()} reviews
</>
) : hasReviews === false && searchQuery.trim() === searchedQuery ? (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
No reviews
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Validate
</>
)}
</button>
</div>
{/* Map Preview Area */}
<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 ? (
<div className="h-[200px] flex items-center justify-center p-6">
<div className="text-center max-w-md">
{/* Map Pin Icon with animation */}
<div className="relative inline-block mb-4">
<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">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
{/* Pulse animation */}
<div className="absolute inset-0 w-16 h-16 bg-blue-400 rounded-full animate-ping opacity-20"></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-[200px] flex items-center justify-center text-gray-400">
<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">
<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" />
</svg>
<p>Search for a business to see location preview</p>
</div>
</div>
)}
</div>
{/* Business Card - Validation Results */}
{searchedQuery && hasReviews !== null && (
<div className="mb-6">
{hasReviews ? (
// Success - Show Business Card
<div className="bg-white border-2 border-green-500 rounded-2xl shadow-lg overflow-hidden mb-4">
{/* Business Card Layout */}
<div className="flex">
{/* Business Image */}
{businessImage && (
<div className="w-40 h-40 flex-shrink-0 bg-gray-200">
<img
src={businessImage}
alt={businessName || 'Business'}
className="w-full h-full object-cover"
onError={(e) => {
// Hide image on error
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
)}
{/* Business Info */}
<div className="flex-1 p-5">
{/* Category Badge + Verified */}
<div className="flex items-center gap-2 mb-2">
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-100 text-green-700 text-xs font-semibold rounded-full">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
Verified
</span>
{businessCategory && (
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs font-medium rounded-full">
{businessCategory}
</span>
)}
</div>
{/* Business Name */}
<h3 className="text-xl font-bold text-gray-900 mb-2 leading-tight">{businessName}</h3>
{/* Rating + Reviews Row */}
<div className="flex items-center gap-3 mb-2">
{businessRating && (
<div className="flex items-center gap-1">
<span className="text-lg font-bold text-gray-900">{businessRating.toFixed(1)}</span>
<div className="flex items-center">
{[...Array(5)].map((_, i) => (
<svg
key={i}
className={`w-4 h-4 ${i < Math.floor(businessRating) ? 'text-yellow-400' : 'text-gray-300'}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
</div>
)}
{availableReviewCount !== null && availableReviewCount > 0 && (
<span className="text-sm text-gray-600 font-medium">
({availableReviewCount.toLocaleString()} reviews)
</span>
)}
</div>
{/* Address */}
{businessAddress && (
<div className="flex items-start gap-1.5 text-gray-500 text-sm">
<svg className="w-4 h-4 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="line-clamp-2">{businessAddress}</span>
</div>
)}
</div>
</div>
{/* Start Scraping Button */}
<div className="px-5 pb-5">
<form onSubmit={handlePreviewBusiness}>
<button
type="submit"
disabled={isSubmitting}
className="w-full py-4 bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white rounded-xl font-bold transition-all flex items-center justify-center gap-2 shadow-lg text-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<>
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
Starting scrape...
</>
) : (
<>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Scrape {availableReviewCount?.toLocaleString()} Reviews
</>
)}
</button>
</form>
</div>
</div>
) : (
// No Reviews - Show Warning
<div className="p-4 bg-yellow-50 border-2 border-yellow-300 rounded-xl">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-yellow-500 rounded-lg flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="flex-1">
<p className="font-bold text-yellow-900 text-lg">No reviews available</p>
{businessName && (
<p className="text-sm text-yellow-800 mt-1">
Business: <strong>{businessName}</strong>
</p>
)}
<p className="text-xs text-yellow-700 mt-1">
This business has no reviews to scrape. Try a different search.
</p>
</div>
</div>
</div>
)}
</div>
)}
</>
{/* Error */}
{error && (
<div className="mb-6 p-4 bg-red-100 border-2 border-red-300 rounded-xl">
<div className="flex items-start gap-3">
<svg className="w-6 h-6 text-red-700 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<div>
<p className="font-bold text-red-900 text-lg">Error</p>
<p className="text-red-800 mt-1">{error}</p>
</div>
</div>
</div>
)}
{/* Jobs List */}
{jobs.size > 0 && (
<div className="mb-6 space-y-4">
<div className="flex items-center justify-between mb-2">
<h2 className="text-2xl font-bold text-gray-900">
Scraping Jobs
</h2>
<span className="px-3 py-1 bg-blue-100 text-blue-800 font-semibold rounded-full text-sm">
{jobs.size} {jobs.size === 1 ? 'Job' : 'Jobs'}
</span>
</div>
{Array.from(jobs.values())
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map(job => (
<div
key={job.job_id}
className={`p-6 rounded-xl transition-all shadow-md ${
job.job_id === activeJobId
? 'bg-blue-50 border-2 border-blue-500 shadow-lg'
: 'bg-white border-2 border-gray-300'
}`}
>
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-3">
{getStatusIcon(job.status)}
<h3 className="text-lg font-bold text-gray-900">
Status: <span className={`${getStatusColor(job.status)} font-extrabold`}>{job.status.toUpperCase()}</span>
</h3>
</div>
<p className="text-xs font-mono text-gray-600 mb-2 bg-gray-100 px-2 py-1 rounded inline-block">{job.job_id}</p>
<p className="text-sm text-gray-700 truncate max-w-2xl font-medium">{job.url}</p>
</div>
</div>
{/* Progress Bar for Running Jobs */}
{job.status === 'running' && job.total_reviews !== null && job.reviews_count !== null && (
<div className="mb-4 p-4 bg-blue-50 border-2 border-blue-200 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-blue-900">Extracting Reviews</span>
<span className="text-sm font-bold text-blue-700">
{job.reviews_count} / {job.total_reviews}
</span>
</div>
<div className="w-full bg-blue-200 rounded-full h-3 overflow-hidden">
<div
className="bg-gradient-to-r from-blue-500 to-indigo-600 h-3 rounded-full transition-all duration-500 ease-out flex items-center justify-end pr-1"
style={{ width: `${Math.min((job.reviews_count / job.total_reviews) * 100, 100)}%` }}
>
{job.reviews_count > 0 && (
<span className="text-xs font-bold text-white drop-shadow">
{Math.round((job.reviews_count / job.total_reviews) * 100)}%
</span>
)}
</div>
</div>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
{job.reviews_count !== null && (
<div className="p-4 bg-blue-100 border-2 border-blue-200 rounded-lg">
<div className="text-3xl font-bold text-blue-800">{job.reviews_count}</div>
<div className="text-xs font-semibold text-blue-700 mt-1">Reviews</div>
</div>
)}
{job.scrape_time !== null && (
<div className="p-4 bg-green-100 border-2 border-green-200 rounded-lg">
<div className="text-3xl font-bold text-green-800">{job.scrape_time.toFixed(1)}s</div>
<div className="text-xs font-semibold text-green-700 mt-1">Time</div>
</div>
)}
{job.scrape_time && job.reviews_count && (
<div className="p-4 bg-purple-100 border-2 border-purple-200 rounded-lg">
<div className="text-3xl font-bold text-purple-800">
{(job.reviews_count / job.scrape_time).toFixed(1)}
</div>
<div className="text-xs font-semibold text-purple-700 mt-1">Reviews/sec</div>
</div>
)}
{job.started_at && (
<div className="p-4 bg-gray-100 border-2 border-gray-300 rounded-lg">
<div className="text-lg font-bold text-gray-800">
{new Date(job.started_at).toLocaleTimeString()}
</div>
<div className="text-xs font-semibold text-gray-700 mt-1">Started</div>
</div>
)}
{job.status === 'running' && job.updated_at && (
<div className="p-4 bg-blue-100 border-2 border-blue-200 rounded-lg">
<div className="text-lg font-bold text-blue-800">
{new Date(job.updated_at).toLocaleTimeString()}
</div>
<div className="text-xs font-semibold text-blue-700 mt-1">Last Update</div>
</div>
)}
</div>
{/* Action Buttons - Show when completed, partial, or running with reviews */}
{(job.status === 'completed' || job.status === 'partial' || (job.status === 'running' && job.reviews_count && job.reviews_count > 0)) && job.reviews_count && job.reviews_count > 0 && (
<div className="flex gap-3">
<button
onClick={async () => {
setError('');
setIsLoadingReviews(true);
try {
console.log('Fetching reviews for job:', job.job_id);
const reviewsResponse = await fetch(`/api/jobs/${job.job_id}/reviews?limit=10000`);
if (!reviewsResponse.ok) {
throw new Error(`Failed to fetch reviews: ${reviewsResponse.status}`);
}
const reviewsData = await reviewsResponse.json();
console.log('Reviews fetched:', reviewsData);
if (!reviewsData.reviews || reviewsData.reviews.length === 0) {
setError('No reviews found for this job');
setIsLoadingReviews(false);
return;
}
setReviews(reviewsData.reviews);
setActiveJobId(job.job_id);
// Call parent callback if provided (for right panel display)
if (onSelectReviews) {
onSelectReviews(reviewsData.reviews, searchedQuery || 'Business', job.job_id);
} else {
setShowAnalytics(true);
}
} catch (err) {
console.error('Failed to fetch reviews:', err);
setError(err instanceof Error ? err.message : 'Failed to load reviews for analysis');
} finally {
setIsLoadingReviews(false);
}
}}
disabled={isLoadingReviews}
className={`flex-1 py-4 text-white rounded-xl font-bold transition-all flex items-center justify-center gap-2 shadow-lg disabled:opacity-50 disabled:cursor-not-allowed text-lg border-2 ${
job.status === 'partial'
? 'bg-gradient-to-r from-orange-500 to-amber-600 hover:from-orange-600 hover:to-amber-700 border-orange-400'
: job.status === 'running'
? 'bg-gradient-to-r from-blue-500 to-cyan-600 hover:from-blue-600 hover:to-cyan-700 border-blue-400'
: 'bg-gradient-to-r from-blue-600 to-indigo-700 hover:from-blue-700 hover:to-indigo-800 border-blue-500'
}`}
>
{isLoadingReviews ? (
<>
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
Loading Reviews...
</>
) : (
<>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
📊 {job.status === 'running' ? 'Preview Analytics' : job.status === 'partial' ? 'View Partial Data' : 'Open Analytics Dashboard'}
</>
)}
</button>
<button
onClick={async () => {
try {
const reviewsResponse = await fetch(`/api/jobs/${job.job_id}/reviews?limit=10000`);
if (reviewsResponse.ok) {
const reviewsData = await reviewsResponse.json();
const data = JSON.stringify(reviewsData.reviews, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `reviews-${job.job_id}${job.status === 'partial' ? '-partial' : ''}.json`;
a.click();
}
} catch (err) {
console.error('Failed to export reviews:', err);
}
}}
className="px-6 py-4 bg-gray-700 hover:bg-gray-800 text-white border-2 border-gray-600 rounded-xl font-bold transition-colors 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Export JSON
</button>
</div>
)}
{/* Partial Job Warning */}
{job.status === 'partial' && (
<div className="mt-4 p-4 bg-orange-100 border-2 border-orange-300 rounded-lg">
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-orange-700 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<div>
<p className="font-bold text-orange-900">Partial Results</p>
<p className="text-sm text-orange-800 mt-1">
This job was interrupted but {job.reviews_count} reviews were saved.
{job.error_message && <span className="block mt-1 text-orange-700">Reason: {job.error_message}</span>}
</p>
</div>
</div>
</div>
)}
{/* Error Message */}
{job.status === 'failed' && job.error_message && (
<div className="mt-4 p-4 bg-red-100 border-2 border-red-300 rounded-lg">
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-red-700 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<div>
<p className="font-bold text-red-900">Error</p>
<p className="text-sm text-red-800 mt-1">{job.error_message}</p>
</div>
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Analytics Dashboard or Simple Review List */}
{reviews.length > 0 && (
<>
{showAnalytics ? (
<div>
<div className="mb-4">
<button
onClick={() => setShowAnalytics(false)}
className="flex items-center gap-2 text-blue-600 hover:text-blue-700 font-medium"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Simple View
</button>
</div>
<ReviewAnalytics reviews={reviews} businessName={searchedQuery || 'Business'} />
</div>
) : (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold text-gray-900">
Reviews ({reviews.length})
</h3>
<button
onClick={() => {
const data = JSON.stringify(reviews, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `reviews-${activeJobId || 'export'}.json`;
a.click();
}}
className="px-4 py-2 text-sm bg-gray-700 hover:bg-gray-800 text-white border-2 border-gray-600 rounded-lg font-bold transition-colors shadow-md"
>
Export JSON
</button>
</div>
<div className="space-y-3 max-h-[600px] overflow-y-auto pr-2">
{reviews.map((review, index) => (
<div key={`${index}-${review.review_id}`} className="p-4 bg-white border border-gray-200 rounded-xl hover:border-gray-300 transition-colors">
<div className="flex items-start gap-3">
{review.avatar_url && (
<img
src={review.avatar_url}
alt={review.author}
className="w-10 h-10 rounded-full"
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-gray-900">{review.author}</span>
<div className="flex items-center gap-1">
{[...Array(5)].map((_, i) => (
<svg
key={i}
className={`w-4 h-4 ${i < review.rating ? 'text-yellow-400' : 'text-gray-300'}`}
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
</div>
<p className="text-xs text-gray-500 mb-2">{review.date_text}</p>
{review.text && (
<p className="text-sm text-gray-700 leading-relaxed">{review.text}</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
</>
)}
{/* Confirmation Modal */}
{showConfirmModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onClick={() => setShowConfirmModal(false)}
>
<div
className="bg-white rounded-2xl shadow-2xl w-full max-w-md border-2 border-green-500 animate-fade-in"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="bg-gradient-to-r from-green-600 to-emerald-600 text-white px-6 py-5 rounded-t-xl">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h2 className="text-xl font-bold">Start Scraping?</h2>
</div>
</div>
{/* Content */}
<div className="px-6 py-5">
<p className="text-gray-700 mb-4">
This will start scraping reviews for:
</p>
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-4 mb-4">
<p className="font-bold text-green-900 text-lg">{businessName}</p>
{businessAddress && (
<p className="text-sm text-green-700 mt-1">{businessAddress}</p>
)}
</div>
<p className="text-sm text-gray-600">
The scraping job will run in the background. You can monitor progress below.
</p>
</div>
{/* Actions */}
<div className="px-6 py-4 bg-gray-50 rounded-b-xl border-t-2 border-gray-200 flex gap-3">
<button
onClick={() => setShowConfirmModal(false)}
className="flex-1 py-3 px-4 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-lg font-semibold transition-all"
>
Cancel
</button>
<button
onClick={handleConfirmScrape}
disabled={isSubmitting}
className="flex-1 py-3 px-4 bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white rounded-lg font-semibold transition-all flex items-center justify-center gap-2 disabled:opacity-50"
>
{isSubmitting ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Starting...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Confirm
</>
)}
</button>
</div>
</div>
</div>
)}
</div>
);
}