import React, { useEffect, useMemo, useState, useContext } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import CareerSearch from './CareerSearch.js'; import { ONET_DEFINITIONS } from './definitions.js'; import { fetchSchools, clientGeocodeZip, haversineDistance } from '../utils/apiUtils.js'; import ChatCtx from '../contexts/ChatCtx.js'; import api from '../auth/apiClient.js'; import { loadDraft, saveDraft } from '../utils/onboardingDraftApi.js'; // Normalize DB/GPT KSA payloads into IM/LV rows for combineIMandLV function normalizeKsaPayloadForCombine(payload, socCode) { if (!payload) return []; const out = []; const coerce = (arr = [], ksa_type) => { arr.forEach((it) => { const name = it.elementName || it.name || it.title || ''; // If already IM/LV-shaped, just pass through if (it.scaleID && it.dataValue != null) { out.push({ ...it, onetSocCode: socCode, ksa_type, elementName: name }); return; } // Otherwise split combined values into IM/LV rows if present const imp = it.importanceValue ?? it.importance ?? it.importanceScore; const lvl = it.levelValue ?? it.level ?? it.levelScore; if (imp != null) out.push({ onetSocCode: socCode, elementName: name, ksa_type, scaleID: 'IM', dataValue: imp }); if (lvl != null) out.push({ onetSocCode: socCode, elementName: name, ksa_type, scaleID: 'LV', dataValue: lvl }); }); }; coerce(payload.knowledge, 'Knowledge'); coerce(payload.skills, 'Skill'); coerce(payload.abilities, 'Ability'); return out; } // Helper to combine IM and LV for each KSA function combineIMandLV(rows) { const map = new Map(); for (const row of rows) { const key = `${row.onetSocCode}::${row.elementName}::${row.ksa_type}`; if (!map.has(key)) { map.set(key, { onetSocCode: row.onetSocCode, elementName: row.elementName, ksa_type: row.ksa_type, importanceValue: null, levelValue: null, }); } const entry = map.get(key); if (row.scaleID === 'IM') { entry.importanceValue = row.dataValue; } else if (row.scaleID === 'LV') { entry.levelValue = row.dataValue; } map.set(key, entry); } return Array.from(map.values()); } function ensureHttp(urlString) { if (!urlString) return ''; // If it already starts with 'http://' or 'https://', just return as-is. if (/^https?:\/\//i.test(urlString)) { return urlString; } // Otherwise prepend 'https://'. return `https://${urlString}`; } function cleanCipDesc(s) { if (!s) return 'N/A'; return String(s).trim().replace(/\.\s*$/, ''); // strip one trailing period + any spaces } // Convert numeric importance (1–5) to star or emoji compact representation function renderImportance(val) { const max = 5; const rounded = Math.round(val); const stars = '★'.repeat(rounded) + '☆'.repeat(max - rounded); return `${stars}`; } // Convert numeric level (0–7) to bar or block representation function renderLevel(val) { const max = 7; const rounded = Math.round(val); const filled = '■'.repeat(rounded); const empty = '□'.repeat(max - rounded); return `${filled}${empty}`; } function EducationalProgramsPage() { const location = useLocation(); const navigate = useNavigate(); const { state } = location; const navCareer = state?.selectedCareer || {}; const [selectedCareer, setSelectedCareer] = useState(navCareer); const [socCode, setSocCode] = useState(navCareer.code || ''); const [cipCodes, setCipCodes] = useState(navCareer.cipCodes || []); const [careerTitle, setCareerTitle] = useState(navCareer.title || ''); const [userState, setUserState]= useState(navCareer.userState || ''); const [userZip, setUserZip] = useState(navCareer.userZip || ''); const [allKsaData, setAllKsaData] = useState([]); const [ksaForCareer, setKsaForCareer] = useState([]); const [loadingKsa, setLoadingKsa] = useState(false); const [ksaError, setKsaError] = useState(null); const [schools, setSchools] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // Additional filters const [sortBy, setSortBy] = useState('tuition'); const [maxTuition, setMaxTuition] = useState(20000); const [maxDistance, setMaxDistance] = useState(100); const [inStateOnly, setInStateOnly] = useState(false); const [showSearch, setShowSearch] = useState(true); const { setChatSnapshot } = useContext(ChatCtx); function normalizeCipPrefix(code) { if (code === undefined || code === null) return null; let s = String(code).trim(); if (s.includes('.')) { // ensure two digits before first dot (e.g. "4.0201" → "04.0201") s = s.replace(/^(\d)\./, '0$1.'); // drop non-digits (remove dot) → "04.0201" → "040201" s = s.replace(/\D/g, ''); } else { // already digits-only ("040201", "0402", "402", 402, etc.) s = s.replace(/\D/g, ''); } // force 4-digit prefix (pad if too short, trim if too long) return s.padStart(4, '0').slice(0, 4); } function normalizeCipList(arr) { const list = Array.isArray(arr) ? arr : [arr]; // unique and non-empty return [...new Set(list.map(normalizeCipPrefix).filter(Boolean))]; } // If user picks a new career from CareerSearch const handleCareerSelected = (foundObj) => { setCareerTitle(foundObj.title || ''); setSelectedCareer(foundObj); localStorage.setItem('selectedCareer', JSON.stringify(foundObj)); const cleaned = normalizeCipList(foundObj.cip_code); setCipCodes(cleaned); setSocCode(foundObj.soc_code); setShowSearch(false); }; function handleChangeCareer() { // Optionally remove from localStorage if the user is truly 'unselecting' it localStorage.removeItem('selectedCareer'); setSelectedCareer(null); setShowSearch(true); } // Fixed handleSelectSchool (removed extra brace) // Replace your existing handleSelectSchool with this: const handleSelectSchool = async (school) => { const proceed = window.confirm( 'You’re about to move to the financial planning portion of the app, which is reserved for premium subscribers. Do you want to continue?' ); if (!proceed) return; // normalize the currently selected career for handoff (optional) const sel = selectedCareer ? { ...selectedCareer, code: selectedCareer.code || selectedCareer.soc_code || selectedCareer.socCode } : null; // 1) normalize college fields const selected_school = school?.INSTNM || ''; const selected_program = (school?.CIPDESC || ''); const program_type = school?.CREDDESC || ''; const unit_id = school?.UNITID || ''; // 2) merge into the cookie-backed draft (don’t clobber existing sections) let draft = null; try { draft = await loadDraft(); } catch (_) {} const existing = draft?.data || {}; await saveDraft({ step: 0, data: { collegeData: { selected_school, selected_program, program_type, unit_id, } } }); // 3) navigate (state is optional now that draft persists) navigate('/career-roadmap', { state: { premiumOnboardingState: { selectedCareer: sel, selectedSchool: { INSTNM: school.INSTNM, CIPDESC: selected_program, CREDDESC: program_type, UNITID: unit_id }, }, }, }); }; function getSearchLinks(ksaName, careerTitle) { const combinedQuery = `${careerTitle} ${ksaName}`.trim(); const encoded = encodeURIComponent(combinedQuery); const courseraUrl = `https://www.coursera.org/search?query=${encoded}`; const edxUrl = `https://www.edx.org/search?q=${encoded}`; return [ { title: 'Coursera', url: courseraUrl }, { title: 'edX', url: edxUrl }, ]; } useEffect(() => { if (!location.state) return; // nothing passed const { socCode : newSoc, cipCodes : newCips = [], careerTitle : newTitle = '', selectedCareer: navCareer // optional convenience payload } = location.state; if (newSoc) setSocCode(newSoc); if (newCips.length) setCipCodes(normalizeCipList(newCips)); if (newTitle) setCareerTitle(newTitle); if (navCareer) setSelectedCareer(navCareer); /* if *any* career info arrived we don’t need the search box */ if (newSoc || navCareer) setShowSearch(false); }, [location.state]); // Filter: only IM >=3, then combine IM+LV useEffect(() => { if (!socCode) { setKsaForCareer([]); return; } // No local blob anymore → ask server3 (local/DB/GPT) for this SOC. if (!allKsaData.length) { fetchKsaFallback(socCode, careerTitle); return; } // If you keep allKsaData around for dev, this path still works: let filtered = allKsaData.filter((r) => r.onetSocCode === socCode); filtered = filtered.filter((r) => r.recommendSuppress !== 'Y'); filtered = filtered.filter((r) => ['IM', 'LV'].includes(r.scaleID)); let combined = combineIMandLV(filtered) .filter((i) => i.importanceValue != null && i.importanceValue >= 3) .sort((a, b) => (b.importanceValue || 0) - (a.importanceValue || 0)); if (combined.length === 0) { fetchKsaFallback(socCode, careerTitle); } else { setKsaForCareer(combined); } }, [socCode, allKsaData, careerTitle]); // Load user profile // Load user profile (cookie-based auth via api client) useEffect(() => { async function loadUserProfile() { try { const { data } = await api.get('/api/user-profile?fields=zipcode,area'); setUserZip(data.zipcode || ''); setUserState(data.state || ''); } catch (err) { console.error('Error loading user profile:', err); } // Then handle localStorage: const stored = localStorage.getItem('selectedCareer'); if (stored) { const parsed = JSON.parse(stored); setSelectedCareer(parsed); setCareerTitle(parsed.title || ''); // Re-set CIP code logic (like in handleCareerSelected) setCipCodes(normalizeCipList(parsed.cip_code)); setSocCode(parsed.soc_code); setShowSearch(false); } } loadUserProfile(); }, []); // Fetch schools once CIP codes are set useEffect(() => { if (!cipCodes.length) return; const fetchData = async () => { setLoading(true); setError(null); try { const fetchedSchools = await fetchSchools(cipCodes); let userLat = null; let userLng = null; if (userZip) { try { const geoResult = await clientGeocodeZip(userZip); userLat = geoResult.lat; userLng = geoResult.lng; } catch (geoErr) { console.warn('Unable to geocode user ZIP:', geoErr.message); } } const schoolsWithDistance = fetchedSchools.map((sch) => { const lat2 = sch.LATITUDE ? parseFloat(sch.LATITUDE) : null; const lon2 = sch.LONGITUD ? parseFloat(sch.LONGITUD) : null; if (userLat && userLng && lat2 && lon2) { const distMiles = haversineDistance(userLat, userLng, lat2, lon2); return { ...sch, distance: distMiles.toFixed(1) }; } return { ...sch, distance: null }; }); setSchools(schoolsWithDistance); } catch (err) { console.error('[EducationalProgramsPage] error:', err); setError('Failed to load schools.'); } finally { setLoading(false); } }; fetchData(); }, [cipCodes, userState, userZip]); // Sort schools in useMemo const filteredAndSortedSchools = useMemo(() => { if (!schools) return []; let result = [...schools]; // In-state if (inStateOnly && userState) { const userAbbr = userState.trim().toUpperCase(); result = result.filter((sch) => { const schoolAbbr = sch.State ? sch.State.trim().toUpperCase() : ''; return schoolAbbr === userAbbr; }); } // Max tuition result = result.filter((sch) => { const cost = sch['In_state cost'] ? parseFloat(sch['In_state cost']) : 999999; return cost <= maxTuition; }); // Max distance result = result.filter((sch) => { if (sch.distance === null) return true; return parseFloat(sch.distance) <= maxDistance; }); // Sort if (sortBy === 'distance') { result.sort((a, b) => { const distA = a.distance ? parseFloat(a.distance) : Infinity; const distB = b.distance ? parseFloat(b.distance) : Infinity; return distA - distB; }); } else { // Sort by in-state tuition result.sort((a, b) => { const tA = a['In_state cost'] ? parseFloat(a['In_state cost']) : Infinity; const tB = b['In_state cost'] ? parseFloat(b['In_state cost']) : Infinity; return tA - tB; }); } return result; }, [schools, inStateOnly, userState, maxTuition, maxDistance, sortBy]); const TOP_N = 8; // ← tweak here const topSchools = filteredAndSortedSchools.slice(0, TOP_N).map(s => ({ name : s.INSTNM, inState : Number(s['In_state cost'] || 0), outState : Number(s['Out_state cost'] || 0), distance : s.distance ? Number(s.distance) : null, degree : s.CREDDESC, website : s.Website })); const snapshot = useMemo(() => ({ careerCtx : socCode ? { socCode, careerTitle, cipCodes } : null, ksaCtx : ksaForCareer.length ? { total : ksaForCareer.length, topKnow : ksaForCareer.filter(k => k.ksa_type === 'Knowledge') .slice(0,3).map(k => k.elementName), topSkill : ksaForCareer.filter(k => k.ksa_type === 'Skill') .slice(0,3).map(k => k.elementName) } : null, filterCtx : { sortBy, maxTuition, maxDistance, inStateOnly }, schoolCtx : { count : filteredAndSortedSchools.length, sample : topSchools } }), [ socCode, careerTitle, cipCodes, ksaForCareer, sortBy, maxTuition, maxDistance, inStateOnly, filteredAndSortedSchools ]); useEffect(() => { setChatSnapshot(snapshot); }, [snapshot, setChatSnapshot]); // Render a single KSA row function renderKsaRow(k, idx, careerTitle) { const elementName = k.elementName; const impStars = renderImportance(k.importanceValue); const lvlBars = k.levelValue !== null ? renderLevel(k.levelValue) : 'n/a'; const isAbility = k.ksa_type === 'Ability'; const links = !isAbility ? getSearchLinks(elementName, careerTitle) : null; const definition = ONET_DEFINITIONS[elementName] || 'No definition available'; return ( {elementName} i {impStars} {lvlBars} {!isAbility && ( {links?.map((link, i) => (
{link.title}
))} )} ); } // Knowledge / Skills / Abilities in 3 columns function renderKsaSection() { if (loadingKsa) return

Loading KSA data...

; if (ksaError) return

{ksaError}

; if (!socCode) return

Please select a career to see KSA data.

; if (!ksaForCareer.length) { return

No Knowledge, Skills, and Abilities data found for {careerTitle}

; } const knowledge = ksaForCareer.filter((k) => k.ksa_type === 'Knowledge'); const skillRows = ksaForCareer.filter((k) => k.ksa_type === 'Skill'); const abilities = ksaForCareer.filter((k) => k.ksa_type === 'Ability'); return (

Knowledge, Skills, and Abilities needed for:{' '} {careerTitle || 'Unknown Career'}

{/* Knowledge */}

Knowledge

{knowledge.length ? ( {knowledge.map((k, idx) => renderKsaRow(k, idx, careerTitle))}
Knowledge Domain Importance Level Courses
) : (

No knowledge entries.

)}
{/* Skills */}

Skills

{skillRows.length ? ( {skillRows.map((sk, idx) => renderKsaRow(sk, idx, careerTitle))}
Skill Importance Level Courses
) : (

No skill entries.

)}
{/* Abilities */}

Abilities

{abilities.length ? ( {abilities.map((ab, idx) => renderKsaRow(ab, idx, careerTitle))}
Ability Importance Level
Why no courses? i
) : (

No ability entries.

)}
); } // If no CIP codes => fallback if (!cipCodes.length) { return (

Educational Programs

You have not selected a career yet. Please search for one below:

After you pick a career, we’ll display matching educational programs.

); } if (loading) { return
Loading skills and educational programs
; } if (error) { return (

Error: {error}

); } // No local KSA records for this SOC => ask server3 to resolve (local/DB/GPT) async function fetchKsaFallback(socCode, careerTitle) { setLoadingKsa(true); setKsaError(null); try { // Ask server3. It will: // 1) Serve local ksa_data.json if present for this SOC // 2) Otherwise return DB ai_generated_ksa (IM/LV rows) // 3) Otherwise call GPT, normalize to IM/LV, store in DB, and return it const resp = await api.get(`/api/premium/ksa/${socCode}`, { params: { careerTitle: careerTitle || '' } }); // server3 returns either: // { source: 'local', data: [IM/LV rows...] } // or // { source: 'db'|'chatgpt', data: { knowledge:[], skills:[], abilities:[] } } const payload = resp?.data?.data ?? resp?.data; let rows; if (Array.isArray(payload)) { // Already IM/LV rows rows = payload; } else { // Object with knowledge/skills/abilities rows = normalizeKsaPayloadForCombine(payload, socCode); } const combined = combineIMandLV(rows) .filter(i => i.importanceValue != null && i.importanceValue >= 3) .sort((a, b) => (b.importanceValue || 0) - (a.importanceValue || 0)); setKsaForCareer(combined); } catch (err) { console.error('Error fetching KSAs:', err); setKsaError('Could not load KSAs. Please try again later.'); setKsaForCareer([]); } finally { setLoadingKsa(false); } } return (
{/* 1. If user is allowed to search for a career again, show CareerSearch */} {showSearch && (

Find a Career

)} {/* 2. If the user has already selected a career and we're not showing search */} {selectedCareer && !showSearch && (

Currently selected: {selectedCareer.title}

)} {/* If we’re loading or errored out, handle that up front */} {loading && (
Loading skills and educational programs...
)} {error && (
Error: {error}
)} {/* 3. Display CIP-based data only if we have CIP codes (means we have a known career) */} {cipCodes.length > 0 ? ( <> {/* KSA section */} {renderKsaSection()} {/* School List */}

Schools for: {careerTitle || 'Unknown Career'}

{/* Filter Bar */}
{userState && ( )}
{/* Display the sorted/filtered schools */}
{filteredAndSortedSchools.map((school, idx) => { const displayWebsite = ensureHttp(school['Website']); return (
{school['Website'] ? ( {school['INSTNM'] || 'Unnamed School'} ) : ( school['INSTNM'] || 'Unnamed School' )}

Program: {cleanCipDesc(school['CIPDESC'])}

Degree Type: {school['CREDDESC'] || 'N/A'}

In-State Tuition: ${school['In_state cost'] || 'N/A'}

Out-of-State Tuition: ${school['Out_state cost'] || 'N/A'}

Distance: {school.distance !== null ? `${school.distance} mi` : 'N/A'}

); })}
) : ( /* 4. If no CIP codes, user hasn't picked a valid career or there's no data */

You have not selected a career (or no CIP codes found) so no programs can be shown.

)}
); } export default EducationalProgramsPage;