import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Button } from './ui/button.js'; import api from '../auth/apiClient.js'; /* ---------- helpers ---------- */ const normalize = (s = '') => s .toLowerCase() .replace(/\s*&\s*/g, ' and ') .replace(/[–—]/g, '-') // long dash → hyphen .replace(/\s+/g, ' ') .trim(); /* ---------- component ---------- */ const CareerSearch = ({ onCareerSelected, required, disabled: externallyDisabled = false }) => { const [suggestions, setSuggestions] = useState([]); // [{title,soc_code,cip_codes,...}] const [searchInput, setSearchInput] = useState(''); const [selectedObj, setSelectedObj] = useState(null); const [loading, setLoading] = useState(false); const abortRef = useRef(null); const lastMouseDownRef = useRef(0); const prevValueRef = useRef(''); const computedDisabled = externallyDisabled || !!selectedObj; const listId = 'career-titles'; // Debounced query to backend on input changes useEffect(() => { if (computedDisabled) return; const q = searchInput.trim(); // Don’t fetch on empty string (keeps UX identical to your datalist flow) if (!q) { setSuggestions([]); return; } setLoading(true); // cancel previous in-flight if (abortRef.current) abortRef.current.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; const timer = setTimeout(async () => { try { const { data } = await api.get('/api/careers/search', { params: { query: q, limit: 15 }, signal: ctrl.signal }); // De-dupe by normalized title (keep first) const map = new Map(); for (const c of Array.isArray(data) ? data : []) { if (!c?.title || !c?.soc_code || !c?.cip_codes) continue; const key = normalize(c.title); if (!map.has(key)) { map.set(key, { title: c.title, soc_code: c.soc_code, cip_codes: c.cip_codes, limited_data: c.limited_data, ratings: c.ratings }); } } setSuggestions([...map.values()]); } catch (err) { if (err?.name !== 'CanceledError' && err?.code !== 'ERR_CANCELED') { console.error('Career search failed:', err); setSuggestions([]); } } finally { setLoading(false); } }, 150); // debounce ~150ms (keeps typing snappy) return () => { clearTimeout(timer); ctrl.abort(); }; }, [searchInput, computedDisabled]); // Handle Enter → commit first startsWith match (then datalist shows exact) const handleKeyDown = (e) => { if (computedDisabled || e.key !== 'Enter') return; const n = normalize(searchInput); const exact = suggestions.find(o => normalize(o.title) === n); const firstP = suggestions.find(o => normalize(o.title).startsWith(n)); const first = exact || firstP || suggestions[0]; if (!first) return; const payload = { ...first, cip_code: first.cip_codes }; setSearchInput(first.title); setSelectedObj(payload); onCareerSelected?.(payload); e.preventDefault(); }; const reset = () => { setSelectedObj(null); setSearchInput(''); setSuggestions([]); }; return (
{ lastMouseDownRef.current = Date.now(); }} onChange={async (e) => { const val = e.target.value; setSearchInput(val); if (computedDisabled) return; const exact = suggestions.find(o => normalize(o.title) === normalize(val)); // Heuristic: datalist pick usually replaces many characters at once. const prev = prevValueRef.current || ''; const bigJump = Math.abs(val.length - prev.length) > 1; // Also still catch Chromium’s signal when present. const it = e?.nativeEvent?.inputType; const replacement = it === 'insertReplacementText'; if (exact && (bigJump || replacement)) { const payload = { ...exact, cip_code: exact.cip_codes }; // full SOC preserved setSelectedObj(payload); onCareerSelected?.(payload); } // update after processing to get a clean delta next time prevValueRef.current = val; }} onKeyDown={handleKeyDown} onBlur={async () => { if (computedDisabled) return; const exact = suggestions.find(o => normalize(o.title) === normalize(searchInput)); if (!exact) return; const payload = { ...exact, cip_code: exact.cip_codes }; setSelectedObj(payload); onCareerSelected?.(payload); }} className={`w-full border rounded p-2 ${computedDisabled ? 'bg-gray-100 cursor-not-allowed opacity-60' : ''}`} placeholder="Start typing a career..." autoComplete="off" /> {!computedDisabled && ( {suggestions.map((o) => ( )} {loading && !computedDisabled && (
loading…
)}
{!selectedObj && (

Please pick from the dropdown when performing search. Our database is very comprehensive but can’t accommodate every job title—choose the closest match to what you’re searching for.

)} {selectedObj && !externallyDisabled && ( )}
); }; export default CareerSearch;