From fe8102385e38751a1a996aa430ac5bbf6ce0f2be Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 22 Aug 2025 14:25:42 +0000 Subject: [PATCH] Put all frontend data calls to backend queries --- .build.hash | 2 +- .last-lock | 2 +- .lock.hash | 2 +- backend/server2.js | 246 ++++++++++++++ nginx.conf | 2 + package-lock.json | 7 + package.json | 1 + src/components/CareerExplorer.js | 142 ++++----- src/components/CareerSearch.js | 184 +++++++---- src/components/EducationalProgramsPage.js | 3 +- .../PremiumOnboarding/CollegeOnboarding.js | 300 ++++++------------ src/utils/apiUtils.js | 59 +++- 12 files changed, 594 insertions(+), 356 deletions(-) diff --git a/.build.hash b/.build.hash index ab3c2c0..7fc3581 100644 --- a/.build.hash +++ b/.build.hash @@ -1 +1 @@ -277110e50c0c8ee5c02fee3c1174a225d5593511-803b2c2ecad09a0fbca070296808a53489de891a-e9eccd451b778829eb2f2c9752c670b707e1268b +3eefb2cd6c785e5815d042d108f67a87c6819a4d-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b diff --git a/.last-lock b/.last-lock index 46734e2..48c332e 100644 --- a/.last-lock +++ b/.last-lock @@ -1 +1 @@ -8eca4afbc834297a74d0c140a17e370c19102dea +1a7fe9191922c4f8389027ed53b6a4909740a48b diff --git a/.lock.hash b/.lock.hash index 46734e2..48c332e 100644 --- a/.lock.hash +++ b/.lock.hash @@ -1 +1 @@ -8eca4afbc834297a74d0c140a17e370c19102dea +1a7fe9191922c4f8389027ed53b6a4909740a48b diff --git a/backend/server2.js b/backend/server2.js index 6ac05b6..c2c9954 100755 --- a/backend/server2.js +++ b/backend/server2.js @@ -24,6 +24,9 @@ import sgMail from '@sendgrid/mail'; // npm i @sendgrid/mail import crypto from 'crypto'; import cookieParser from 'cookie-parser'; import { v4 as uuid } from 'uuid'; +import Fuse from 'fuse.js'; +import { parse as csvParse } from 'csv-parse/sync'; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -55,6 +58,70 @@ const chatLimiter = rateLimit({ keyGenerator: req => req.user?.id || req.ip }); + +// ── helpers ───────────────────────────────────────────────────────── +const normTitle = (s='') => + String(s) + .toLowerCase() + .replace(/\s*&\s*/g, ' and ') + .replace(/[–—]/g, '-') // en/em dash → hyphen + .replace(/\s+/g, ' ') + .trim(); + +const stripSoc = (s='') => String(s).split('.')[0]; // "15-1252.00" → "15-1252" + + +// 1) careers_with_ratings.json +let CAREERS = []; +let careersFuse = null; +try { + const raw = fs.readFileSync(path.join(DATA_DIR, 'careers_with_ratings.json'), 'utf8'); + CAREERS = JSON.parse(raw); + careersFuse = new Fuse(CAREERS, { + keys: ['title'], + threshold: 0.3, + ignoreLocation: true, + }); + console.log(`[data] careers_with_ratings loaded: ${CAREERS.length}`); +} catch (e) { + console.error('[data] careers_with_ratings load failed:', e.message); +} + + +const norm = (s='') => + String(s).toLowerCase().replace(/\s*&\s*/g,' and ').replace(/[–—]/g,'-').replace(/\s+/g,' ').trim(); + + +// 2) CIP institution mapping (line-delimited JSON or array) +let CIPMAP = []; +try { + const cand = ['cip_institution_mapping_new.json','cip_institution_mapping_fixed.json','cip_institution_mapping.json'] + .map(f => path.join(DATA_DIR, f)) + .find(f => fs.existsSync(f)); + if (cand) { + const text = fs.readFileSync(cand, 'utf8').trim(); + if (text.startsWith('[')) { + CIPMAP = JSON.parse(text); + } else { + CIPMAP = text.split('\n').map(l => { try { return JSON.parse(l); } catch { return null; } }) + .filter(Boolean); + } + console.log(`[data] CIP map loaded: ${CIPMAP.length} rows`); + } +} catch (e) { + console.error('[data] CIP map load failed:', e.message); +} + +// 3) IPEDS ic2023_ay.csv -> parse once +let IPEDS = []; +try { + const csv = fs.readFileSync(path.join(DATA_DIR, 'ic2023_ay.csv'), 'utf8'); + IPEDS = csvParse(csv, { columns: true, skip_empty_lines: true }); + console.log(`[data] IPEDS ic2023_ay loaded: ${IPEDS.length} rows`); +} catch (e) { + console.error('[data] IPEDS load failed:', e.message); +} + // Load institution data (kept for existing routes) const institutionData = JSON.parse(fs.readFileSync(INSTITUTION_DATA_PATH, 'utf8')); @@ -1422,6 +1489,185 @@ app.post('/api/chat/threads/:id/stream', authenticateUser, async (req, res) => { }); +// GET /api/careers/search?query=&limit=15 +app.get('/api/careers/search', (req, res) => { + const q = String(req.query.query || '').trim(); + const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 15)); + if (!q || !careersFuse) return res.json([]); + + // exact match first (auto-commit parity) + const nq = normTitle(q); + const exact = CAREERS.find(c => normTitle(c.title) === nq); + + // fuse results for partials + const hits = careersFuse.search(q, { limit: Math.max(limit, 25) }).map(h => h.item); + + // de-dupe by normalized title; put exact first if present + const seen = new Set(); + const out = []; + + if (exact) { + seen.add(normTitle(exact.title)); + out.push(exact); + } + + for (const c of hits) { + const key = normTitle(c.title); + if (seen.has(key)) continue; + out.push(c); + seen.add(key); + if (out.length >= limit) break; + } + + // return minimal shape + SOC stripped; include cip_code mirror for legacy callers + res.json(out.map(c => ({ + title : c.title, + soc_code : c.soc_code, + cip_codes : c.cip_codes, + cip_code : c.cip_codes, // ← backward-compat (some callers expect singular) + limited_data : c.limited_data, + ratings : c.ratings, + }))); +}); + +// GET /api/careers/resolve?title= +app.get('/api/careers/resolve', (req, res) => { + const t = String(req.query.title || '').trim(); + if (!t) return res.status(400).json({ error: 'title required' }); + + const m = CAREERS.find(c => normTitle(c.title) === normTitle(t)); + if (!m) return res.status(404).json({ error: 'not_found' }); + + res.json({ + title : m.title, + soc_code : m.soc_code, + cip_codes : m.cip_codes, + cip_code : m.cip_codes, // ← mirror for legacy + limited_data : m.limited_data, + ratings : m.ratings, + }); +}); + +app.get('/api/careers/by-soc', (req, res) => { + const raw = String(req.query.soc || '').trim(); + if (!raw) return res.status(400).json({ error: 'soc required' }); + + const base = raw; // tolerate .00 + const match = + CAREERS.find(c => c.soc_code === base) || + null; + + if (!match) return res.status(404).json({ error: 'not_found' }); + + res.json({ + soc_code : match.soc_code, + title : match.title, + cip_codes : match.cip_codes || [], + ratings : match.ratings || {} + }); +}); + +// GET /api/schools/suggest?query=&limit=10 +app.get('/api/schools/suggest', (req, res) => { + const q = String(req.query.query || '').trim().toLowerCase(); + const limit = Math.min(20, Math.max(1, Number(req.query.limit) || 10)); + if (!q) return res.json([]); + const seen = new Set(); + const out = []; + for (const r of CIPMAP) { + const name = (r.INSTNM || '').trim(); + if (!name) continue; + if (name.toLowerCase().includes(q) && !seen.has(name)) { + seen.add(name); + out.push({ name, unitId: r.UNITID || null }); + if (out.length >= limit) break; + } + } + res.json(out); +}); + +// GET /api/programs/suggest?school=&query=&limit=10 +app.get('/api/programs/suggest', (req, res) => { + const school = String(req.query.school || '').trim().toLowerCase(); + const q = String(req.query.query || '').trim().toLowerCase(); + const limit = Math.min(30, Math.max(1, Number(req.query.limit) || 10)); + if (!school || !q) return res.json([]); + const seen = new Set(); + const out = []; + for (const r of CIPMAP) { + const sname = (r.INSTNM || '').trim().toLowerCase(); + const prog = (r.CIPDESC || '').trim(); + if (!prog || sname !== school) continue; + if (prog.toLowerCase().includes(q) && !seen.has(prog)) { + seen.add(prog); + out.push({ program: prog }); + if (out.length >= limit) break; + } + } + res.json(out); +}); + +// GET /api/programs/types?school=&program= +app.get('/api/programs/types', (req, res) => { + const school = String(req.query.school || '').trim().toLowerCase(); + const program = String(req.query.program || '').trim(); + if (!school || !program) return res.status(400).json({ error: 'school and program required' }); + const types = new Set( + CIPMAP + .filter(r => + (r.INSTNM || '').trim().toLowerCase() === school && + (r.CIPDESC || '').trim() === program + ) + .map(r => r.CREDDESC) + .filter(Boolean) + ); + res.json({ types: [...types] }); +}); + +// GET /api/tuition/estimate?unitId=...&programType=...&inState=0|1&inDistrict=0|1&creditHoursPerYear=NN +app.get('/api/tuition/estimate', (req, res) => { + const unitId = String(req.query.unitId || '').trim(); + const programType = String(req.query.programType || '').trim(); + const inState = Number(req.query.inState || 0) ? 1 : 0; + const inDistrict = Number(req.query.inDistrict || 0) ? 1 : 0; + const chpy = Math.max(0, Number(req.query.creditHoursPerYear || 0)); + + if (!unitId) return res.status(400).json({ error: 'unitId required' }); + + const row = IPEDS.find(r => String(r.UNITID) === unitId); + if (!row) return res.status(404).json({ error: 'unitId not found' }); + + const grad = [ + "Master's Degree", "Doctoral Degree", "Graduate/Professional Certificate", "First Professional Degree" + ].includes(programType); + + let part, full; + if (grad) { + if (inDistrict) { part = Number(row.HRCHG5 || 0); full = Number(row.TUITION5 || 0); } + else if (inState) { part = Number(row.HRCHG6 || 0); full = Number(row.TUITION6 || 0); } + else { part = Number(row.HRCHG7 || 0); full = Number(row.TUITION7 || 0); } + } else { + if (inDistrict) { part = Number(row.HRCHG1 || 0); full = Number(row.TUITION1 || 0); } + else if (inState) { part = Number(row.HRCHG2 || 0); full = Number(row.TUITION2 || 0); } + else { part = Number(row.HRCHG3 || 0); full = Number(row.TUITION3 || 0); } + } + + let estimate = full; + if (chpy && chpy < 24 && part) estimate = part * chpy; + + res.json({ + unitId, + programType, + inState: !!inState, + inDistrict: !!inDistrict, + creditHoursPerYear: chpy, + estimate: Math.round(estimate) + }); +}); + + + + /************************************************** * Start the Express server **************************************************/ diff --git a/nginx.conf b/nginx.conf index 4501ba2..7328cca 100644 --- a/nginx.conf +++ b/nginx.conf @@ -59,6 +59,8 @@ http { location ^~ /api/schools { proxy_pass http://backend5001; } location ^~ /api/support { proxy_pass http://backend5001; } location ^~ /api/data/ { proxy_pass http://backend5001; } + location ^~ /api/careers/ { proxy_pass http://backend5001; } + location ^~ /api/programs/ { proxy_pass http://backend5001; } location ^~ /api/premium/ { proxy_pass http://backend5002; } location ^~ /api/public/ { proxy_pass http://backend5002; } diff --git a/package-lock.json b/package-lock.json index 82b7640..cd0ecc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "cookie-parser": "^1.4.7", "cors": "^2.8.5", "cra-template": "1.2.0", + "csv-parse": "^6.1.0", "docx": "^9.5.0", "dotenv": "^16.4.7", "express-rate-limit": "^7.5.1", @@ -7852,6 +7853,12 @@ "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", "license": "MIT" }, + "node_modules/csv-parse": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.1.0.tgz", + "integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==", + "license": "MIT" + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", diff --git a/package.json b/package.json index 49d1b7e..ecf680d 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "cookie-parser": "^1.4.7", "cors": "^2.8.5", "cra-template": "1.2.0", + "csv-parse": "^6.1.0", "docx": "^9.5.0", "dotenv": "^16.4.7", "express-rate-limit": "^7.5.1", diff --git a/src/components/CareerExplorer.js b/src/components/CareerExplorer.js index 111a2d1..7b42dfc 100644 --- a/src/components/CareerExplorer.js +++ b/src/components/CareerExplorer.js @@ -63,7 +63,6 @@ function CareerExplorer() { // ---------- Component States ---------- const [userProfile, setUserProfile] = useState(null); - const [masterCareerRatings, setMasterCareerRatings] = useState([]); const [careerList, setCareerList] = useState([]); const [careerDetails, setCareerDetails] = useState(null); const [showModal, setShowModal] = useState(false); @@ -271,6 +270,19 @@ function CareerExplorer() { } }; +const stripSoc = (s = '') => s.split('.')[0]; + +async function fetchCareerMetaBySoc(soc) { + const base = soc || ''; + if (!base) return null; + try { + const { data } = await api.get('/api/careers/by-soc', { params: { soc: base } }); + return data || null; // { soc_code, title, cip_codes, ratings } + } catch { + return null; + } +} + // -------------------------------------- // On mount, load suggestions from cache // -------------------------------------- @@ -477,20 +489,6 @@ const handleCareerClick = useCallback( } }, [pendingCareerForModal, handleCareerFromSearch]); - // ------------------------------------------------------ - // Load careers_with_ratings for CIP arrays - // ------------------------------------------------------ -useEffect(() => { - (async () => { - try { - const { data } = await api.get('/api/data/careers-with-ratings'); // server2 route - setMasterCareerRatings(data || []); - } catch (err) { - console.error('Error fetching career ratings:', err); - setMasterCareerRatings([]); - } - })(); -}, []); // ------------------------------------------------------ // Derived data / Helpers @@ -558,12 +556,6 @@ useEffect(() => { setChatSnapshot({ coreCtx, modalCtx }); }, [coreCtx, modalCtx, setChatSnapshot]); - const getCareerRatingsBySocCode = (socCode) => { - return ( - masterCareerRatings.find((c) => c.soc_code === socCode)?.ratings || {} - ); - }; - // ------------------------------------------------------ // Save comparison list to backend // ------------------------------------------------------ @@ -593,34 +585,34 @@ useEffect(() => { // ------------------------------------------------------ // Add/Remove from comparison // ------------------------------------------------------ - const addCareerToList = (career) => { - // 1) get default (pre-calculated) ratings from your JSON - const masterRatings = getCareerRatingsBySocCode(career.code); + const addCareerToList = async (career) => { + // 1) get default (pre-calculated) ratings from backend by SOC + const meta = await fetchCareerMetaBySoc(career.code); + const masterRatings = meta?.ratings || {}; - // 2) figure out interest - const userHasInventory = - !career.fromManualSearch && // ← skip the shortcut if manual - priorities.interests && - priorities.interests !== "I’m not sure yet"; - const defaultInterestValue = - userHasInventory - ? - (fitRatingMap[career.fit] || masterRatings.interests || 3) - : - 3; + // 2) figure out interest + const userHasInventory = + !career.fromManualSearch && + priorities.interests && + priorities.interests !== "I’m not sure yet"; - const defaultMeaningValue = 3; + const defaultInterestValue = userHasInventory + ? (fitRatingMap[career.fit] || masterRatings.interests || 3) + : 3; + + const defaultMeaningValue = 3; + + // 3) open the InterestMeaningModal (unchanged) + setModalData({ + career, + masterRatings, + askForInterest: !userHasInventory, + defaultInterest: defaultInterestValue, + defaultMeaning: defaultMeaningValue, + }); + setShowInterestMeaningModal(true); +}; - // 4) open the InterestMeaningModal instead of using prompt() - setModalData({ - career, - masterRatings, - askForInterest: !userHasInventory, - defaultInterest: defaultInterestValue, - defaultMeaning: defaultMeaningValue, - }); - setShowInterestMeaningModal(true); - }; const handleModalSave = ({ interest, meaning }) => { const { career, masterRatings, askForInterest, defaultInterest } = modalData; @@ -674,12 +666,10 @@ useEffect(() => { }); }; - const stripSoc = (s = '') => s.split('.')[0]; + // ------------------------------------------------------ // "Select for Education" => navigate with CIP codes // ------------------------------------------------------ -// CareerExplorer.js -// CareerExplorer.js const handleSelectForEducation = async (career) => { if (!career) return; @@ -696,34 +686,36 @@ const handleSelectForEducation = async (career) => { } // 1) try local JSON by base SOC (tolerates .00 vs none) - const match = masterCareerRatings.find(r => stripSoc(r.soc_code) === baseSoc); - let rawCips = Array.isArray(match?.cip_codes) ? match.cip_codes : []; - let cleanedCips = cleanCipCodes(rawCips); + let rawCips = []; +try { + const meta = await fetchCareerMetaBySoc(baseSoc); + rawCips = Array.isArray(meta?.cip_codes) ? meta.cip_codes : []; +} catch { /* best-effort */ } - // 2) fallback: ask server2 to map SOC→CIP if local didn’t have any - if (!cleanedCips.length) { - try { - const candidates = [ - fullSoc, // as-is - baseSoc, // stripped - fullSoc.includes('.') ? null : `${fullSoc}.00` // add .00 if missing - ].filter(Boolean); +let cleanedCips = cleanCipCodes(rawCips); - let fromApi = null; - for (const soc of candidates) { - try { - const { data } = await api.get(`/api/cip/${soc}`); - if (data?.cipCode) { fromApi = data.cipCode; break; } - } catch {} - } - if (fromApi) { - rawCips = [fromApi]; - cleanedCips = cleanCipCodes(rawCips); - } - } catch { - // best-effort fallback; continue +// 2) fallback: ask server2 to map SOC→CIP if none were found +if (!cleanedCips.length) { + try { + const candidates = [ + fullSoc, // as-is + baseSoc, // stripped + fullSoc.includes('.') ? null : `${fullSoc}.00` // add .00 if missing + ].filter(Boolean); + + let fromApi = null; + for (const soc of candidates) { + try { + const { data } = await api.get(`/api/cip/${soc}`); + if (data?.cipCode) { fromApi = data.cipCode; break; } + } catch {} } - } + if (fromApi) { + rawCips = [fromApi]; + cleanedCips = cleanCipCodes(rawCips); + } + } catch { /* ignore */ } +} // Persist for the next page (keep raw list if we have it) const careerForStorage = { @@ -853,7 +845,7 @@ const handleSelectForEducation = async (career) => { { - console.log('[Dashboard] onCareerSelected =>', careerObj); + setLoading(true); setPendingCareerForModal(careerObj); }} /> diff --git a/src/components/CareerSearch.js b/src/components/CareerSearch.js index 9ea58a1..b103080 100644 --- a/src/components/CareerSearch.js +++ b/src/components/CareerSearch.js @@ -1,5 +1,4 @@ -import React, { useEffect, useState } from 'react'; -import { Check } from 'lucide-react'; // (unused here, keep/remove as you like) +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Button } from './ui/button.js'; import api from '../auth/apiClient.js'; @@ -8,78 +7,105 @@ const normalize = (s = '') => s .toLowerCase() .replace(/\s*&\s*/g, ' and ') - .replace(/[–—]/g, '-') // long dash → hyphen + .replace(/[–—]/g, '-') // long dash → hyphen .replace(/\s+/g, ' ') .trim(); /* ---------- component ---------- */ const CareerSearch = ({ onCareerSelected, required, disabled: externallyDisabled = false }) => { - const [careerObjects, setCareerObjects] = useState([]); - const [searchInput, setSearchInput] = useState(''); - const [selectedObj, setSelectedObj] = useState(null); // ✓ state - const computedDisabled = externallyDisabled || !!selectedObj; + 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(''); - /* fetch & de-dupe once (now via backend) */ + const computedDisabled = externallyDisabled || !!selectedObj; + const listId = 'career-titles'; + + // Debounced query to backend on input changes useEffect(() => { - (async () => { + 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: raw = [] } = await api.get('/api/data/careers-with-ratings'); + 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 raw) { - if (c.title && c.soc_code && c.cip_codes) { - const key = normalize(c.title); - if (!map.has(key)) { - map.set(key, { - title: c.title, - soc_code: c.soc_code, - cip_code: c.cip_codes, - limited_data: c.limited_data, - ratings: c.ratings - }); - } + 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 + }); } } - setCareerObjects([...map.values()]); + setSuggestions([...map.values()]); } catch (err) { - console.error('Career list load failed:', err); - setCareerObjects([]); + if (err?.name !== 'CanceledError' && err?.code !== 'ERR_CANCELED') { + console.error('Career search failed:', err); + setSuggestions([]); + } + } finally { + setLoading(false); } - })(); - }, []); + }, 150); // debounce ~150ms (keeps typing snappy) - /* whenever input changes, auto-commit if it matches */ - useEffect(() => { - const match = careerObjects.find( - (o) => normalize(o.title) === normalize(searchInput) - ); - if (match && match !== selectedObj) { - setSelectedObj(match); - onCareerSelected?.(match); // notify parent immediately - } - }, [searchInput, careerObjects, selectedObj, onCareerSelected]); + 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(); +}; - /* allow “Enter” to commit first suggestion */ - const handleKeyDown = (e) => { - if (computedDisabled) return; - if (e.key === 'Enter') { - const first = careerObjects.find(o => - normalize(o.title).startsWith(normalize(searchInput)) - ); - if (first) { - setSearchInput(first.title); // triggers auto-commit - e.preventDefault(); - } - } - }; - /* clear & edit again */ const reset = () => { setSelectedObj(null); setSearchInput(''); + setSuggestions([]); }; - const listId = 'career-titles'; - return (
- {/* PSA message */} {!selectedObj && (

Please pick from the dropdown when performing search. Our database is very comprehensive but can’t @@ -117,7 +180,6 @@ const CareerSearch = ({ onCareerSelected, required, disabled: externallyDisabled

)} - {/* change / clear link */} {selectedObj && !externallyDisabled && (