BREAKING IMPROVEMENTS:
1. Early Detection for No Reviews:
- Check for "no reviews" messages in 11+ languages before scraping
- Detect disabled reviews tabs and aria-labels with 0 reviews
- Return early with success when no reviews exist (saves time)
- Prevents wasted scraping attempts on businesses with no reviews
2. Structural Pattern Matching (Class-Agnostic):
- STRATEGY 1: Try known CSS selectors (div.jftiEf.fontBodyMedium, etc.)
- STRATEGY 2: Structural matching - find containers with review-like structure
* Looks for elements containing: author + rating + text + date
* Counts elements with 3+ review indicators (robust, works across layouts)
- STRATEGY 3: Use role="article" with review content detection
- Falls back through strategies automatically
3. Less Script-Dependent Selectors:
- Uses aria-label attributes (more stable than CSS classes)
- Uses role attributes (semantic HTML)
- Searches for structural patterns (author img + rating span + text span)
- Works across different Google Maps page layouts and languages
4. Frontend Improvement:
- Hide "Open Analytics Dashboard" button when reviews_count is 0
- Only show action buttons for completed jobs with reviews
TECHNICAL DETAILS:
Structural Matching Logic:
- Scans all divs for review indicators:
* hasAuthor: img with photo/avatar in src
* hasRating: aria-label containing "star" or "rating"
* hasText: span with 20+ characters
* hasDate: text matching date patterns (day/week/month/year)
- Element is a review if it has 3+ of these indicators
Early Detection Patterns:
- Checks page text for: "no reviews yet", "be the first to review", etc.
- Checks for "0 reviews" patterns in text and aria-labels
- Checks if reviews tab is disabled or aria-disabled
Benefits:
- Works on Lithuanian hospital page (was getting 0/271 reviews)
- Handles regional Google Maps variations automatically
- Faster exit for businesses with no reviews
- More reliable across Google Maps UI updates
- Better UX: no empty analytics dashboard for 0-review jobs
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
910 lines
40 KiB
TypeScript
910 lines
40 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export default function ScraperTest() {
|
|
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 debounceRef = useRef<NodeJS.Timeout | null>(null);
|
|
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);
|
|
}
|
|
}, [searchQuery, searchedQuery]);
|
|
|
|
// 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);
|
|
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);
|
|
} 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 }),
|
|
});
|
|
|
|
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,
|
|
reviews_count: null,
|
|
total_reviews: null,
|
|
scrape_time: null,
|
|
error_message: null,
|
|
});
|
|
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 (
|
|
<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>
|
|
);
|
|
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>
|
|
);
|
|
}
|
|
};
|
|
|
|
const embedUrl = searchedQuery
|
|
? `https://maps.google.com/maps?q=${encodeURIComponent(searchedQuery)}&output=embed&z=15`
|
|
: '';
|
|
|
|
const [mapClicked, setMapClicked] = useState(false);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const handleMapClick = () => {
|
|
setMapClicked(true);
|
|
};
|
|
|
|
const closeModal = () => {
|
|
setMapClicked(false);
|
|
};
|
|
|
|
const focusSearchBar = () => {
|
|
setMapClicked(false);
|
|
searchInputRef.current?.focus();
|
|
};
|
|
|
|
return (
|
|
<div className="w-full max-w-4xl mx-auto">
|
|
{/* 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 bg-blue-600 text-white font-semibold rounded-xl hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
|
>
|
|
{isCheckingReviews ? (
|
|
<>
|
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
Validating...
|
|
</>
|
|
) : (
|
|
<>
|
|
<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 with Click Overlay */}
|
|
<div className="mb-4 rounded-xl overflow-hidden border-2 border-gray-200 bg-gray-100 relative">
|
|
{searchedQuery ? (
|
|
<>
|
|
<iframe
|
|
src={embedUrl}
|
|
width="100%"
|
|
height="350"
|
|
style={{ border: 0, pointerEvents: 'none' }}
|
|
allowFullScreen
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer-when-downgrade"
|
|
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>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="h-[350px] 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 the map</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">
|
|
{/* Header */}
|
|
<div className="bg-gradient-to-r from-green-500 to-emerald-500 px-6 py-4">
|
|
<div className="flex items-center gap-2 text-white">
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
<span className="font-bold text-lg">Business Found</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Business Info */}
|
|
<div className="p-6">
|
|
{/* Business Name */}
|
|
<h3 className="text-2xl font-bold text-gray-900 mb-3">{businessName}</h3>
|
|
|
|
{/* Rating */}
|
|
{businessRating && (
|
|
<div className="flex items-center gap-1 mb-3">
|
|
<span className="text-2xl font-bold text-gray-900">{businessRating.toFixed(1)}</span>
|
|
<div className="flex items-center ml-1">
|
|
{[...Array(5)].map((_, i) => (
|
|
<svg
|
|
key={i}
|
|
className={`w-5 h-5 ${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>
|
|
)}
|
|
|
|
{/* Address */}
|
|
{businessAddress && (
|
|
<div className="flex items-start gap-2 text-gray-600 mb-4">
|
|
<span className="text-lg">📍</span>
|
|
<span className="text-sm">{businessAddress}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Start Scraping Button */}
|
|
<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>
|
|
Start Scraping 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 and has reviews */}
|
|
{job.status === 'completed' && 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);
|
|
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 bg-gradient-to-r from-blue-600 to-indigo-700 text-white rounded-xl font-bold hover:from-blue-700 hover:to-indigo-800 transition-all flex items-center justify-center gap-2 shadow-lg disabled:opacity-50 disabled:cursor-not-allowed text-lg border-2 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>
|
|
📊 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}.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>
|
|
)}
|
|
|
|
{/* 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>
|
|
);
|
|
}
|