/** * UK Visa Jobs search page. */ import React, { useEffect, useMemo, useState } from "react"; import { ArrowLeft, Briefcase, Calendar, ChevronLeft, ChevronRight, Clock, Database, DollarSign, ExternalLink, GraduationCap, Loader2, MapPin, Search, Settings, } from "lucide-react"; import { Link } from "react-router-dom"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"; import { cn } from "@/lib/utils"; import * as api from "../api"; import type { CreateJobInput } from "../../shared/types"; const formatDate = (dateStr?: string | null) => { if (!dateStr) return null; try { return new Date(dateStr).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric", }); } catch { return dateStr; } }; const formatDateTime = (dateStr?: string | null) => { if (!dateStr) return null; try { const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T"); const parsed = new Date(normalized); if (Number.isNaN(parsed.getTime())) return dateStr; const date = parsed.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric", }); const time = parsed.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", }); return `${date} ${time}`; } catch { return dateStr; } }; const stripHtml = (value: string) => value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); const clampText = (value: string, max = 160) => (value.length > max ? `${value.slice(0, max).trim()}...` : value); const jobKey = (job: CreateJobInput) => job.sourceJobId || job.jobUrl; export const UkVisaJobsPage: React.FC = () => { const [searchTermInput, setSearchTermInput] = useState(""); const [activeSearchTerm, setActiveSearchTerm] = useState(null); const [results, setResults] = useState([]); const [selectedJobId, setSelectedJobId] = useState(null); const [selectedJobIds, setSelectedJobIds] = useState>(new Set()); const [isSearching, setIsSearching] = useState(false); const [isImporting, setIsImporting] = useState(false); const [lastRunAt, setLastRunAt] = useState(null); const [lastSearchTerm, setLastSearchTerm] = useState(null); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(15); const [totalJobs, setTotalJobs] = useState(0); const [totalPages, setTotalPages] = useState(1); const [errorMessage, setErrorMessage] = useState(null); const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false); const [isDesktop, setIsDesktop] = useState( () => (typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false), ); useEffect(() => { if (results.length === 0) { setSelectedJobId(null); return; } const firstKey = jobKey(results[0]); if (!selectedJobId || !results.some((job) => jobKey(job) === selectedJobId)) { setSelectedJobId(firstKey); } }, [results, selectedJobId]); useEffect(() => { if (!selectedJobId) { setIsDetailDrawerOpen(false); } }, [selectedJobId]); useEffect(() => { if (typeof window === "undefined") return; const media = window.matchMedia("(min-width: 1024px)"); const handleChange = () => setIsDesktop(media.matches); handleChange(); if (media.addEventListener) { media.addEventListener("change", handleChange); return () => media.removeEventListener("change", handleChange); } media.addListener(handleChange); return () => media.removeListener(handleChange); }, []); useEffect(() => { if (isDesktop && isDetailDrawerOpen) { setIsDetailDrawerOpen(false); } }, [isDesktop, isDetailDrawerOpen]); useEffect(() => { setSelectedJobIds(new Set()); }, [results]); const selectedJob = useMemo( () => (selectedJobId ? results.find((job) => jobKey(job) === selectedJobId) ?? null : null), [results, selectedJobId], ); const summaryCounts = useMemo(() => { const startIndex = totalJobs === 0 ? 0 : (page - 1) * pageSize + 1; const endIndex = totalJobs === 0 ? 0 : Math.min(page * pageSize, totalJobs); return { startIndex, endIndex, }; }, [page, pageSize, totalJobs]); const runSearch = async ({ term, pageNumber }: { term: string | null; pageNumber: number }) => { try { setIsSearching(true); setErrorMessage(null); const response = await api.searchUkVisaJobs({ searchTerm: term ?? undefined, page: pageNumber, }); setResults(response.jobs); setPage(response.page); setPageSize(response.pageSize); setTotalJobs(response.totalJobs); setTotalPages(response.totalPages); setLastRunAt(new Date().toISOString()); if (response.jobs.length === 0) { toast.message("No UK Visa Jobs found for this search."); } } catch (error) { const message = error instanceof Error ? error.message : "UK Visa Jobs search failed"; setErrorMessage(message); toast.error(message); } finally { setIsSearching(false); } }; const handleSearch = async (event: React.FormEvent) => { event.preventDefault(); setErrorMessage(null); const terms = searchTermInput .split(/[\n,]+/) .map((term) => term.trim()) .filter(Boolean); const term = terms[0] || null; if (terms.length > 1) { toast.message("Using the first term for pagination."); } setActiveSearchTerm(term); setLastSearchTerm(term); setPage(1); await runSearch({ term, pageNumber: 1 }); }; const handlePageChange = (nextPage: number) => { if (isSearching) return; if (nextPage < 1 || nextPage > totalPages) return; setPage(nextPage); void runSearch({ term: activeSearchTerm, pageNumber: nextPage }); }; const handleImportSelected = async () => { const selectedJobs = results.filter((job) => selectedJobIds.has(jobKey(job))); if (selectedJobs.length === 0) return; try { setIsImporting(true); const response = await api.importUkVisaJobs({ jobs: selectedJobs }); toast.success(`Imported ${response.created} jobs`, { description: response.skipped ? `${response.skipped} skipped (duplicates)` : undefined, }); setSelectedJobIds(new Set()); } catch (error) { const message = error instanceof Error ? error.message : "Failed to import jobs"; toast.error(message); } finally { setIsImporting(false); } }; const selectedDescription = useMemo(() => { if (!selectedJob?.jobDescription) return "No description available."; const cleaned = stripHtml(selectedJob.jobDescription); return cleaned || "No description available."; }, [selectedJob]); const selectedJobLink = selectedJob ? selectedJob.applicationLink || selectedJob.jobUrl : "#"; const selectedDeadline = selectedJob ? formatDate(selectedJob.deadline) : null; const selectedPosted = selectedJob ? formatDate(selectedJob.datePosted) : null; const selectedCount = selectedJobIds.size; const allSelected = results.length > 0 && results.every((job) => selectedJobIds.has(jobKey(job))); const selectAllState = allSelected ? true : selectedCount > 0 ? "indeterminate" : false; const canGoPrev = page > 1; const canGoNext = page < totalPages; const handleSelectJob = (jobId: string) => { setSelectedJobId(jobId); if (!isDesktop) { setIsDetailDrawerOpen(true); } }; const detailPanelContent = !selectedJob ? (
Select a job

Pick a job from the list to inspect details.

) : (
{selectedJob.title}
{selectedJob.employer}
UK Visa Jobs
{selectedJob.location && ( {selectedJob.location} )} {selectedDeadline && ( {selectedDeadline} )} {selectedPosted && ( Posted {selectedPosted} )} {selectedJob.salary && ( {selectedJob.salary} )} {selectedJob.degreeRequired && ( {selectedJob.degreeRequired} )}
Job type
{selectedJob.jobType || "Not set"}
Job level
{selectedJob.jobLevel || "Not set"}
Location
{selectedJob.location || "Not set"}
Deadline
{selectedDeadline || "Not set"}
Description
{selectedDescription}
); return ( <>
UK Visa Jobs
Live search console
API search
setSearchTermInput(event.target.value)} placeholder="e.g. data analyst" className="h-10" />

Single keyword or phrase. Leave blank to fetch the newest jobs.

{errorMessage && (
{errorMessage}
)}
Last run: {lastRunAt ? formatDateTime(lastRunAt) : "No searches yet"}
{totalJobs} total {results.length} on page Page {page} of {totalPages} {lastSearchTerm && Term: {lastSearchTerm}}
{isSearching && results.length > 0 && (
Fetching UK Visa Jobs...
)} {results.length === 0 ? (
{isSearching ? ( <>
Searching...

Fetching fresh UK Visa Jobs listings.

) : ( <>
No results yet

Run a search to fetch fresh UK Visa Jobs listings.

)}
) : ( <>
{ if (checked === true) { setSelectedJobIds(new Set(results.map((job) => jobKey(job)))); } else { setSelectedJobIds(new Set()); } }} aria-label="Select all jobs on this page" /> Select page {selectedCount} selected
{results.map((job) => { const key = jobKey(job); const isSelected = key === selectedJobId; const isChecked = selectedJobIds.has(key); const description = job.jobDescription ? clampText(stripHtml(job.jobDescription)) : "No description."; return (
handleSelectJob(key)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); handleSelectJob(key); } }} className={cn( "flex w-full items-start gap-4 px-4 py-3 text-left transition-colors", isSelected ? "bg-muted/40" : "hover:bg-muted/30", )} aria-pressed={isSelected} >
event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()} role="presentation" > { setSelectedJobIds((current) => { const next = new Set(current); if (checked) { next.add(key); } else { next.delete(key); } return next; }); }} aria-label={`Select ${job.title}`} />
{job.title}
{job.employer}
{description}
{job.location && ( {job.location} )} {job.salary && ( {job.salary} )} {job.deadline && ( {formatDate(job.deadline)} )}
{job.jobType && ( {job.jobType} )} {job.jobLevel && ( {job.jobLevel} )}
); })}
Showing {summaryCounts.startIndex}-{summaryCounts.endIndex} of {totalJobs}
Page {page} of {totalPages}
)}
{detailPanelContent}
Job details
{detailPanelContent}
); };