diff --git a/web/app/analytics/[id]/page.tsx b/web/app/analytics/[id]/page.tsx new file mode 100644 index 0000000..c74a3b0 --- /dev/null +++ b/web/app/analytics/[id]/page.tsx @@ -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(null); + const [reviews, setReviews] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [newCount, setNewCount] = useState(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 ( +
+
+
+

Loading analytics...

+
+
+ ); + } + + if (error) { + return ( +
+
+ + + +

Error Loading Analytics

+

{error}

+ + Back to Analytics + +
+
+ ); + } + + if (reviews.length === 0) { + return ( +
+
+ + + +

No Reviews Found

+

This job has no reviews to analyze

+ + Back to Analytics + +
+
+ ); + } + + const businessName = job ? extractBusinessName(job) : 'Business'; + const businessUrl = job?.url || ''; + + return ( +
+ {/* Breadcrumb */} +
+ Analytics + / + + {businessName} + +
+ + {/* Header with back button */} +
+

Analytics

+
+ + + + + View Job Details + + + + + + Back to Analytics + +
+
+ + +
+ ); +} diff --git a/web/app/analytics/page.tsx b/web/app/analytics/page.tsx new file mode 100644 index 0000000..3a26878 --- /dev/null +++ b/web/app/analytics/page.tsx @@ -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 ( +
+
+
+ ); + } + + return ( +
+
+

Analytics

+

+ {completedJobs.length} completed {completedJobs.length === 1 ? 'scrape' : 'scrapes'} with reviews +

+
+ + {completedJobs.length === 0 ? ( +
+ + + +

No Analytics Yet

+

Complete a scrape job to see analytics

+ + Start New Scrape + +
+ ) : ( +
+ {completedJobs.map(job => { + const businessName = extractBusinessName(job); + + return ( + +
+

+ {businessName} +

+ {job.rating_snapshot && ( + + + + + {job.rating_snapshot.toFixed(1)} + + )} +
+ +
+ {job.reviews_count} reviews + {job.scrape_time && {job.scrape_time.toFixed(1)}s} +
+ + {job.business_category && ( +
+ + {job.business_category} + +
+ )} + +
+ {new Date(job.created_at).toLocaleDateString()} at {new Date(job.created_at).toLocaleTimeString()} +
+ + ); + })} +
+ )} +
+ ); +} diff --git a/web/app/jobs/[id]/page.tsx b/web/app/jobs/[id]/page.tsx new file mode 100644 index 0000000..984f63c --- /dev/null +++ b/web/app/jobs/[id]/page.tsx @@ -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(null); + const [logs, setLogs] = useState(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 ( +
+
+
+ ); + } + + const businessName = extractBusinessName(job); + const canViewAnalytics = job.reviews_count && job.reviews_count > 0; + + return ( +
+ {/* Breadcrumb */} +
+ Jobs + / + {jobId.slice(0, 8)}... +
+ + {/* Header */} +
+
+
+

{businessName}

+ {job.business_address && ( +

{job.business_address}

+ )} +
+ + {job.status === 'running' && ( +
+ )} + {job.status.charAt(0).toUpperCase() + job.status.slice(1)} + +
+ + {/* Stats Grid */} +
+ {job.reviews_count !== null && ( +
+
{job.reviews_count.toLocaleString()}
+
Reviews
+
+ )} + {job.scrape_time !== null && ( +
+
{formatDuration(job.scrape_time)}
+
Duration
+
+ )} + {job.rating_snapshot !== null && ( +
+
+ {job.rating_snapshot.toFixed(1)} + + + +
+
Rating
+
+ )} +
+
+ {new Date(job.created_at).toLocaleDateString()} +
+
+ {new Date(job.created_at).toLocaleTimeString()} +
+
Created
+
+
+ + {/* Actions */} +
+ {canViewAnalytics && ( + + + + + View Analytics + + )} + + + + + Open in Maps + + +
+ + {/* Error Message */} + {job.error_message && ( +
+
+ + + +
+

Error

+

{job.error_message}

+
+
+
+ )} +
+ + {/* Logs Section */} +
+
+

Logs

+ {logs && ( + {logs.log_count} entries + )} +
+ +
+ {isLoadingLogs ? ( +
+
+
+ ) : logs && logs.logs.length > 0 ? ( +
+ {[...logs.logs] + .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) + .map((log, idx) => ( +
+ + {new Date(log.timestamp).toLocaleTimeString()} + + {' '} + + [{log.level}] + + {' '} + + [{log.source}] + + {' '} + {log.message} +
+ ))} +
+ ) : ( +
+

No logs available

+

Logs are recorded during scraping

+
+ )} +
+
+
+ ); +} diff --git a/web/app/jobs/page.tsx b/web/app/jobs/page.tsx new file mode 100644 index 0000000..1114132 --- /dev/null +++ b/web/app/jobs/page.tsx @@ -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(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 ( + + ); +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx index f7fa87e..ee04837 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -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({ - {children} + +
+ +
+ {children} +
+
+
); diff --git a/web/app/new/page.tsx b/web/app/new/page.tsx new file mode 100644 index 0000000..87f1303 --- /dev/null +++ b/web/app/new/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/web/app/page.tsx b/web/app/page.tsx index 834865a..151999b 100644 --- a/web/app/page.tsx +++ b/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('newScrape'); - const [jobs, setJobs] = useState([]); - const [selectedJob, setSelectedJob] = useState(null); - const [isLoadingJob, setIsLoadingJob] = useState(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 ( -
- -
- ); - - case 'jobs': - return ( - - ); - - 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 ? ( -
-
-

Analytics

- -
- -
- ) : ( -
-
-

Reports

-

- {completedJobs.length} completed {completedJobs.length === 1 ? 'scrape' : 'scrapes'} with reviews -

-
- - {completedJobs.length === 0 ? ( -
- - - -

No Reports Yet

-

Complete a scrape job to see analytics reports

- -
- ) : ( -
- {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 ( -
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" - > -
-

- {businessName} -

- {job.rating_snapshot && ( - - - - - {job.rating_snapshot.toFixed(1)} - - )} -
- -
- {job.reviews_count} reviews - {job.scrape_time && {job.scrape_time.toFixed(1)}s} -
- -
- {new Date(job.created_at).toLocaleDateString()} at {new Date(job.created_at).toLocaleTimeString()} -
- - {isLoadingJob === job.job_id && ( -
-
- Loading... -
- )} -
- ); - })} -
- )} -
- ); - } - } - }; - - return ( -
- {/* Sidebar */} - - - {/* Main Content */} -
- {renderMainContent()} -
-
- ); + redirect('/new'); } diff --git a/web/components/JobsView.tsx b/web/components/JobsView.tsx index ebe89a9..3729fdb 100644 --- a/web/components/JobsView.tsx +++ b/web/components/JobsView.tsx @@ -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 }:
)} + {status === 'partial' && ( + + + + )} {isStuck && ( @@ -571,7 +591,55 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }: ), 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 ( + +
+ {formatDuration(elapsed)} + + ); + } + + // 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 ( + + {formatDuration(elapsed)} + + ); + } + + // For partial jobs, use scrape_time or calculate from timestamps + if (isPartial) { + const time = job.scrape_time; + if (time !== null) { + return ( + + {formatDuration(time)} + + ); + } + // 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 ( + + {formatDuration(elapsed)} + + ); + } + } + + const time = job.scrape_time; if (time === null) return -; return ( @@ -592,9 +660,65 @@ export default function JobsView({ jobs, onSelectJob, isLoadingJob, onRefresh }: ), - 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 ( + +
+ {speed.toFixed(1)}/s + + ); + } + + // 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 ( + + {speed.toFixed(1)}/s + + ); + } + + // For partial jobs, show speed in orange + if (isPartial) { + const speed = calculateSpeed(job.reviews_count, job.scrape_time); + if (speed !== null) { + return ( + + {speed.toFixed(1)}/s + + ); + } + } + + const speed = calculateSpeed(job.reviews_count, job.scrape_time); if (speed === null) return -; 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 (
- {/* View Reviews */} + {/* View Reviews - available for any job with reviews */} {canView && ( )} diff --git a/web/components/Sidebar.tsx b/web/components/Sidebar.tsx index a13a0df..3a3a24b 100644 --- a/web/components/Sidebar.tsx +++ b/web/components/Sidebar.tsx @@ -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: ( ), - label: 'New Scrape', + label: 'New', + matchPaths: ['/new'], }, { - id: 'jobs' as const, + href: '/jobs', icon: ( ), label: 'Jobs', - badge: jobCount > 0 ? jobCount : undefined, + matchPaths: ['/jobs'], + badge: jobs.length > 0 ? jobs.length : undefined, }, { - id: 'reports' as const, + href: '/analytics', icon: ( ), - 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 (
{navItems.map((item) => ( - + ))}
); diff --git a/web/contexts/JobsContext.tsx b/web/contexts/JobsContext.tsx new file mode 100644 index 0000000..1e7d69b --- /dev/null +++ b/web/contexts/JobsContext.tsx @@ -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; + addJob: (job: JobStatus) => void; + updateJob: (jobId: string, updates: Partial) => void; + getJobById: (jobId: string) => JobStatus | undefined; + loadJobReviews: (jobId: string, previousJobId?: string) => Promise; +} + +const JobsContext = createContext(undefined); + +export function JobsProvider({ children }: { children: ReactNode }) { + const [jobs, setJobs] = useState([]); + 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) => { + 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 => { + 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 ( + + {children} + + ); +} + +export function useJobs() { + const context = useContext(JobsContext); + if (!context) { + throw new Error('useJobs must be used within a JobsProvider'); + } + return context; +}