Put all frontend data calls to backend queries
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
4c5fdea80c
commit
fe8102385e
@ -1 +1 @@
|
||||
277110e50c0c8ee5c02fee3c1174a225d5593511-803b2c2ecad09a0fbca070296808a53489de891a-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
3eefb2cd6c785e5815d042d108f67a87c6819a4d-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
|
@ -1 +1 @@
|
||||
8eca4afbc834297a74d0c140a17e370c19102dea
|
||||
1a7fe9191922c4f8389027ed53b6a4909740a48b
|
||||
|
@ -1 +1 @@
|
||||
8eca4afbc834297a74d0c140a17e370c19102dea
|
||||
1a7fe9191922c4f8389027ed53b6a4909740a48b
|
||||
|
@ -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=<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
|
||||
**************************************************/
|
||||
|
@ -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; }
|
||||
|
7
package-lock.json
generated
7
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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,25 +585,24 @@ 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
|
||||
!career.fromManualSearch &&
|
||||
priorities.interests &&
|
||||
priorities.interests !== "I’m not sure yet";
|
||||
const defaultInterestValue =
|
||||
userHasInventory
|
||||
?
|
||||
(fitRatingMap[career.fit] || masterRatings.interests || 3)
|
||||
:
|
||||
3;
|
||||
|
||||
const defaultInterestValue = userHasInventory
|
||||
? (fitRatingMap[career.fit] || masterRatings.interests || 3)
|
||||
: 3;
|
||||
|
||||
const defaultMeaningValue = 3;
|
||||
|
||||
// 4) open the InterestMeaningModal instead of using prompt()
|
||||
// 3) open the InterestMeaningModal (unchanged)
|
||||
setModalData({
|
||||
career,
|
||||
masterRatings,
|
||||
@ -620,7 +611,8 @@ useEffect(() => {
|
||||
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,12 +686,16 @@ 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) {
|
||||
let cleanedCips = cleanCipCodes(rawCips);
|
||||
|
||||
// 2) fallback: ask server2 to map SOC→CIP if none were found
|
||||
if (!cleanedCips.length) {
|
||||
try {
|
||||
const candidates = [
|
||||
fullSoc, // as-is
|
||||
@ -720,10 +714,8 @@ const handleSelectForEducation = async (career) => {
|
||||
rawCips = [fromApi];
|
||||
cleanedCips = cleanCipCodes(rawCips);
|
||||
}
|
||||
} catch {
|
||||
// best-effort fallback; continue
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Persist for the next page (keep raw list if we have it)
|
||||
const careerForStorage = {
|
||||
@ -853,7 +845,7 @@ const handleSelectForEducation = async (career) => {
|
||||
<CareerSearch
|
||||
disabled={showModal}
|
||||
onCareerSelected={(careerObj) => {
|
||||
console.log('[Dashboard] onCareerSelected =>', careerObj);
|
||||
setLoading(true);
|
||||
setPendingCareerForModal(careerObj);
|
||||
}}
|
||||
/>
|
||||
|
@ -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';
|
||||
|
||||
@ -14,72 +13,99 @@ const normalize = (s = '') =>
|
||||
|
||||
/* ---------- component ---------- */
|
||||
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 [selectedObj, setSelectedObj] = useState(null); // ✓ state
|
||||
const computedDisabled = externallyDisabled || !!selectedObj;
|
||||
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) {
|
||||
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_code: c.cip_codes,
|
||||
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]);
|
||||
|
||||
/* 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();
|
||||
}
|
||||
}
|
||||
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();
|
||||
};
|
||||
|
||||
|
||||
/* clear & edit again */
|
||||
const reset = () => {
|
||||
setSelectedObj(null);
|
||||
setSearchInput('');
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
const listId = 'career-titles';
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label className="block font-medium mb-1">
|
||||
@ -92,24 +118,61 @@ const CareerSearch = ({ onCareerSelected, required, disabled: externallyDisabled
|
||||
list={listId}
|
||||
value={searchInput}
|
||||
required={required}
|
||||
disabled={computedDisabled} // lock when chosen
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
disabled={computedDisabled}
|
||||
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 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}
|
||||
className={`w-full border rounded p-2
|
||||
${computedDisabled ? 'bg-gray-100 cursor-not-allowed opacity-60' : ''}`}
|
||||
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 && (
|
||||
<datalist id={listId}>
|
||||
{careerObjects.map((o) => (
|
||||
<option key={o.soc_code} value={o.title} />
|
||||
{suggestions.map((o) => (
|
||||
<option key={`${o.soc_code}:${o.title}`} value={o.title} />
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
|
||||
{loading && !computedDisabled && (
|
||||
<div className="absolute right-2 top-2 text-xs text-gray-500">loading…</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PSA message */}
|
||||
{!selectedObj && (
|
||||
<p className="mt-2 text-sm text-blue-700">
|
||||
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
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* change / clear link */}
|
||||
{selectedObj && !externallyDisabled && (
|
||||
<button
|
||||
type="button"
|
||||
|
@ -93,7 +93,7 @@ function renderLevel(val) {
|
||||
function EducationalProgramsPage() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { state } = useLocation();
|
||||
const { state } = location;
|
||||
const navCareer = state?.selectedCareer || {};
|
||||
const [selectedCareer, setSelectedCareer] = useState(navCareer);
|
||||
const [socCode, setSocCode] = useState(navCareer.code || '');
|
||||
@ -738,6 +738,7 @@ const topSchools = filteredAndSortedSchools.slice(0, TOP_N).map(s => ({
|
||||
school['INSTNM'] || 'Unnamed School'
|
||||
)}
|
||||
</strong>
|
||||
<p> Program: {school['CIPDESC'] || 'N/A'}</p>
|
||||
<p>Degree Type: {school['CREDDESC'] || '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>
|
||||
|
@ -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 FinancialAidWizard from '../../components/FinancialAidWizard.js';
|
||||
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>;
|
||||
|
||||
function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
|
||||
// CIP / iPEDS local states
|
||||
const [schoolData, setSchoolData] = useState([]);
|
||||
const [icTuitionData, setIcTuitionData] = useState([]);
|
||||
const [schoolSuggestions, setSchoolSuggestions] = useState([]);
|
||||
const schoolPrevRef = useRef('');
|
||||
const [programSuggestions, setProgramSuggestions] = useState([]);
|
||||
const [availableProgramTypes, setAvailableProgramTypes] = useState([]);
|
||||
const [schoolValid, setSchoolValid] = useState(false);
|
||||
@ -18,6 +16,7 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
|
||||
const [enrollmentDate, setEnrollmentDate] = useState(
|
||||
data.enrollment_date || ''
|
||||
);
|
||||
const [selectedUnitId, setSelectedUnitId] = useState(null);
|
||||
const [expectedGraduation, setExpectedGraduation] = useState(data.expected_graduation || '');
|
||||
const [showAidWizard, setShowAidWizard] = useState(false);
|
||||
const location = useLocation();
|
||||
@ -149,55 +148,6 @@ useEffect(() => {
|
||||
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(() => {
|
||||
if (college_enrollment_status !== 'prospective_student') return;
|
||||
|
||||
@ -213,52 +163,54 @@ useEffect(() => {
|
||||
}, [college_enrollment_status, enrollmentDate, data.program_length, setData]);
|
||||
|
||||
// School Name
|
||||
const handleSchoolChange = (eOrVal) => {
|
||||
const value =
|
||||
typeof eOrVal === 'string' ? eOrVal : eOrVal?.target?.value || '';
|
||||
setData(prev => ({
|
||||
...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 handleSchoolChange = async (e) => {
|
||||
const value = e.target.value || '';
|
||||
setData(prev => ({ ...prev, selected_school: value, selected_program: '', program_type: '', credit_hours_required: '' }));
|
||||
if (!value.trim()) { setSchoolSuggestions([]); return; }
|
||||
|
||||
const handleSchoolSelect = (schoolName) => {
|
||||
setData(prev => ({
|
||||
...prev,
|
||||
selected_school: schoolName,
|
||||
selected_program: '',
|
||||
program_type: '',
|
||||
credit_hours_required: '',
|
||||
}));
|
||||
const it = e?.nativeEvent?.inputType; // Chromium: 'insertReplacementText' on datalist pick
|
||||
const replacement = it === 'insertReplacementText';
|
||||
const bigJump = Math.abs(value.length - (schoolPrevRef.current || '').length) > 1;
|
||||
try {
|
||||
const resp = await api.get('/api/schools/suggest', { params: { query: value, limit: 10 }});
|
||||
const opts = Array.isArray(resp.data) ? resp.data : [];
|
||||
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([]);
|
||||
}
|
||||
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
|
||||
const handleProgramChange = (e) => {
|
||||
const handleProgramChange = async (e) => {
|
||||
const value = e.target.value;
|
||||
setData(prev => ({ ...prev, selected_program: value }));
|
||||
if (!value) {
|
||||
if (!value || !selected_school) { setProgramSuggestions([]); return; }
|
||||
try {
|
||||
const { data } = await api.get('/api/programs/suggest', {
|
||||
params: { school: selected_school, query: value, limit: 10 }
|
||||
});
|
||||
setProgramSuggestions(Array.isArray(data) ? data : []); // [{ program }]
|
||||
} catch {
|
||||
setProgramSuggestions([]);
|
||||
return;
|
||||
}
|
||||
const filtered = schoolData.filter(
|
||||
s => s.INSTNM.toLowerCase() === selected_school.toLowerCase() &&
|
||||
s.CIPDESC.toLowerCase().includes(value.toLowerCase())
|
||||
);
|
||||
const uniquePrograms = [...new Set(filtered.map(s => s.CIPDESC))];
|
||||
setProgramSuggestions(uniquePrograms.slice(0, 10));
|
||||
};
|
||||
|
||||
const handleProgramSelect = (prog) => {
|
||||
@ -279,79 +231,37 @@ useEffect(() => {
|
||||
|
||||
// once we have school+program => load possible program types
|
||||
useEffect(() => {
|
||||
if (!selected_program || !selected_school || !schoolData.length) return;
|
||||
const possibleTypes = schoolData
|
||||
.filter(
|
||||
s => s.INSTNM.toLowerCase() === selected_school.toLowerCase() &&
|
||||
s.CIPDESC === selected_program
|
||||
)
|
||||
.map(s => s.CREDDESC);
|
||||
setAvailableProgramTypes([...new Set(possibleTypes)]);
|
||||
}, [selected_program, selected_school, schoolData]);
|
||||
if (!selected_program || !selected_school) { setAvailableProgramTypes([]); return; }
|
||||
(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/programs/types', { params: { school: selected_school, program: selected_program }});
|
||||
setAvailableProgramTypes(Array.isArray(data?.types) ? data.types : []);
|
||||
} catch {
|
||||
setAvailableProgramTypes([]);
|
||||
}
|
||||
})();
|
||||
}, [selected_program, selected_school]);
|
||||
|
||||
// auto-calc tuition
|
||||
/*Auto Calc Tuition*/
|
||||
useEffect(() => {
|
||||
if (!icTuitionData.length) return;
|
||||
if (!selected_school || !program_type || !credit_hours_per_year) return;
|
||||
|
||||
const found = schoolData.find(
|
||||
s => s.INSTNM.toLowerCase() === selected_school.toLowerCase()
|
||||
);
|
||||
if (!found) return;
|
||||
|
||||
const unitId = found.UNITID;
|
||||
if (!unitId) return;
|
||||
|
||||
const match = icTuitionData.find(row => row.UNITID === unitId);
|
||||
if (!match) return;
|
||||
|
||||
const isGradOrProf = [
|
||||
"Master's Degree",
|
||||
"Doctoral Degree",
|
||||
"Graduate/Professional Certificate",
|
||||
"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);
|
||||
(async () => {
|
||||
if (!selectedUnitId || !program_type || !credit_hours_per_year) return;
|
||||
try {
|
||||
const { data } = await api.get('/api/tuition/estimate', {
|
||||
params: {
|
||||
unitId: String(selectedUnitId),
|
||||
programType: program_type,
|
||||
inState: is_in_state ? 1 : 0,
|
||||
inDistrict: is_in_district ? 1 : 0,
|
||||
creditHoursPerYear: Number(credit_hours_per_year) || 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);
|
||||
});
|
||||
setAutoTuition(Number.isFinite(data?.estimate) ? data.estimate : 0);
|
||||
} catch {
|
||||
setAutoTuition(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
|
||||
]);
|
||||
})();
|
||||
}, [selectedUnitId, program_type, credit_hours_per_year, is_in_state, is_in_district]);
|
||||
|
||||
// auto-calc program length
|
||||
useEffect(() => {
|
||||
@ -528,9 +438,9 @@ const ready =
|
||||
value={selected_school}
|
||||
onChange={handleSchoolChange}
|
||||
onBlur={() => {
|
||||
const ok = schoolData.some(
|
||||
s => s.INSTNM.toLowerCase() === selected_school.toLowerCase()
|
||||
);
|
||||
const exact = schoolSuggestions.find(o => (o.name || '').toLowerCase() === (selected_school || '').toLowerCase());
|
||||
if (exact) handleSchoolSelect(exact); // ensure UNITID is set
|
||||
const ok = !!exact || !!selected_school;
|
||||
setSchoolValid(ok);
|
||||
if (!ok) alert("Please pick a school from the list.");
|
||||
}}
|
||||
@ -539,11 +449,11 @@ const ready =
|
||||
placeholder="Start typing and choose…"
|
||||
/>
|
||||
<datalist id="school-suggestions">
|
||||
{schoolSuggestions.map((sch, idx) => (
|
||||
{schoolSuggestions.map((s) => (
|
||||
<option
|
||||
key={idx}
|
||||
value={sch}
|
||||
onClick={() => handleSchoolSelect(sch)}
|
||||
key={`${s.unitId}:${s.name}`}
|
||||
value={s.name}
|
||||
onClick={() => handleSchoolSelect(s)}
|
||||
/>
|
||||
))}
|
||||
</datalist>
|
||||
@ -556,13 +466,7 @@ const ready =
|
||||
value={selected_program}
|
||||
onChange={handleProgramChange}
|
||||
onBlur={() => {
|
||||
const ok =
|
||||
selected_school && // need a school first
|
||||
schoolData.some(
|
||||
s =>
|
||||
s.INSTNM.toLowerCase() === selected_school.toLowerCase() &&
|
||||
s.CIPDESC.toLowerCase() === selected_program.toLowerCase()
|
||||
);
|
||||
const ok = !!programSuggestions.find(p => (p.program || '').toLowerCase() === (selected_program || '').toLowerCase());
|
||||
setProgramValid(ok);
|
||||
if (!ok) alert("Please pick a program from the list.");
|
||||
}}
|
||||
@ -571,12 +475,8 @@ const ready =
|
||||
placeholder="Start typing and choose…"
|
||||
/>
|
||||
<datalist id="program-suggestions">
|
||||
{programSuggestions.map((prog, idx) => (
|
||||
<option
|
||||
key={idx}
|
||||
value={prog}
|
||||
onClick={() => handleProgramSelect(prog)}
|
||||
/>
|
||||
{programSuggestions.map((p, idx) => (
|
||||
<option key={idx} value={p.program} onClick={() => handleProgramSelect(p.program)} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
@ -1,4 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import api from '../auth/apiClient.js';
|
||||
|
||||
export async function clientGeocodeZip(zip) {
|
||||
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) {
|
||||
try {
|
||||
|
||||
// 1) If `cipCodes` is a single string => wrap in array
|
||||
let codesArray = Array.isArray(cipCodes) ? cipCodes : [cipCodes];
|
||||
|
||||
// 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,
|
||||
},
|
||||
const codes = Array.isArray(cipCodes) ? cipCodes : [cipCodes];
|
||||
const { data } = await api.get('/api/schools', {
|
||||
params: { cipCodes: codes.join(',') }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching schools:', error);
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch (err) {
|
||||
console.error('Error fetching schools:', err);
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user