Put all frontend data calls to backend queries
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
Josh 2025-08-22 14:25:42 +00:00
parent 4c5fdea80c
commit fe8102385e
12 changed files with 594 additions and 356 deletions

View File

@ -1 +1 @@
277110e50c0c8ee5c02fee3c1174a225d5593511-803b2c2ecad09a0fbca070296808a53489de891a-e9eccd451b778829eb2f2c9752c670b707e1268b 3eefb2cd6c785e5815d042d108f67a87c6819a4d-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b

View File

@ -1 +1 @@
8eca4afbc834297a74d0c140a17e370c19102dea 1a7fe9191922c4f8389027ed53b6a4909740a48b

View File

@ -1 +1 @@
8eca4afbc834297a74d0c140a17e370c19102dea 1a7fe9191922c4f8389027ed53b6a4909740a48b

View File

@ -24,6 +24,9 @@ import sgMail from '@sendgrid/mail'; // npm i @sendgrid/mail
import crypto from 'crypto'; import crypto from 'crypto';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import { v4 as uuid } from 'uuid'; 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 __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@ -55,6 +58,70 @@ const chatLimiter = rateLimit({
keyGenerator: req => req.user?.id || req.ip 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) // Load institution data (kept for existing routes)
const institutionData = JSON.parse(fs.readFileSync(INSTITUTION_DATA_PATH, 'utf8')); 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=<text>&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=<exact>
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=<text>&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=<name>&query=<text>&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=<name>&program=<exact>
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 * Start the Express server
**************************************************/ **************************************************/

View File

@ -59,6 +59,8 @@ http {
location ^~ /api/schools { proxy_pass http://backend5001; } location ^~ /api/schools { proxy_pass http://backend5001; }
location ^~ /api/support { proxy_pass http://backend5001; } location ^~ /api/support { proxy_pass http://backend5001; }
location ^~ /api/data/ { 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/premium/ { proxy_pass http://backend5002; }
location ^~ /api/public/ { proxy_pass http://backend5002; } location ^~ /api/public/ { proxy_pass http://backend5002; }

7
package-lock.json generated
View File

@ -28,6 +28,7 @@
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"cra-template": "1.2.0", "cra-template": "1.2.0",
"csv-parse": "^6.1.0",
"docx": "^9.5.0", "docx": "^9.5.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express-rate-limit": "^7.5.1", "express-rate-limit": "^7.5.1",
@ -7852,6 +7853,12 @@
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
"license": "MIT" "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": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",

View File

@ -23,6 +23,7 @@
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"cra-template": "1.2.0", "cra-template": "1.2.0",
"csv-parse": "^6.1.0",
"docx": "^9.5.0", "docx": "^9.5.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express-rate-limit": "^7.5.1", "express-rate-limit": "^7.5.1",

View File

@ -63,7 +63,6 @@ function CareerExplorer() {
// ---------- Component States ---------- // ---------- Component States ----------
const [userProfile, setUserProfile] = useState(null); const [userProfile, setUserProfile] = useState(null);
const [masterCareerRatings, setMasterCareerRatings] = useState([]);
const [careerList, setCareerList] = useState([]); const [careerList, setCareerList] = useState([]);
const [careerDetails, setCareerDetails] = useState(null); const [careerDetails, setCareerDetails] = useState(null);
const [showModal, setShowModal] = useState(false); 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 // On mount, load suggestions from cache
// -------------------------------------- // --------------------------------------
@ -477,20 +489,6 @@ const handleCareerClick = useCallback(
} }
}, [pendingCareerForModal, handleCareerFromSearch]); }, [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 // Derived data / Helpers
@ -558,12 +556,6 @@ useEffect(() => {
setChatSnapshot({ coreCtx, modalCtx }); setChatSnapshot({ coreCtx, modalCtx });
}, [coreCtx, modalCtx, setChatSnapshot]); }, [coreCtx, modalCtx, setChatSnapshot]);
const getCareerRatingsBySocCode = (socCode) => {
return (
masterCareerRatings.find((c) => c.soc_code === socCode)?.ratings || {}
);
};
// ------------------------------------------------------ // ------------------------------------------------------
// Save comparison list to backend // Save comparison list to backend
// ------------------------------------------------------ // ------------------------------------------------------
@ -593,34 +585,34 @@ useEffect(() => {
// ------------------------------------------------------ // ------------------------------------------------------
// Add/Remove from comparison // Add/Remove from comparison
// ------------------------------------------------------ // ------------------------------------------------------
const addCareerToList = (career) => { const addCareerToList = async (career) => {
// 1) get default (pre-calculated) ratings from your JSON // 1) get default (pre-calculated) ratings from backend by SOC
const masterRatings = getCareerRatingsBySocCode(career.code); const meta = await fetchCareerMetaBySoc(career.code);
const masterRatings = meta?.ratings || {};
// 2) figure out interest // 2) figure out interest
const userHasInventory = const userHasInventory =
!career.fromManualSearch && // ← skip the shortcut if manual !career.fromManualSearch &&
priorities.interests && priorities.interests &&
priorities.interests !== "Im not sure yet"; priorities.interests !== "Im not sure yet";
const defaultInterestValue =
userHasInventory
?
(fitRatingMap[career.fit] || masterRatings.interests || 3)
:
3;
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 handleModalSave = ({ interest, meaning }) => {
const { career, masterRatings, askForInterest, defaultInterest } = modalData; const { career, masterRatings, askForInterest, defaultInterest } = modalData;
@ -674,12 +666,10 @@ useEffect(() => {
}); });
}; };
const stripSoc = (s = '') => s.split('.')[0];
// ------------------------------------------------------ // ------------------------------------------------------
// "Select for Education" => navigate with CIP codes // "Select for Education" => navigate with CIP codes
// ------------------------------------------------------ // ------------------------------------------------------
// CareerExplorer.js
// CareerExplorer.js
const handleSelectForEducation = async (career) => { const handleSelectForEducation = async (career) => {
if (!career) return; if (!career) return;
@ -696,34 +686,36 @@ const handleSelectForEducation = async (career) => {
} }
// 1) try local JSON by base SOC (tolerates .00 vs none) // 1) try local JSON by base SOC (tolerates .00 vs none)
const match = masterCareerRatings.find(r => stripSoc(r.soc_code) === baseSoc); let rawCips = [];
let rawCips = Array.isArray(match?.cip_codes) ? match.cip_codes : []; try {
let cleanedCips = cleanCipCodes(rawCips); 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 didnt have any let cleanedCips = cleanCipCodes(rawCips);
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; // 2) fallback: ask server2 to map SOC→CIP if none were found
for (const soc of candidates) { if (!cleanedCips.length) {
try { try {
const { data } = await api.get(`/api/cip/${soc}`); const candidates = [
if (data?.cipCode) { fromApi = data.cipCode; break; } fullSoc, // as-is
} catch {} baseSoc, // stripped
} fullSoc.includes('.') ? null : `${fullSoc}.00` // add .00 if missing
if (fromApi) { ].filter(Boolean);
rawCips = [fromApi];
cleanedCips = cleanCipCodes(rawCips); let fromApi = null;
} for (const soc of candidates) {
} catch { try {
// best-effort fallback; continue 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) // Persist for the next page (keep raw list if we have it)
const careerForStorage = { const careerForStorage = {
@ -853,7 +845,7 @@ const handleSelectForEducation = async (career) => {
<CareerSearch <CareerSearch
disabled={showModal} disabled={showModal}
onCareerSelected={(careerObj) => { onCareerSelected={(careerObj) => {
console.log('[Dashboard] onCareerSelected =>', careerObj); setLoading(true);
setPendingCareerForModal(careerObj); setPendingCareerForModal(careerObj);
}} }}
/> />

View File

@ -1,5 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Check } from 'lucide-react'; // (unused here, keep/remove as you like)
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
import api from '../auth/apiClient.js'; import api from '../auth/apiClient.js';
@ -8,78 +7,105 @@ const normalize = (s = '') =>
s s
.toLowerCase() .toLowerCase()
.replace(/\s*&\s*/g, ' and ') .replace(/\s*&\s*/g, ' and ')
.replace(/[–—]/g, '-') // long dash → hyphen .replace(/[–—]/g, '-') // long dash → hyphen
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim(); .trim();
/* ---------- component ---------- */ /* ---------- component ---------- */
const CareerSearch = ({ onCareerSelected, required, disabled: externallyDisabled = false }) => { const CareerSearch = ({ onCareerSelected, required, disabled: externallyDisabled = false }) => {
const [careerObjects, setCareerObjects] = useState([]); const [suggestions, setSuggestions] = useState([]); // [{title,soc_code,cip_codes,...}]
const [searchInput, setSearchInput] = useState(''); const [searchInput, setSearchInput] = useState('');
const [selectedObj, setSelectedObj] = useState(null); // ✓ state const [selectedObj, setSelectedObj] = useState(null);
const computedDisabled = externallyDisabled || !!selectedObj; 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(() => { useEffect(() => {
(async () => { if (computedDisabled) return;
const q = searchInput.trim();
// Dont 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 { 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(); const map = new Map();
for (const c of raw) { for (const c of Array.isArray(data) ? data : []) {
if (c.title && c.soc_code && c.cip_codes) { if (!c?.title || !c?.soc_code || !c?.cip_codes) continue;
const key = normalize(c.title); const key = normalize(c.title);
if (!map.has(key)) { if (!map.has(key)) {
map.set(key, { map.set(key, {
title: c.title, title: c.title,
soc_code: c.soc_code, soc_code: c.soc_code,
cip_code: c.cip_codes, cip_codes: c.cip_codes,
limited_data: c.limited_data, limited_data: c.limited_data,
ratings: c.ratings ratings: c.ratings
}); });
}
} }
} }
setCareerObjects([...map.values()]); setSuggestions([...map.values()]);
} catch (err) { } catch (err) {
console.error('Career list load failed:', err); if (err?.name !== 'CanceledError' && err?.code !== 'ERR_CANCELED') {
setCareerObjects([]); console.error('Career search failed:', err);
setSuggestions([]);
}
} finally {
setLoading(false);
} }
})(); }, 150); // debounce ~150ms (keeps typing snappy)
}, []);
/* whenever input changes, auto-commit if it matches */ return () => {
useEffect(() => { clearTimeout(timer);
const match = careerObjects.find( ctrl.abort();
(o) => normalize(o.title) === normalize(searchInput) };
); }, [searchInput, computedDisabled]);
if (match && match !== selectedObj) {
setSelectedObj(match); // Handle Enter → commit first startsWith match (then datalist shows exact)
onCareerSelected?.(match); // notify parent immediately const handleKeyDown = (e) => {
} if (computedDisabled || e.key !== 'Enter') return;
}, [searchInput, careerObjects, selectedObj, onCareerSelected]);
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 = () => { const reset = () => {
setSelectedObj(null); setSelectedObj(null);
setSearchInput(''); setSearchInput('');
setSuggestions([]);
}; };
const listId = 'career-titles';
return ( return (
<div className="mb-4"> <div className="mb-4">
<label className="block font-medium mb-1"> <label className="block font-medium mb-1">
@ -92,24 +118,61 @@ const CareerSearch = ({ onCareerSelected, required, disabled: externallyDisabled
list={listId} list={listId}
value={searchInput} value={searchInput}
required={required} required={required}
disabled={computedDisabled} // lock when chosen disabled={computedDisabled}
onChange={(e) => setSearchInput(e.target.value)} onMouseDown={() => { 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 Chromiums 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} onKeyDown={handleKeyDown}
className={`w-full border rounded p-2 onBlur={async () => {
${computedDisabled ? 'bg-gray-100 cursor-not-allowed opacity-60' : ''}`} 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..." placeholder="Start typing a career..."
autoComplete="off"
/> />
{!computedDisabled && ( {!computedDisabled && (
<datalist id={listId}> <datalist id={listId}>
{careerObjects.map((o) => ( {suggestions.map((o) => (
<option key={o.soc_code} value={o.title} /> <option key={`${o.soc_code}:${o.title}`} value={o.title} />
))} ))}
</datalist> </datalist>
)} )}
{loading && !computedDisabled && (
<div className="absolute right-2 top-2 text-xs text-gray-500">loading</div>
)}
</div> </div>
{/* PSA message */}
{!selectedObj && ( {!selectedObj && (
<p className="mt-2 text-sm text-blue-700"> <p className="mt-2 text-sm text-blue-700">
Please pick from the dropdown when performing search. Our database is very comprehensive but cant Please pick from the dropdown when performing search. Our database is very comprehensive but cant
@ -117,7 +180,6 @@ const CareerSearch = ({ onCareerSelected, required, disabled: externallyDisabled
</p> </p>
)} )}
{/* change / clear link */}
{selectedObj && !externallyDisabled && ( {selectedObj && !externallyDisabled && (
<button <button
type="button" type="button"

View File

@ -93,7 +93,7 @@ function renderLevel(val) {
function EducationalProgramsPage() { function EducationalProgramsPage() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { state } = useLocation(); const { state } = location;
const navCareer = state?.selectedCareer || {}; const navCareer = state?.selectedCareer || {};
const [selectedCareer, setSelectedCareer] = useState(navCareer); const [selectedCareer, setSelectedCareer] = useState(navCareer);
const [socCode, setSocCode] = useState(navCareer.code || ''); const [socCode, setSocCode] = useState(navCareer.code || '');
@ -738,6 +738,7 @@ const topSchools = filteredAndSortedSchools.slice(0, TOP_N).map(s => ({
school['INSTNM'] || 'Unnamed School' school['INSTNM'] || 'Unnamed School'
)} )}
</strong> </strong>
<p> Program: {school['CIPDESC'] || 'N/A'}</p>
<p>Degree Type: {school['CREDDESC'] || 'N/A'}</p> <p>Degree Type: {school['CREDDESC'] || 'N/A'}</p>
<p>In-State Tuition: ${school['In_state cost'] || 'N/A'}</p> <p>In-State Tuition: ${school['In_state cost'] || 'N/A'}</p>
<p>Out-of-State Tuition: ${school['Out_state cost'] || 'N/A'}</p> <p>Out-of-State Tuition: ${school['Out_state cost'] || 'N/A'}</p>

View File

@ -1,16 +1,14 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import Modal from '../../components/ui/modal.js'; import Modal from '../../components/ui/modal.js';
import FinancialAidWizard from '../../components/FinancialAidWizard.js'; import FinancialAidWizard from '../../components/FinancialAidWizard.js';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import authFetch from '../../utils/authFetch.js'; import api from '../../auth/apiClient.js';
const Req = () => <span className="text-red-600 ml-0.5">*</span>; const Req = () => <span className="text-red-600 ml-0.5">*</span>;
function CollegeOnboarding({ nextStep, prevStep, data, setData }) { function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
// CIP / iPEDS local states
const [schoolData, setSchoolData] = useState([]);
const [icTuitionData, setIcTuitionData] = useState([]);
const [schoolSuggestions, setSchoolSuggestions] = useState([]); const [schoolSuggestions, setSchoolSuggestions] = useState([]);
const schoolPrevRef = useRef('');
const [programSuggestions, setProgramSuggestions] = useState([]); const [programSuggestions, setProgramSuggestions] = useState([]);
const [availableProgramTypes, setAvailableProgramTypes] = useState([]); const [availableProgramTypes, setAvailableProgramTypes] = useState([]);
const [schoolValid, setSchoolValid] = useState(false); const [schoolValid, setSchoolValid] = useState(false);
@ -18,6 +16,7 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
const [enrollmentDate, setEnrollmentDate] = useState( const [enrollmentDate, setEnrollmentDate] = useState(
data.enrollment_date || '' data.enrollment_date || ''
); );
const [selectedUnitId, setSelectedUnitId] = useState(null);
const [expectedGraduation, setExpectedGraduation] = useState(data.expected_graduation || ''); const [expectedGraduation, setExpectedGraduation] = useState(data.expected_graduation || '');
const [showAidWizard, setShowAidWizard] = useState(false); const [showAidWizard, setShowAidWizard] = useState(false);
const location = useLocation(); const location = useLocation();
@ -149,55 +148,6 @@ useEffect(() => {
setManualProgramLength(e.target.value); setManualProgramLength(e.target.value);
}; };
// CIP data
useEffect(() => {
async function fetchCipData() {
try {
// Preferred: backend returns JSON array
const res = await authFetch('/api/data/cip-institution-map');
if (!res.ok) throw new Error(`CIP map error (${res.status})`);
const ct = res.headers.get('content-type') || '';
if (ct.includes('application/json')) {
const json = await res.json();
setSchoolData(Array.isArray(json) ? json : []);
} else {
// Fallback: line-delimited JSON (if you keep that format)
const text = await res.text();
const parsed = text
.split('\n')
.map(line => { try { return JSON.parse(line); } catch { return null; } })
.filter(Boolean);
setSchoolData(parsed);
}
} catch (err) {
console.error('Failed to load CIP data:', err);
setSchoolData([]);
}
}
fetchCipData();
}, []);
// iPEDS data
useEffect(() => {
async function fetchIpedsData() {
try {
const res = await authFetch('/api/data/ic2023'); // serves CSV from backend
if (!res.ok) throw new Error(`iPEDS error (${res.status})`);
const text = await res.text();
const rows = text.split('\n').map(line => line.split(','));
const headers = rows[0] || [];
const dataRows = rows.slice(1).map(row =>
Object.fromEntries(row.map((val, idx) => [headers[idx], val]))
);
setIcTuitionData(dataRows);
} catch (err) {
console.error('Failed to load iPEDS data:', err);
setIcTuitionData([]);
}
}
fetchIpedsData();
}, []);
useEffect(() => { useEffect(() => {
if (college_enrollment_status !== 'prospective_student') return; if (college_enrollment_status !== 'prospective_student') return;
@ -213,53 +163,55 @@ useEffect(() => {
}, [college_enrollment_status, enrollmentDate, data.program_length, setData]); }, [college_enrollment_status, enrollmentDate, data.program_length, setData]);
// School Name // School Name
const handleSchoolChange = (eOrVal) => { const handleSchoolChange = async (e) => {
const value = const value = e.target.value || '';
typeof eOrVal === 'string' ? eOrVal : eOrVal?.target?.value || ''; setData(prev => ({ ...prev, selected_school: value, selected_program: '', program_type: '', credit_hours_required: '' }));
setData(prev => ({ if (!value.trim()) { setSchoolSuggestions([]); return; }
...prev,
selected_school: value,
selected_program: '',
program_type: '',
credit_hours_required: '',
}));
const filtered = schoolData.filter(s =>
s.INSTNM.toLowerCase().includes(value.toLowerCase())
);
const uniqueSchools = [...new Set(filtered.map(s => s.INSTNM))];
setSchoolSuggestions(uniqueSchools.slice(0, 10));
setProgramSuggestions([]);
setAvailableProgramTypes([]);
};
const handleSchoolSelect = (schoolName) => { const it = e?.nativeEvent?.inputType; // Chromium: 'insertReplacementText' on datalist pick
setData(prev => ({ const replacement = it === 'insertReplacementText';
...prev, const bigJump = Math.abs(value.length - (schoolPrevRef.current || '').length) > 1;
selected_school: schoolName, try {
selected_program: '', const resp = await api.get('/api/schools/suggest', { params: { query: value, limit: 10 }});
program_type: '', const opts = Array.isArray(resp.data) ? resp.data : [];
credit_hours_required: '', setSchoolSuggestions(opts);
}));
// if user actually picked from dropdown → commit now (sets UNITID)
const exact = opts.find(o => (o.name || '').toLowerCase() === value.toLowerCase());
if (exact && (replacement || bigJump)) {
handleSchoolSelect(exact); // sets selectedUnitId + clears suggestions
}
} catch {
setSchoolSuggestions([]); setSchoolSuggestions([]);
setProgramSuggestions([]); }
setAvailableProgramTypes([]); schoolPrevRef.current = value;
}; };
const handleSchoolSelect = (schoolObj) => {
const name = schoolObj?.name || schoolObj || '';
const uid = schoolObj?.unitId || null;
setSelectedUnitId(uid);
setData(prev => ({ ...prev, selected_school: name, selected_program: '', program_type: '', credit_hours_required: '' }));
setSchoolSuggestions([]);
setProgramSuggestions([]);
setAvailableProgramTypes([]);
};
// Program // Program
const handleProgramChange = (e) => { const handleProgramChange = async (e) => {
const value = e.target.value; const value = e.target.value;
setData(prev => ({ ...prev, selected_program: value })); setData(prev => ({ ...prev, selected_program: value }));
if (!value) { if (!value || !selected_school) { setProgramSuggestions([]); return; }
setProgramSuggestions([]); try {
return; const { data } = await api.get('/api/programs/suggest', {
} params: { school: selected_school, query: value, limit: 10 }
const filtered = schoolData.filter( });
s => s.INSTNM.toLowerCase() === selected_school.toLowerCase() && setProgramSuggestions(Array.isArray(data) ? data : []); // [{ program }]
s.CIPDESC.toLowerCase().includes(value.toLowerCase()) } catch {
); setProgramSuggestions([]);
const uniquePrograms = [...new Set(filtered.map(s => s.CIPDESC))]; }
setProgramSuggestions(uniquePrograms.slice(0, 10)); };
};
const handleProgramSelect = (prog) => { const handleProgramSelect = (prog) => {
setData(prev => ({ ...prev, selected_program: prog })); setData(prev => ({ ...prev, selected_program: prog }));
@ -279,79 +231,37 @@ useEffect(() => {
// once we have school+program => load possible program types // once we have school+program => load possible program types
useEffect(() => { useEffect(() => {
if (!selected_program || !selected_school || !schoolData.length) return; if (!selected_program || !selected_school) { setAvailableProgramTypes([]); return; }
const possibleTypes = schoolData (async () => {
.filter( try {
s => s.INSTNM.toLowerCase() === selected_school.toLowerCase() && const { data } = await api.get('/api/programs/types', { params: { school: selected_school, program: selected_program }});
s.CIPDESC === selected_program setAvailableProgramTypes(Array.isArray(data?.types) ? data.types : []);
) } catch {
.map(s => s.CREDDESC); setAvailableProgramTypes([]);
setAvailableProgramTypes([...new Set(possibleTypes)]); }
}, [selected_program, selected_school, schoolData]); })();
}, [selected_program, selected_school]);
// auto-calc tuition /*Auto Calc Tuition*/
useEffect(() => { useEffect(() => {
if (!icTuitionData.length) return; (async () => {
if (!selected_school || !program_type || !credit_hours_per_year) return; if (!selectedUnitId || !program_type || !credit_hours_per_year) return;
try {
const found = schoolData.find( const { data } = await api.get('/api/tuition/estimate', {
s => s.INSTNM.toLowerCase() === selected_school.toLowerCase() params: {
); unitId: String(selectedUnitId),
if (!found) return; programType: program_type,
inState: is_in_state ? 1 : 0,
const unitId = found.UNITID; inDistrict: is_in_district ? 1 : 0,
if (!unitId) return; creditHoursPerYear: Number(credit_hours_per_year) || 0
}
const match = icTuitionData.find(row => row.UNITID === unitId); });
if (!match) return; setAutoTuition(Number.isFinite(data?.estimate) ? data.estimate : 0);
} catch {
const isGradOrProf = [ setAutoTuition(0);
"Master's Degree", }
"Doctoral Degree", })();
"Graduate/Professional Certificate", }, [selectedUnitId, program_type, credit_hours_per_year, is_in_state, is_in_district]);
"First Professional Degree"
].includes(program_type);
let partTimeRate = 0;
let fullTimeTuition = 0;
if (isGradOrProf) {
if (is_in_district) {
partTimeRate = parseFloat(match.HRCHG5 || 0);
fullTimeTuition = parseFloat(match.TUITION5 || 0);
} else if (is_in_state) {
partTimeRate = parseFloat(match.HRCHG6 || 0);
fullTimeTuition = parseFloat(match.TUITION6 || 0);
} else {
partTimeRate = parseFloat(match.HRCHG7 || 0);
fullTimeTuition = parseFloat(match.TUITION7 || 0);
}
} else {
// undergrad
if (is_in_district) {
partTimeRate = parseFloat(match.HRCHG1 || 0);
fullTimeTuition = parseFloat(match.TUITION1 || 0);
} else if (is_in_state) {
partTimeRate = parseFloat(match.HRCHG2 || 0);
fullTimeTuition = parseFloat(match.TUITION2 || 0);
} else {
partTimeRate = parseFloat(match.HRCHG3 || 0);
fullTimeTuition = parseFloat(match.TUITION3 || 0);
}
}
const chpy = parseFloat(credit_hours_per_year) || 0;
let estimate = 0;
if (chpy < 24 && partTimeRate) {
estimate = partTimeRate * chpy;
} else {
estimate = fullTimeTuition;
}
setAutoTuition(Math.round(estimate));
}, [
icTuitionData, selected_school, program_type,
credit_hours_per_year, is_in_state, is_in_district, schoolData
]);
// auto-calc program length // auto-calc program length
useEffect(() => { useEffect(() => {
@ -527,25 +437,25 @@ const ready =
name="selected_school" name="selected_school"
value={selected_school} value={selected_school}
onChange={handleSchoolChange} onChange={handleSchoolChange}
onBlur={() => { onBlur={() => {
const ok = schoolData.some( const exact = schoolSuggestions.find(o => (o.name || '').toLowerCase() === (selected_school || '').toLowerCase());
s => s.INSTNM.toLowerCase() === selected_school.toLowerCase() if (exact) handleSchoolSelect(exact); // ensure UNITID is set
); const ok = !!exact || !!selected_school;
setSchoolValid(ok); setSchoolValid(ok);
if (!ok) alert("Please pick a school from the list."); if (!ok) alert("Please pick a school from the list.");
}} }}
list="school-suggestions" list="school-suggestions"
className={`w-full border rounded p-2 ${schoolValid ? '' : 'border-red-500'}`} className={`w-full border rounded p-2 ${schoolValid ? '' : 'border-red-500'}`}
placeholder="Start typing and choose…" placeholder="Start typing and choose…"
/> />
<datalist id="school-suggestions"> <datalist id="school-suggestions">
{schoolSuggestions.map((sch, idx) => ( {schoolSuggestions.map((s) => (
<option <option
key={idx} key={`${s.unitId}:${s.name}`}
value={sch} value={s.name}
onClick={() => handleSchoolSelect(sch)} onClick={() => handleSchoolSelect(s)}
/> />
))} ))}
</datalist> </datalist>
</div> </div>
@ -555,28 +465,18 @@ const ready =
name="selected_program" name="selected_program"
value={selected_program} value={selected_program}
onChange={handleProgramChange} onChange={handleProgramChange}
onBlur={() => { onBlur={() => {
const ok = const ok = !!programSuggestions.find(p => (p.program || '').toLowerCase() === (selected_program || '').toLowerCase());
selected_school && // need a school first setProgramValid(ok);
schoolData.some( if (!ok) alert("Please pick a program from the list.");
s => }}
s.INSTNM.toLowerCase() === selected_school.toLowerCase() &&
s.CIPDESC.toLowerCase() === selected_program.toLowerCase()
);
setProgramValid(ok);
if (!ok) alert("Please pick a program from the list.");
}}
list="program-suggestions" list="program-suggestions"
className={`w-full border rounded p-2 ${programValid ? '' : 'border-red-500'}`} className={`w-full border rounded p-2 ${programValid ? '' : 'border-red-500'}`}
placeholder="Start typing and choose…" placeholder="Start typing and choose…"
/> />
<datalist id="program-suggestions"> <datalist id="program-suggestions">
{programSuggestions.map((prog, idx) => ( {programSuggestions.map((p, idx) => (
<option <option key={idx} value={p.program} onClick={() => handleProgramSelect(p.program)} />
key={idx}
value={prog}
onClick={() => handleProgramSelect(prog)}
/>
))} ))}
</datalist> </datalist>
</div> </div>

View File

@ -1,4 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import api from '../auth/apiClient.js';
export async function clientGeocodeZip(zip) { export async function clientGeocodeZip(zip) {
const apiKey = process.env.REACT_APP_GOOGLE_MAPS_API_KEY; const apiKey = process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
@ -34,23 +35,49 @@ export function haversineDistance(lat1, lon1, lat2, lon2) {
export async function fetchSchools(cipCodes) { export async function fetchSchools(cipCodes) {
try { try {
const codes = Array.isArray(cipCodes) ? cipCodes : [cipCodes];
// 1) If `cipCodes` is a single string => wrap in array const { data } = await api.get('/api/schools', {
let codesArray = Array.isArray(cipCodes) ? cipCodes : [cipCodes]; params: { cipCodes: codes.join(',') }
// 2) Turn that array into a comma-separated string
// e.g. ["1101","1409"] => "1101,1409"
const cipParam = codesArray.join(',');
// 3) Call your endpoint with `?cipCodes=1101,1409&state=NY`
const response = await axios.get('/api/schools', {
params: {
cipCodes: cipParam,
},
}); });
return response.data; return Array.isArray(data) ? data : [];
} catch (error) { } catch (err) {
console.error('Error fetching schools:', error); console.error('Error fetching schools:', err);
return []; return [];
} }
} }
export async function fetchPrograms(school, query) {
try {
const { data } = await api.get('/api/programs/suggest', {
params: { school, query, limit: 10 }
});
return Array.isArray(data) ? data : [];
} catch (err) {
console.error('Error fetching programs:', err);
return [];
}
}
export async function fetchProgramTypes(school, program) {
try {
const { data } = await api.get('/api/programs/types', {
params: { school, program }
});
return Array.isArray(data.types) ? data.types : [];
} catch (err) {
console.error('Error fetching program types:', err);
return [];
}
}
export async function fetchTuitionEstimate(unitId, programType, inState, inDistrict, creditHoursPerYear) {
try {
const { data } = await api.get('/api/tuition/estimate', {
params: { unitId, programType, inState, inDistrict, creditHoursPerYear }
});
return data || {};
} catch (err) {
console.error('Error fetching tuition estimate:', err);
return {};
}
}