Add URL-based routing with sidebar navigation
Replace client-side state switching with proper Next.js routes: - /new - New scrape form - /jobs - Jobs list with table view - /jobs/[id] - Individual job details and logs - /analytics - Analytics overview (completed jobs) - /analytics/[id] - Analytics for specific job Add JobsContext for shared state across routes. Update Sidebar to use next/link with pathname matching. Root page redirects to /new. Also adds partial job status styling to JobsView. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
183
web/app/analytics/[id]/page.tsx
Normal file
183
web/app/analytics/[id]/page.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useJobs } from '@/contexts/JobsContext';
|
||||
import ReviewAnalytics from '@/components/ReviewAnalytics';
|
||||
import { JobStatus } from '@/components/ScraperTest';
|
||||
|
||||
interface Review {
|
||||
author: string;
|
||||
rating: number;
|
||||
text: string | null;
|
||||
date_text: string;
|
||||
avatar_url: string | null;
|
||||
profile_url: string | null;
|
||||
review_id: string;
|
||||
is_new?: boolean;
|
||||
}
|
||||
|
||||
function extractBusinessName(job: JobStatus): string {
|
||||
if (job.business_name) return job.business_name;
|
||||
try {
|
||||
const urlObj = new URL(job.url);
|
||||
const query = urlObj.searchParams.get('query');
|
||||
return query ? decodeURIComponent(query) : 'Unknown Business';
|
||||
} catch {
|
||||
return 'Unknown Business';
|
||||
}
|
||||
}
|
||||
|
||||
export default function AnalyticsDetailPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const { jobs } = useJobs();
|
||||
|
||||
const jobId = params.id as string;
|
||||
const compareJobId = searchParams.get('compare');
|
||||
|
||||
const [job, setJob] = useState<JobStatus | null>(null);
|
||||
const [reviews, setReviews] = useState<Review[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newCount, setNewCount] = useState<number | undefined>(undefined);
|
||||
|
||||
// Find job from context or fetch it
|
||||
useEffect(() => {
|
||||
const foundJob = jobs.find(j => j.job_id === jobId);
|
||||
if (foundJob) {
|
||||
setJob(foundJob);
|
||||
} else {
|
||||
fetch(`/api/jobs/${jobId}`)
|
||||
.then(res => res.json())
|
||||
.then(data => setJob(data))
|
||||
.catch(err => setError(err.message));
|
||||
}
|
||||
}, [jobId, jobs]);
|
||||
|
||||
// Fetch reviews
|
||||
useEffect(() => {
|
||||
if (!jobId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const url = compareJobId
|
||||
? `/api/jobs/${jobId}/compare?previous=${compareJobId}`
|
||||
: `/api/jobs/${jobId}/reviews?limit=10000`;
|
||||
|
||||
fetch(url)
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('Failed to fetch reviews');
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
setReviews(data.reviews || []);
|
||||
setNewCount(data.new_count);
|
||||
})
|
||||
.catch(err => setError(err.message))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [jobId, compareJobId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">Loading analytics...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<svg className="w-16 h-16 text-red-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
|
||||
<h3 className="text-xl font-semibold text-gray-700 mb-2">Error Loading Analytics</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">{error}</p>
|
||||
<Link
|
||||
href="/analytics"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Back to Analytics
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (reviews.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<svg className="w-16 h-16 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<h3 className="text-xl font-semibold text-gray-700 mb-2">No Reviews Found</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">This job has no reviews to analyze</p>
|
||||
<Link
|
||||
href="/analytics"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Back to Analytics
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const businessName = job ? extractBusinessName(job) : 'Business';
|
||||
const businessUrl = job?.url || '';
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4 flex items-center gap-2 text-sm text-gray-500">
|
||||
<Link href="/analytics" className="hover:text-blue-600">Analytics</Link>
|
||||
<span>/</span>
|
||||
<span className="text-gray-900 font-medium truncate max-w-[200px]" title={businessName}>
|
||||
{businessName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Header with back button */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-900">Analytics</h1>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href={`/jobs/${jobId}`}
|
||||
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
View Job Details
|
||||
</Link>
|
||||
<Link
|
||||
href="/analytics"
|
||||
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg font-medium transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" 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 Analytics
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReviewAnalytics
|
||||
reviews={reviews}
|
||||
businessName={businessName}
|
||||
businessUrl={businessUrl}
|
||||
newCount={newCount}
|
||||
businessCategory={job?.business_category || undefined}
|
||||
reviewTopics={job?.review_topics || undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
web/app/analytics/page.tsx
Normal file
105
web/app/analytics/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useJobs } from '@/contexts/JobsContext';
|
||||
import { JobStatus } from '@/components/ScraperTest';
|
||||
|
||||
function extractBusinessName(job: JobStatus): string {
|
||||
if (job.business_name) return job.business_name;
|
||||
try {
|
||||
const urlObj = new URL(job.url);
|
||||
const query = urlObj.searchParams.get('query');
|
||||
return query ? decodeURIComponent(query) : 'Unknown Business';
|
||||
} catch {
|
||||
return 'Unknown Business';
|
||||
}
|
||||
}
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const { jobs, isLoading } = useJobs();
|
||||
|
||||
// Filter to only completed jobs with reviews
|
||||
const completedJobs = jobs
|
||||
.filter(j => j.status === 'completed' && j.reviews_count && j.reviews_count > 0)
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Analytics</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{completedJobs.length} completed {completedJobs.length === 1 ? 'scrape' : 'scrapes'} with reviews
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{completedJobs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
||||
<svg className="w-20 h-20 mb-4 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
|
||||
<h3 className="text-xl font-semibold text-gray-700 mb-2">No Analytics Yet</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">Complete a scrape job to see analytics</p>
|
||||
<Link
|
||||
href="/new"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Start New Scrape
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{completedJobs.map(job => {
|
||||
const businessName = extractBusinessName(job);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={job.job_id}
|
||||
href={`/analytics/${job.job_id}`}
|
||||
className="bg-white rounded-xl border-2 border-gray-200 p-5 hover:border-blue-400 hover:shadow-lg transition-all block"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="font-bold text-gray-900 truncate flex-1" title={businessName}>
|
||||
{businessName}
|
||||
</h3>
|
||||
{job.rating_snapshot && (
|
||||
<span className="flex items-center gap-1 text-yellow-600 font-semibold ml-2">
|
||||
<svg className="w-4 h-4" 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>
|
||||
{job.rating_snapshot.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600 mb-3">
|
||||
<span className="font-semibold text-blue-700">{job.reviews_count} reviews</span>
|
||||
{job.scrape_time && <span>{job.scrape_time.toFixed(1)}s</span>}
|
||||
</div>
|
||||
|
||||
{job.business_category && (
|
||||
<div className="mb-3">
|
||||
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs font-medium rounded-full">
|
||||
{job.business_category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(job.created_at).toLocaleDateString()} at {new Date(job.created_at).toLocaleTimeString()}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
288
web/app/jobs/[id]/page.tsx
Normal file
288
web/app/jobs/[id]/page.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useJobs } from '@/contexts/JobsContext';
|
||||
import { JobStatus } from '@/components/ScraperTest';
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
message: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface JobLogs {
|
||||
job_id: string;
|
||||
status: string;
|
||||
error_message: string | null;
|
||||
logs: LogEntry[];
|
||||
log_count: number;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.round(seconds % 60);
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
|
||||
function extractBusinessName(job: JobStatus): string {
|
||||
if (job.business_name) return job.business_name;
|
||||
try {
|
||||
const urlObj = new URL(job.url);
|
||||
const query = urlObj.searchParams.get('query');
|
||||
return query ? decodeURIComponent(query) : 'Unknown Business';
|
||||
} catch {
|
||||
return 'Unknown Business';
|
||||
}
|
||||
}
|
||||
|
||||
export default function JobDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { jobs, refreshJobs } = useJobs();
|
||||
const [job, setJob] = useState<JobStatus | null>(null);
|
||||
const [logs, setLogs] = useState<JobLogs | null>(null);
|
||||
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const jobId = params.id as string;
|
||||
|
||||
// Find job from context or fetch it
|
||||
useEffect(() => {
|
||||
const foundJob = jobs.find(j => j.job_id === jobId);
|
||||
if (foundJob) {
|
||||
setJob(foundJob);
|
||||
} else {
|
||||
// Fetch job directly if not in context
|
||||
fetch(`/api/jobs/${jobId}`)
|
||||
.then(res => res.json())
|
||||
.then(data => setJob(data))
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [jobId, jobs]);
|
||||
|
||||
// Fetch logs
|
||||
useEffect(() => {
|
||||
if (!jobId) return;
|
||||
setIsLoadingLogs(true);
|
||||
fetch(`/api/jobs/${jobId}/logs`)
|
||||
.then(res => res.json())
|
||||
.then(data => setLogs(data))
|
||||
.catch(console.error)
|
||||
.finally(() => setIsLoadingLogs(false));
|
||||
}, [jobId]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Are you sure you want to delete this job?')) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await fetch(`/api/jobs/${jobId}`, { method: 'DELETE' });
|
||||
await refreshJobs();
|
||||
router.push('/jobs');
|
||||
} catch (err) {
|
||||
console.error('Failed to delete job:', err);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!job) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const businessName = extractBusinessName(job);
|
||||
const canViewAnalytics = job.reviews_count && job.reviews_count > 0;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4 flex items-center gap-2 text-sm text-gray-500">
|
||||
<Link href="/jobs" className="hover:text-blue-600">Jobs</Link>
|
||||
<span>/</span>
|
||||
<span className="text-gray-900 font-medium">{jobId.slice(0, 8)}...</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-white border-2 border-gray-200 rounded-xl p-6 mb-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-1">{businessName}</h1>
|
||||
{job.business_address && (
|
||||
<p className="text-gray-500 text-sm">{job.business_address}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-semibold ${
|
||||
job.status === 'completed' ? 'bg-green-100 text-green-800' :
|
||||
job.status === 'partial' ? 'bg-orange-100 text-orange-800' :
|
||||
job.status === 'running' ? 'bg-blue-100 text-blue-800' :
|
||||
job.status === 'failed' ? 'bg-red-100 text-red-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{job.status === 'running' && (
|
||||
<div className="w-2 h-2 border border-current border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
{job.status.charAt(0).toUpperCase() + job.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
{job.reviews_count !== null && (
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-800">{job.reviews_count.toLocaleString()}</div>
|
||||
<div className="text-xs font-medium text-blue-600">Reviews</div>
|
||||
</div>
|
||||
)}
|
||||
{job.scrape_time !== null && (
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-800">{formatDuration(job.scrape_time)}</div>
|
||||
<div className="text-xs font-medium text-green-600">Duration</div>
|
||||
</div>
|
||||
)}
|
||||
{job.rating_snapshot !== null && (
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="text-2xl font-bold text-yellow-800 flex items-center gap-1">
|
||||
{job.rating_snapshot.toFixed(1)}
|
||||
<svg className="w-5 h-5" 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 className="text-xs font-medium text-yellow-600">Rating</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<div className="text-sm font-bold text-gray-800">
|
||||
{new Date(job.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(job.created_at).toLocaleTimeString()}
|
||||
</div>
|
||||
<div className="text-xs font-medium text-gray-600 mt-1">Created</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
{canViewAnalytics && (
|
||||
<Link
|
||||
href={`/analytics/${jobId}`}
|
||||
className="flex-1 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-semibold transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" 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>
|
||||
View Analytics
|
||||
</Link>
|
||||
)}
|
||||
<a
|
||||
href={job.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-xl font-semibold transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" 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>
|
||||
Open in Maps
|
||||
</a>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting || job.status === 'running'}
|
||||
className="px-6 py-3 bg-red-100 hover:bg-red-200 text-red-700 rounded-xl font-semibold transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<div className="w-5 h-5 border-2 border-red-500 border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
)}
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{job.error_message && (
|
||||
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-5 h-5 text-red-600 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-semibold text-red-800">Error</p>
|
||||
<p className="text-sm text-red-700">{job.error_message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logs Section */}
|
||||
<div className="bg-white border-2 border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-gray-900">Logs</h2>
|
||||
{logs && (
|
||||
<span className="text-sm text-gray-500">{logs.log_count} entries</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[500px] overflow-y-auto p-4 bg-gray-900">
|
||||
{isLoadingLogs ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : logs && logs.logs.length > 0 ? (
|
||||
<div className="space-y-1 font-mono text-xs">
|
||||
{[...logs.logs]
|
||||
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
||||
.map((log, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`px-2 py-1 rounded ${
|
||||
log.level === 'ERROR' ? 'bg-red-900/30 text-red-300' :
|
||||
log.level === 'WARNING' ? 'bg-yellow-900/30 text-yellow-300' :
|
||||
log.level === 'INFO' ? 'text-green-300' :
|
||||
'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<span className="text-gray-500">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
{' '}
|
||||
<span className={`font-bold ${
|
||||
log.level === 'ERROR' ? 'text-red-400' :
|
||||
log.level === 'WARNING' ? 'text-yellow-400' :
|
||||
log.level === 'INFO' ? 'text-blue-400' :
|
||||
'text-gray-500'
|
||||
}`}>
|
||||
[{log.level}]
|
||||
</span>
|
||||
{' '}
|
||||
<span className={`${
|
||||
log.source === 'browser' ? 'text-purple-400' : 'text-green-400'
|
||||
}`}>
|
||||
[{log.source}]
|
||||
</span>
|
||||
{' '}
|
||||
<span>{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
<p className="font-medium">No logs available</p>
|
||||
<p className="text-sm">Logs are recorded during scraping</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
web/app/jobs/page.tsx
Normal file
30
web/app/jobs/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import JobsView from '@/components/JobsView';
|
||||
import { useJobs } from '@/contexts/JobsContext';
|
||||
import { JobStatus } from '@/components/ScraperTest';
|
||||
|
||||
export default function JobsPage() {
|
||||
const router = useRouter();
|
||||
const { jobs, refreshJobs } = useJobs();
|
||||
const [isLoadingJob, setIsLoadingJob] = useState<string | null>(null);
|
||||
|
||||
const handleSelectJob = async (job: JobStatus, previousJob?: JobStatus) => {
|
||||
// Navigate to analytics page for this job
|
||||
const url = previousJob
|
||||
? `/analytics/${job.job_id}?compare=${previousJob.job_id}`
|
||||
: `/analytics/${job.job_id}`;
|
||||
router.push(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<JobsView
|
||||
jobs={jobs}
|
||||
onSelectJob={handleSelectJob}
|
||||
isLoadingJob={isLoadingJob}
|
||||
onRefresh={refreshJobs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { JobsProvider } from "@/contexts/JobsContext";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -13,8 +15,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Google Reviews Scraper Pro",
|
||||
description: "Scrape and analyze Google Reviews",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -27,7 +29,14 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<JobsProvider>
|
||||
<div className="h-screen w-screen overflow-hidden flex">
|
||||
<Sidebar />
|
||||
<div className="flex-1 bg-gray-50 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</JobsProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
30
web/app/new/page.tsx
Normal file
30
web/app/new/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import ScraperTest from '@/components/ScraperTest';
|
||||
import { useJobs } from '@/contexts/JobsContext';
|
||||
import { JobStatus } from '@/components/ScraperTest';
|
||||
|
||||
export default function NewScrapePage() {
|
||||
const router = useRouter();
|
||||
const { addJob, refreshJobs } = useJobs();
|
||||
|
||||
const handleJobsChange = (jobs: JobStatus[]) => {
|
||||
// Add new jobs to context
|
||||
jobs.forEach(job => addJob(job));
|
||||
};
|
||||
|
||||
const handleSelectReviews = (reviews: unknown[], businessName: string, jobId: string) => {
|
||||
// Navigate to analytics page for this job
|
||||
router.push(`/analytics/${jobId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
<ScraperTest
|
||||
onJobsChange={handleJobsChange}
|
||||
onSelectReviews={handleSelectReviews}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
web/app/page.tsx
262
web/app/page.tsx
@@ -1,263 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import ScraperTest, { JobStatus } from '@/components/ScraperTest';
|
||||
import ReviewAnalytics from '@/components/ReviewAnalytics';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import JobsView from '@/components/JobsView';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
interface Review {
|
||||
author: string;
|
||||
rating: number;
|
||||
text: string | null;
|
||||
date_text: string;
|
||||
avatar_url: string | null;
|
||||
profile_url: string | null;
|
||||
review_id: string;
|
||||
}
|
||||
|
||||
interface ReviewWithNew extends Review {
|
||||
is_new?: boolean;
|
||||
}
|
||||
|
||||
interface SelectedJob {
|
||||
reviews: ReviewWithNew[];
|
||||
businessName: string;
|
||||
businessUrl: string;
|
||||
jobId: string;
|
||||
newCount?: number;
|
||||
previousJobId?: string;
|
||||
businessCategory?: string;
|
||||
reviewTopics?: { topic: string; count: number }[];
|
||||
}
|
||||
|
||||
type ViewType = 'newScrape' | 'jobs' | 'reports';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
const [activeView, setActiveView] = useState<ViewType>('newScrape');
|
||||
const [jobs, setJobs] = useState<JobStatus[]>([]);
|
||||
const [selectedJob, setSelectedJob] = useState<SelectedJob | null>(null);
|
||||
const [isLoadingJob, setIsLoadingJob] = useState<string | null>(null);
|
||||
|
||||
// Load jobs from API
|
||||
const refreshJobs = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/jobs?limit=100');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.jobs) {
|
||||
setJobs(data.jobs);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load jobs:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load jobs from API on mount
|
||||
useEffect(() => {
|
||||
refreshJobs();
|
||||
}, [refreshJobs]);
|
||||
|
||||
const handleJobsChange = useCallback((newJobs: JobStatus[]) => {
|
||||
setJobs(prev => {
|
||||
// Merge new jobs with existing, updating duplicates
|
||||
const jobMap = new Map(prev.map(j => [j.job_id, j]));
|
||||
newJobs.forEach(job => jobMap.set(job.job_id, job));
|
||||
return Array.from(jobMap.values());
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelectReviews = useCallback((reviews: Review[], businessName: string, jobId: string, businessUrl?: string) => {
|
||||
setSelectedJob({ reviews, businessName, businessUrl: businessUrl || '', jobId });
|
||||
setActiveView('reports');
|
||||
}, []);
|
||||
|
||||
const loadJobReviews = async (job: JobStatus, previousJob?: JobStatus) => {
|
||||
if (job.status !== 'completed' || !job.reviews_count) return;
|
||||
|
||||
setIsLoadingJob(job.job_id);
|
||||
try {
|
||||
// Use compare API if we have a previous job
|
||||
const url = previousJob
|
||||
? `/api/jobs/${job.job_id}/compare?previous=${previousJob.job_id}`
|
||||
: `/api/jobs/${job.job_id}/reviews?limit=10000`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Failed to fetch reviews');
|
||||
const data = await response.json();
|
||||
|
||||
const reviews = data.reviews || [];
|
||||
if (reviews.length > 0) {
|
||||
// Extract business name from URL query param as fallback
|
||||
let businessName = job.business_name;
|
||||
if (!businessName) {
|
||||
try {
|
||||
const urlObj = new URL(job.url);
|
||||
const query = urlObj.searchParams.get('query');
|
||||
businessName = query ? decodeURIComponent(query) : 'Unknown Business';
|
||||
} catch {
|
||||
businessName = 'Unknown Business';
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedJob({
|
||||
reviews,
|
||||
businessName,
|
||||
businessUrl: job.url,
|
||||
jobId: job.job_id,
|
||||
newCount: data.new_count,
|
||||
previousJobId: previousJob?.job_id,
|
||||
businessCategory: job.business_category || undefined,
|
||||
reviewTopics: job.review_topics || undefined,
|
||||
});
|
||||
setActiveView('reports');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load job reviews:', err);
|
||||
} finally {
|
||||
setIsLoadingJob(null);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMainContent = () => {
|
||||
switch (activeView) {
|
||||
case 'newScrape':
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
<ScraperTest onJobsChange={handleJobsChange} onSelectReviews={handleSelectReviews} />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'jobs':
|
||||
return (
|
||||
<JobsView
|
||||
jobs={jobs}
|
||||
onSelectJob={loadJobReviews}
|
||||
isLoadingJob={isLoadingJob}
|
||||
onRefresh={refreshJobs}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'reports': {
|
||||
// Get completed jobs with reviews
|
||||
const completedJobs = jobs
|
||||
.filter(j => j.status === 'completed' && j.reviews_count && j.reviews_count > 0)
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
|
||||
return selectedJob ? (
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">Analytics</h2>
|
||||
<button
|
||||
onClick={() => setSelectedJob(null)}
|
||||
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg font-medium transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" 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 Reports
|
||||
</button>
|
||||
</div>
|
||||
<ReviewAnalytics reviews={selectedJob.reviews} businessName={selectedJob.businessName} businessUrl={selectedJob.businessUrl} newCount={selectedJob.newCount} businessCategory={selectedJob.businessCategory} reviewTopics={selectedJob.reviewTopics} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Reports</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{completedJobs.length} completed {completedJobs.length === 1 ? 'scrape' : 'scrapes'} with reviews
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{completedJobs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
|
||||
<svg className="w-20 h-20 mb-4 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
|
||||
<h3 className="text-xl font-semibold text-gray-700 mb-2">No Reports Yet</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">Complete a scrape job to see analytics reports</p>
|
||||
<button
|
||||
onClick={() => setActiveView('newScrape')}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Start New Scrape
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{completedJobs.map(job => {
|
||||
// Extract business name from URL as fallback
|
||||
let businessName = job.business_name;
|
||||
if (!businessName) {
|
||||
try {
|
||||
const urlObj = new URL(job.url);
|
||||
const query = urlObj.searchParams.get('query');
|
||||
businessName = query ? decodeURIComponent(query) : 'Unknown Business';
|
||||
} catch {
|
||||
businessName = 'Unknown Business';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={job.job_id}
|
||||
onClick={() => loadJobReviews(job)}
|
||||
className="bg-white rounded-xl border-2 border-gray-200 p-5 cursor-pointer hover:border-blue-400 hover:shadow-lg transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="font-bold text-gray-900 truncate flex-1" title={businessName}>
|
||||
{businessName}
|
||||
</h3>
|
||||
{job.rating_snapshot && (
|
||||
<span className="flex items-center gap-1 text-yellow-600 font-semibold ml-2">
|
||||
<svg className="w-4 h-4" 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>
|
||||
{job.rating_snapshot.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600 mb-3">
|
||||
<span className="font-semibold text-blue-700">{job.reviews_count} reviews</span>
|
||||
{job.scrape_time && <span>{job.scrape_time.toFixed(1)}s</span>}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(job.created_at).toLocaleDateString()} at {new Date(job.created_at).toLocaleTimeString()}
|
||||
</div>
|
||||
|
||||
{isLoadingJob === job.job_id && (
|
||||
<div className="mt-3 flex items-center gap-2 text-blue-600">
|
||||
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm font-medium">Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden flex">
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
activeView={activeView}
|
||||
onViewChange={setActiveView}
|
||||
jobCount={jobs.length}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 bg-gray-50 overflow-hidden">
|
||||
{renderMainContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
redirect('/new');
|
||||
}
|
||||
|
||||
@@ -154,6 +154,20 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('job_partial', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
setRunningJobUpdates(prev => {
|
||||
const updated = new Map(prev);
|
||||
updated.delete(data.job_id);
|
||||
return updated;
|
||||
});
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
console.error('Failed to parse job_partial event:', err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
console.log('SSE connection error, reconnecting...');
|
||||
eventSource?.close();
|
||||
@@ -499,6 +513,7 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold ${
|
||||
status === 'completed' ? 'bg-green-100 text-green-800' :
|
||||
status === 'partial' ? 'bg-orange-100 text-orange-800' :
|
||||
isStuck ? 'bg-red-100 text-red-800' :
|
||||
status === 'running' ? 'bg-blue-100 text-blue-800' :
|
||||
status === 'failed' ? 'bg-red-100 text-red-800' :
|
||||
@@ -507,6 +522,11 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
|
||||
{status === 'running' && !isStuck && (
|
||||
<div className="w-2 h-2 border border-current border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
{status === 'partial' && (
|
||||
<svg className="w-3 h-3" 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>
|
||||
)}
|
||||
{isStuck && (
|
||||
<svg className="w-3 h-3" 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" />
|
||||
@@ -571,7 +591,55 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
|
||||
</button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const time = row.original.scrape_time;
|
||||
const job = row.original;
|
||||
const isRunning = job.status === 'running';
|
||||
const isPartial = job.status === 'partial';
|
||||
const isStuck = isRunning &&
|
||||
new Date().getTime() - new Date(job.created_at).getTime() > 10 * 60 * 1000;
|
||||
|
||||
// For actively running jobs (not stuck), show live elapsed time
|
||||
if (isRunning && !isStuck && job.started_at) {
|
||||
const elapsed = (Date.now() - new Date(job.started_at).getTime()) / 1000;
|
||||
return (
|
||||
<span className="font-medium text-blue-600 flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||
{formatDuration(elapsed)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// For stuck jobs, show frozen elapsed time in red (no pulse)
|
||||
if (isStuck && job.started_at) {
|
||||
const elapsed = (Date.now() - new Date(job.started_at).getTime()) / 1000;
|
||||
return (
|
||||
<span className="font-medium text-red-600">
|
||||
{formatDuration(elapsed)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// For partial jobs, use scrape_time or calculate from timestamps
|
||||
if (isPartial) {
|
||||
const time = job.scrape_time;
|
||||
if (time !== null) {
|
||||
return (
|
||||
<span className="font-medium text-orange-600">
|
||||
{formatDuration(time)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// Fallback: calculate from started_at to completed_at
|
||||
if (job.started_at && job.completed_at) {
|
||||
const elapsed = (new Date(job.completed_at).getTime() - new Date(job.started_at).getTime()) / 1000;
|
||||
return (
|
||||
<span className="font-medium text-orange-600">
|
||||
{formatDuration(elapsed)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const time = job.scrape_time;
|
||||
if (time === null) return <span className="text-gray-400">-</span>;
|
||||
|
||||
return (
|
||||
@@ -592,9 +660,65 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
|
||||
<SortIcon sorted={column.getIsSorted()} />
|
||||
</button>
|
||||
),
|
||||
accessorFn: (row) => calculateSpeed(row.reviews_count, row.scrape_time),
|
||||
accessorFn: (row) => {
|
||||
const isStuck = row.status === 'running' &&
|
||||
new Date().getTime() - new Date(row.created_at).getTime() > 10 * 60 * 1000;
|
||||
|
||||
// For actively running jobs (not stuck), calculate speed from elapsed time
|
||||
if (row.status === 'running' && !isStuck && row.started_at && row.reviews_count) {
|
||||
const elapsed = (Date.now() - new Date(row.started_at).getTime()) / 1000;
|
||||
return elapsed > 0 ? row.reviews_count / elapsed : null;
|
||||
}
|
||||
return calculateSpeed(row.reviews_count, row.scrape_time);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const speed = calculateSpeed(row.original.reviews_count, row.original.scrape_time);
|
||||
const job = row.original;
|
||||
const isRunning = job.status === 'running';
|
||||
const isPartial = job.status === 'partial';
|
||||
const isStuck = isRunning &&
|
||||
new Date().getTime() - new Date(job.created_at).getTime() > 10 * 60 * 1000;
|
||||
|
||||
// For actively running jobs (not stuck), show live speed
|
||||
if (isRunning && !isStuck && job.started_at && job.reviews_count) {
|
||||
const elapsed = (Date.now() - new Date(job.started_at).getTime()) / 1000;
|
||||
const speed = elapsed > 0 ? job.reviews_count / elapsed : 0;
|
||||
const isGood = speed >= 1;
|
||||
const isSlow = speed < 0.5;
|
||||
|
||||
return (
|
||||
<span className={`font-medium flex items-center gap-1 ${
|
||||
isGood ? 'text-green-600' : isSlow ? 'text-orange-500' : 'text-blue-600'
|
||||
}`}>
|
||||
<div className="w-2 h-2 bg-current rounded-full animate-pulse" />
|
||||
{speed.toFixed(1)}/s
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// For stuck jobs, show frozen speed in red
|
||||
if (isStuck && job.started_at && job.reviews_count) {
|
||||
const elapsed = (Date.now() - new Date(job.started_at).getTime()) / 1000;
|
||||
const speed = elapsed > 0 ? job.reviews_count / elapsed : 0;
|
||||
return (
|
||||
<span className="font-medium text-red-600">
|
||||
{speed.toFixed(1)}/s
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// For partial jobs, show speed in orange
|
||||
if (isPartial) {
|
||||
const speed = calculateSpeed(job.reviews_count, job.scrape_time);
|
||||
if (speed !== null) {
|
||||
return (
|
||||
<span className="font-medium text-orange-600">
|
||||
{speed.toFixed(1)}/s
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const speed = calculateSpeed(job.reviews_count, job.scrape_time);
|
||||
if (speed === null) return <span className="text-gray-400">-</span>;
|
||||
|
||||
const isGood = speed >= 1;
|
||||
@@ -644,21 +768,38 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }:
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
const job = row.original;
|
||||
const canView = job.status === 'completed' && job.reviews_count;
|
||||
const canView = job.reviews_count && job.reviews_count > 0;
|
||||
const isRunning = job.status === 'running';
|
||||
const isPartial = job.status === 'partial';
|
||||
const previousJob = findPreviousJob(job);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View Reviews */}
|
||||
{/* View Reviews - available for any job with reviews */}
|
||||
{canView && (
|
||||
<button
|
||||
onClick={() => onSelectJob(job, previousJob)}
|
||||
className="px-2.5 py-1.5 bg-blue-600 text-white text-xs font-semibold rounded-lg hover:bg-blue-700 transition-colors"
|
||||
className={`px-2.5 py-1.5 text-xs font-semibold rounded-lg transition-colors flex items-center gap-1.5 ${
|
||||
isRunning
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: isPartial
|
||||
? 'bg-orange-600 text-white hover:bg-orange-700'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
title={isRunning ? 'Preview analytics (job still running)' : isPartial ? 'View partial results' : 'View analytics'}
|
||||
>
|
||||
{isLoadingJob === job.job_id ? (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
'View'
|
||||
<>
|
||||
{isRunning && (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
{isRunning ? 'Preview' : isPartial ? 'Partial' : 'View'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,64 +1,73 @@
|
||||
'use client';
|
||||
|
||||
interface SidebarProps {
|
||||
activeView: 'newScrape' | 'jobs' | 'reports';
|
||||
onViewChange: (view: 'newScrape' | 'jobs' | 'reports') => void;
|
||||
jobCount: number;
|
||||
}
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useJobs } from '@/contexts/JobsContext';
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { jobs } = useJobs();
|
||||
|
||||
export default function Sidebar({ activeView, onViewChange, jobCount }: SidebarProps) {
|
||||
const navItems = [
|
||||
{
|
||||
id: 'newScrape' as const,
|
||||
href: '/new',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
),
|
||||
label: 'New Scrape',
|
||||
label: 'New',
|
||||
matchPaths: ['/new'],
|
||||
},
|
||||
{
|
||||
id: 'jobs' as const,
|
||||
href: '/jobs',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
),
|
||||
label: 'Jobs',
|
||||
badge: jobCount > 0 ? jobCount : undefined,
|
||||
matchPaths: ['/jobs'],
|
||||
badge: jobs.length > 0 ? jobs.length : undefined,
|
||||
},
|
||||
{
|
||||
id: 'reports' as const,
|
||||
href: '/analytics',
|
||||
icon: (
|
||||
<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>
|
||||
),
|
||||
label: 'Reports',
|
||||
label: 'Analytics',
|
||||
matchPaths: ['/analytics'],
|
||||
},
|
||||
];
|
||||
|
||||
const isActive = (item: typeof navItems[0]) => {
|
||||
// Check if current path starts with any of the match paths
|
||||
return item.matchPaths.some(path => pathname.startsWith(path));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-20 bg-gray-900 flex flex-col items-center py-6 gap-2">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onViewChange(item.id)}
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`relative w-14 h-14 rounded-xl flex flex-col items-center justify-center gap-1 transition-all ${
|
||||
activeView === item.id
|
||||
isActive(item)
|
||||
? 'bg-blue-600 text-white shadow-lg'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||
}`}
|
||||
title={item.label}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="text-[10px] font-medium">{item.label.split(' ')[0]}</span>
|
||||
<span className="text-[10px] font-medium">{item.label}</span>
|
||||
{item.badge !== undefined && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center">
|
||||
{item.badge > 99 ? '99+' : item.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
106
web/contexts/JobsContext.tsx
Normal file
106
web/contexts/JobsContext.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
|
||||
import { JobStatus } from '@/components/ScraperTest';
|
||||
|
||||
interface Review {
|
||||
author: string;
|
||||
rating: number;
|
||||
text: string | null;
|
||||
date_text: string;
|
||||
avatar_url: string | null;
|
||||
profile_url: string | null;
|
||||
review_id: string;
|
||||
is_new?: boolean;
|
||||
}
|
||||
|
||||
interface JobsContextType {
|
||||
jobs: JobStatus[];
|
||||
isLoading: boolean;
|
||||
refreshJobs: () => Promise<void>;
|
||||
addJob: (job: JobStatus) => void;
|
||||
updateJob: (jobId: string, updates: Partial<JobStatus>) => void;
|
||||
getJobById: (jobId: string) => JobStatus | undefined;
|
||||
loadJobReviews: (jobId: string, previousJobId?: string) => Promise<Review[]>;
|
||||
}
|
||||
|
||||
const JobsContext = createContext<JobsContextType | undefined>(undefined);
|
||||
|
||||
export function JobsProvider({ children }: { children: ReactNode }) {
|
||||
const [jobs, setJobs] = useState<JobStatus[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const refreshJobs = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/jobs?limit=100');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.jobs) {
|
||||
setJobs(data.jobs);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load jobs:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load jobs on mount
|
||||
useEffect(() => {
|
||||
refreshJobs();
|
||||
}, [refreshJobs]);
|
||||
|
||||
const addJob = useCallback((job: JobStatus) => {
|
||||
setJobs(prev => {
|
||||
const existing = prev.find(j => j.job_id === job.job_id);
|
||||
if (existing) {
|
||||
return prev.map(j => j.job_id === job.job_id ? { ...j, ...job } : j);
|
||||
}
|
||||
return [job, ...prev];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateJob = useCallback((jobId: string, updates: Partial<JobStatus>) => {
|
||||
setJobs(prev => prev.map(j =>
|
||||
j.job_id === jobId ? { ...j, ...updates } : j
|
||||
));
|
||||
}, []);
|
||||
|
||||
const getJobById = useCallback((jobId: string) => {
|
||||
return jobs.find(j => j.job_id === jobId);
|
||||
}, [jobs]);
|
||||
|
||||
const loadJobReviews = useCallback(async (jobId: string, previousJobId?: string): Promise<Review[]> => {
|
||||
const url = previousJobId
|
||||
? `/api/jobs/${jobId}/compare?previous=${previousJobId}`
|
||||
: `/api/jobs/${jobId}/reviews?limit=10000`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Failed to fetch reviews');
|
||||
const data = await response.json();
|
||||
return data.reviews || [];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<JobsContext.Provider value={{
|
||||
jobs,
|
||||
isLoading,
|
||||
refreshJobs,
|
||||
addJob,
|
||||
updateJob,
|
||||
getJobById,
|
||||
loadJobReviews,
|
||||
}}>
|
||||
{children}
|
||||
</JobsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useJobs() {
|
||||
const context = useContext(JobsContext);
|
||||
if (!context) {
|
||||
throw new Error('useJobs must be used within a JobsProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user