spb/airiskindex Public
The most methodologically rigorous, fully transparent AI job-exposure index.
TypeScript 88%
Python 6.1%
SQL 2.7%
CSS 1.2%
JavaScript 0.9%
Shell 0.8%
1"use client";23// File: occupation-search.tsx4// Path: apps/web/components/occupation-search.tsx5// Project: AI Risk Index — airiskindex.io6// Author: Simon-Pierre Boucher7// Contact: contact@spboucher.ai8// Copyright © 2026 Simon-Pierre Boucher. All rights reserved.9//10// Description: Debounced live occupation search box (client component).111213import Link from "next/link";14import { useEffect, useRef, useState } from "react";1516interface Item {17 code: string;18 title: string;19}2021/** Debounced live search over /api/v1/occupations. */22export function OccupationSearch(): JSX.Element {23 const [query, setQuery] = useState("");24 const [items, setItems] = useState<Item[]>([]);25 const [open, setOpen] = useState(false);26 const boxRef = useRef<HTMLDivElement>(null);2728 useEffect(() => {29 if (query.trim().length < 2) {30 setItems([]);31 return;32 }33 const controller = new AbortController();34 const timer = setTimeout(async () => {35 try {36 const response = await fetch(37 `/api/v1/occupations?q=${encodeURIComponent(query.trim())}&per_page=8`,38 { signal: controller.signal },39 );40 if (response.ok) {41 const data = (await response.json()) as { items: Item[] };42 setItems(data.items);43 setOpen(true);44 }45 } catch {46 /* aborted or offline — keep prior results */47 }48 }, 200);49 return () => {50 controller.abort();51 clearTimeout(timer);52 };53 }, [query]);5455 useEffect(() => {56 const onClick = (event: MouseEvent) => {57 if (!boxRef.current?.contains(event.target as Node)) setOpen(false);58 };59 document.addEventListener("mousedown", onClick);60 return () => document.removeEventListener("mousedown", onClick);61 }, []);6263 return (64 <div ref={boxRef} className="relative max-w-xl">65 <input66 type="search"67 value={query}68 onChange={(event) => setQuery(event.target.value)}69 onFocus={() => items.length > 0 && setOpen(true)}70 placeholder="Search 1,000+ occupations — e.g. paralegal, radiologist, roofer…"71 aria-label="Search occupations"72 className="w-full card px-4 py-3 text-base outline-none placeholder:text-[var(--muted)] focus:border-[var(--seq)]"73 />74 {open && items.length > 0 && (75 <ul className="absolute z-20 mt-2 w-full overflow-hidden card shadow-lg">76 {items.map((item) => (77 <li key={item.code} className="border-b border-[var(--grid)] last:border-b-0">78 <Link79 href={`/occupations/${item.code}`}80 className="flex items-baseline justify-between px-4 py-2.5 hover:bg-[var(--wash)]"81 onClick={() => setOpen(false)}82 >83 <span className="truncate">{item.title}</span>84 <span className="ml-3 shrink-0 text-xs text-[var(--muted)]">{item.code}</span>85 </Link>86 </li>87 ))}88 </ul>89 )}90 </div>91 );92}93