diff --git a/src/App.js b/src/App.js index b81a718..852e2fb 100644 --- a/src/App.js +++ b/src/App.js @@ -89,6 +89,7 @@ function App() { // Logout const handleLogout = () => { localStorage.removeItem('token'); + localStorage.removeItem('careerSuggestionsCache'); setIsAuthenticated(false); setUser(null); navigate('/signin'); diff --git a/src/components/CareerExplorer.js b/src/components/CareerExplorer.js index 0b1e341..a22aad0 100644 --- a/src/components/CareerExplorer.js +++ b/src/components/CareerExplorer.js @@ -8,27 +8,27 @@ import CareerSearch from './CareerSearch.js'; import { Button } from './ui/button.js'; import axios from 'axios'; - const STATES = [ - { name: 'Alabama', code: 'AL' }, { name: 'Alaska', code: 'AK' }, { name: 'Arizona', code: 'AZ' }, - { name: 'Arkansas', code: 'AR' }, { name: 'California', code: 'CA' }, { name: 'Colorado', code: 'CO' }, - { name: 'Connecticut', code: 'CT' }, { name: 'Delaware', code: 'DE' }, { name: 'District of Columbia', code: 'DC' }, - { name: 'Florida', code: 'FL' }, { name: 'Georgia', code: 'GA' }, { name: 'Hawaii', code: 'HI' }, - { name: 'Idaho', code: 'ID' }, { name: 'Illinois', code: 'IL' }, { name: 'Indiana', code: 'IN' }, - { name: 'Iowa', code: 'IA' }, { name: 'Kansas', code: 'KS' }, { name: 'Kentucky', code: 'KY' }, - { name: 'Louisiana', code: 'LA' }, { name: 'Maine', code: 'ME' }, { name: 'Maryland', code: 'MD' }, - { name: 'Massachusetts', code: 'MA' }, { name: 'Michigan', code: 'MI' }, { name: 'Minnesota', code: 'MN' }, - { name: 'Mississippi', code: 'MS' }, { name: 'Missouri', code: 'MO' }, { name: 'Montana', code: 'MT' }, - { name: 'Nebraska', code: 'NE' }, { name: 'Nevada', code: 'NV' }, { name: 'New Hampshire', code: 'NH' }, - { name: 'New Jersey', code: 'NJ' }, { name: 'New Mexico', code: 'NM' }, { name: 'New York', code: 'NY' }, - { name: 'North Carolina', code: 'NC' }, { name: 'North Dakota', code: 'ND' }, { name: 'Ohio', code: 'OH' }, - { name: 'Oklahoma', code: 'OK' }, { name: 'Oregon', code: 'OR' }, { name: 'Pennsylvania', code: 'PA' }, - { name: 'Rhode Island', code: 'RI' }, { name: 'South Carolina', code: 'SC' }, { name: 'South Dakota', code: 'SD' }, - { name: 'Tennessee', code: 'TN' }, { name: 'Texas', code: 'TX' }, { name: 'Utah', code: 'UT' }, - { name: 'Vermont', code: 'VT' }, { name: 'Virginia', code: 'VA' }, { name: 'Washington', code: 'WA' }, - { name: 'West Virginia', code: 'WV' }, { name: 'Wisconsin', code: 'WI' }, { name: 'Wyoming', code: 'WY' }, - ]; +const STATES = [ + { name: 'Alabama', code: 'AL' }, { name: 'Alaska', code: 'AK' }, { name: 'Arizona', code: 'AZ' }, + { name: 'Arkansas', code: 'AR' }, { name: 'California', code: 'CA' }, { name: 'Colorado', code: 'CO' }, + { name: 'Connecticut', code: 'CT' }, { name: 'Delaware', code: 'DE' }, { name: 'District of Columbia', code: 'DC' }, + { name: 'Florida', code: 'FL' }, { name: 'Georgia', code: 'GA' }, { name: 'Hawaii', code: 'HI' }, + { name: 'Idaho', code: 'ID' }, { name: 'Illinois', code: 'IL' }, { name: 'Indiana', code: 'IN' }, + { name: 'Iowa', code: 'IA' }, { name: 'Kansas', code: 'KS' }, { name: 'Kentucky', code: 'KY' }, + { name: 'Louisiana', code: 'LA' }, { name: 'Maine', code: 'ME' }, { name: 'Maryland', code: 'MD' }, + { name: 'Massachusetts', code: 'MA' }, { name: 'Michigan', code: 'MI' }, { name: 'Minnesota', code: 'MN' }, + { name: 'Mississippi', code: 'MS' }, { name: 'Missouri', code: 'MO' }, { name: 'Montana', code: 'MT' }, + { name: 'Nebraska', code: 'NE' }, { name: 'Nevada', code: 'NV' }, { name: 'New Hampshire', code: 'NH' }, + { name: 'New Jersey', code: 'NJ' }, { name: 'New Mexico', code: 'NM' }, { name: 'New York', code: 'NY' }, + { name: 'North Carolina', code: 'NC' }, { name: 'North Dakota', code: 'ND' }, { name: 'Ohio', code: 'OH' }, + { name: 'Oklahoma', code: 'OK' }, { name: 'Oregon', code: 'OR' }, { name: 'Pennsylvania', code: 'PA' }, + { name: 'Rhode Island', code: 'RI' }, { name: 'South Carolina', code: 'SC' }, { name: 'South Dakota', code: 'SD' }, + { name: 'Tennessee', code: 'TN' }, { name: 'Texas', code: 'TX' }, { name: 'Utah', code: 'UT' }, + { name: 'Vermont', code: 'VT' }, { name: 'Virginia', code: 'VA' }, { name: 'Washington', code: 'WA' }, + { name: 'West Virginia', code: 'WV' }, { name: 'Wisconsin', code: 'WI' }, { name: 'Wyoming', code: 'WY' }, +]; - // -------------- CIP HELPER FUNCTIONS -------------- +// -------------- CIP HELPER FUNCTIONS -------------- // 1) Insert leading zero if there's only 1 digit before the decimal function ensureTwoDigitsBeforeDecimal(cipStr) { @@ -55,6 +55,7 @@ function CareerExplorer() { const location = useLocation(); const apiUrl = process.env.REACT_APP_API_URL || ''; + // ---------- Component States ---------- const [userProfile, setUserProfile] = useState(null); const [masterCareerRatings, setMasterCareerRatings] = useState([]); const [careerList, setCareerList] = useState([]); @@ -65,8 +66,10 @@ function CareerExplorer() { const [userZipcode, setUserZipcode] = useState(null); const [error, setError] = useState(null); const [pendingCareerForModal, setPendingCareerForModal] = useState(null); + + // This is where we'll hold ALL final suggestions (with job_zone merged) const [careerSuggestions, setCareerSuggestions] = useState([]); - const [careersWithJobZone, setCareersWithJobZone] = useState([]); + const [salaryData, setSalaryData] = useState([]); const [economicProjections, setEconomicProjections] = useState(null); const [selectedJobZone, setSelectedJobZone] = useState(''); @@ -89,7 +92,143 @@ function CareerExplorer() { Good: 'Good - Less Strong Match', }; - // ===================== Load user profile ===================== + // -------------------------------------------------- + // fetchSuggestions - combined suggestions + job zone + // -------------------------------------------------- + const fetchSuggestions = async (answers, profileData) => { + if (!answers) { + setCareerSuggestions([]); + localStorage.removeItem('careerSuggestionsCache'); + + // Reset loading & progress if userProfile has no answers + setLoading(true); + setProgress(0); + setLoading(false); + return; + } + + try { + setLoading(true); + setProgress(0); + + // 1) O*NET answers -> initial career list + const submitRes = await axios.post(`${apiUrl}/onet/submit_answers`, { + answers, + state: profileData.state, + area: profileData.area, + }); + const { careers = [] } = submitRes.data || {}; + const flattened = careers.flat(); + + // We'll do an extra single call for job zones + 4 calls for each career: + // => total steps = 1 (jobZones) + (flattened.length * 4) + let totalSteps = 1 + (flattened.length * 4); + let completedSteps = 0; + + // Increments the global progress bar + const increment = () => { + completedSteps++; + const pct = Math.round((completedSteps / totalSteps) * 100); + setProgress(pct); + }; + + // A helper that does a GET request, increments progress on success/fail + const fetchWithProgress = async (url, params) => { + try { + const res = await axios.get(url, { params }); + increment(); + return res.data; + } catch (err) { + increment(); + return {}; // or null + } + }; + + // 2) job zones (one call for all SOC codes) + const socCodes = flattened.map((c) => c.code); + const zonesRes = await axios.post(`${apiUrl}/job-zones`, { socCodes }).catch(() => null); + // increment progress for this single request + increment(); + + const jobZoneData = zonesRes?.data || {}; + + // 3) For each career, also fetch CIP, job details, projections, salary + const enrichedPromiseArray = flattened.map(async (career) => { + const strippedSoc = career.code.split('.')[0]; + + // build URLs + const cipUrl = `${apiUrl}/cip/${career.code}`; + const jobDetailsUrl = `${apiUrl}/onet/career-description/${career.code}`; + const economicUrl = `${apiUrl}/projections/${strippedSoc}`; + const salaryParams = { socCode: strippedSoc, area: profileData.area }; + + // We'll fetch them in parallel with our custom fetchWithProgress: + const [cipRaw, jobRaw, ecoRaw, salRaw] = await Promise.all([ + fetchWithProgress(cipUrl), + fetchWithProgress(jobDetailsUrl), + fetchWithProgress(economicUrl), + fetchWithProgress(`${apiUrl}/salary`, salaryParams), + ]); + + // parse data + const cip = cipRaw || {}; + const jobDetails = jobRaw || {}; + const economic = ecoRaw || {}; + const salary = salRaw || {}; + + // Check if data is missing + const isCipMissing = !cip || Object.keys(cip).length === 0; + const isJobDetailsMissing = !jobDetails || Object.keys(jobDetails).length === 0; + const isEconomicMissing = + !economic || Object.values(economic).every((val) => val === 'N/A' || val === '*'); + const isSalaryMissing = !salary || Object.keys(salary).length === 0; + + const isLimitedData = + isCipMissing || isJobDetailsMissing || isEconomicMissing || isSalaryMissing; + + return { + ...career, + job_zone: jobZoneData[career.code.slice(0, -3)]?.job_zone || null, + limitedData: isLimitedData, + }; + }); + + // Wait for everything to finish + const finalEnrichedCareers = await Promise.all(enrichedPromiseArray); + + // Store final suggestions in local storage + localStorage.setItem('careerSuggestionsCache', JSON.stringify(finalEnrichedCareers)); + + // Update React state + setCareerSuggestions(finalEnrichedCareers); + + } catch (err) { + console.error('[fetchSuggestions] Error:', err); + setCareerSuggestions([]); + localStorage.removeItem('careerSuggestionsCache'); + } finally { + // Hide spinner + setLoading(false); + } +}; + + + // -------------------------------------- + // On mount, load suggestions from cache + // -------------------------------------- + useEffect(() => { + const cached = localStorage.getItem('careerSuggestionsCache'); + if (cached) { + const parsed = JSON.parse(cached); + if (parsed?.length) { + setCareerSuggestions(parsed); + } + } + }, []); + + // -------------------------------------- + // Load user profile + // -------------------------------------- useEffect(() => { setLoading(true); @@ -102,49 +241,21 @@ function CareerExplorer() { if (res.status === 200) { const profileData = res.data; - console.log('[fetchUserProfile] loaded profileData =>', profileData); - setUserProfile(profileData); setUserState(profileData.state); setAreaTitle(profileData.area); setUserZipcode(profileData.zipcode); - // If they have a saved career list if (profileData.career_list) { setCareerList(JSON.parse(profileData.career_list)); } - - // If they have interest inventory, fetch suggestions - if (profileData.interest_inventory_answers) { - const answers = profileData.interest_inventory_answers; - const careerSuggestionsRes = await axios.post(`${apiUrl}/onet/submit_answers`, { - answers, - state: profileData.state, - area: profileData.area, - }); - const { careers = [] } = careerSuggestionsRes.data || {}; - setCareerSuggestions(careers.flat()); - } else { - setCareerSuggestions([]); - } - - // Check if priorities answered - const priorities = profileData.career_priorities - ? JSON.parse(profileData.career_priorities) - : {}; - - const allAnswered = ['interests','meaning','stability','growth','balance','recognition'] - .every((key) => priorities[key]); - - if (!allAnswered) { - setShowModal(true); - } } else { setShowModal(true); } } catch (err) { console.error('Error fetching user profile:', err); setShowModal(true); + } finally { setLoading(false); } }; @@ -152,39 +263,23 @@ function CareerExplorer() { fetchUserProfile(); }, [apiUrl]); - // ===================== If location.state has careerSuggestions ===================== + // ------------------------------------------------------ + // If user came from Interest Inventory => auto-fetch + // ------------------------------------------------------ useEffect(() => { - if (location.state?.careerSuggestions) { - setCareerSuggestions(location.state.careerSuggestions); + if ( + location.state?.fromInterestInventory && + userProfile?.interest_inventory_answers + ) { + fetchSuggestions(userProfile.interest_inventory_answers, userProfile); + // remove that state so refresh doesn't re-fetch + navigate('.', { replace: true }); } - }, [location.state]); + }, [location.state, userProfile, fetchSuggestions, navigate]); - // ===================== Fetch job zones for suggestions ===================== - useEffect(() => { - const fetchJobZones = async () => { - if (!careerSuggestions.length) return; - - const flatSuggestions = careerSuggestions.flat(); - const socCodes = flatSuggestions.map((career) => career.code); - - try { - const response = await axios.post(`${apiUrl}/job-zones`, { socCodes }); - const jobZoneData = response.data; - const updatedCareers = flatSuggestions.map((career) => ({ - ...career, - job_zone: jobZoneData[career.code.slice(0, -3)]?.job_zone || null, - })); - - setCareersWithJobZone([...updatedCareers]); - } catch (error) { - console.error('Error fetching job zone information:', error); - } - }; - - fetchJobZones(); - }, [careerSuggestions, apiUrl]); - - // ===================== handleCareerClick (detail fetch) ===================== + // ------------------------------------------------------ + // handleCareerClick (detail fetch for CIP, Salary, etc.) + // ------------------------------------------------------ const handleCareerClick = useCallback( async (career) => { console.log('[handleCareerClick] career =>', career); @@ -195,8 +290,6 @@ function CareerExplorer() { setSalaryData([]); setEconomicProjections({}); - setSelectedCareer(career); - if (!socCode) { console.error('SOC Code is missing'); setError('SOC Code is missing'); @@ -211,7 +304,9 @@ function CareerExplorer() { setError( `We're sorry, but specific details for "${career.title}" are not available at this time.` ); - setCareerDetails({ error: `We're sorry, but detailed info for "${career.title}" isn't available right now.`}); + setCareerDetails({ + error: `We're sorry, but detailed info for "${career.title}" isn't available right now.`, + }); setLoading(false); return; } @@ -219,9 +314,13 @@ function CareerExplorer() { const cleanedCipCode = cipCode.replace('.', '').slice(0, 4); // Job details - const jobDetailsResponse = await fetch(`${apiUrl}/onet/career-description/${socCode}`); - if (!jobDetailsResponse.ok){ - setCareerDetails({ error: `We're sorry, but detailed info for "${career.title}" isn't available right now.`}); + const jobDetailsResponse = await fetch( + `${apiUrl}/onet/career-description/${socCode}` + ); + if (!jobDetailsResponse.ok) { + setCareerDetails({ + error: `We're sorry, but detailed info for "${career.title}" isn't available right now.`, + }); setLoading(false); return; } @@ -237,35 +336,64 @@ function CareerExplorer() { salaryResponse = { data: {} }; } - // Build salary array const sData = salaryResponse.data || {}; const salaryDataPoints = sData && Object.keys(sData).length > 0 ? [ { percentile: '10th Percentile', - regionalSalary: parseInt(sData.regional?.regional_PCT10, 10) || 0, - nationalSalary: parseInt(sData.national?.national_PCT10, 10) || 0, + regionalSalary: parseInt( + sData.regional?.regional_PCT10, + 10 + ) || 0, + nationalSalary: parseInt( + sData.national?.national_PCT10, + 10 + ) || 0, }, { percentile: '25th Percentile', - regionalSalary: parseInt(sData.regional?.regional_PCT25, 10) || 0, - nationalSalary: parseInt(sData.national?.national_PCT25, 10) || 0, + regionalSalary: parseInt( + sData.regional?.regional_PCT25, + 10 + ) || 0, + nationalSalary: parseInt( + sData.national?.national_PCT25, + 10 + ) || 0, }, { percentile: 'Median', - regionalSalary: parseInt(sData.regional?.regional_MEDIAN, 10) || 0, - nationalSalary: parseInt(sData.national?.national_MEDIAN, 10) || 0, + regionalSalary: parseInt( + sData.regional?.regional_MEDIAN, + 10 + ) || 0, + nationalSalary: parseInt( + sData.national?.national_MEDIAN, + 10 + ) || 0, }, { percentile: '75th Percentile', - regionalSalary: parseInt(sData.regional?.regional_PCT75, 10) || 0, - nationalSalary: parseInt(sData.national?.national_PCT75, 10) || 0, + regionalSalary: parseInt( + sData.regional?.regional_PCT75, + 10 + ) || 0, + nationalSalary: parseInt( + sData.national?.national_PCT75, + 10 + ) || 0, }, { percentile: '90th Percentile', - regionalSalary: parseInt(sData.regional?.regional_PCT90, 10) || 0, - nationalSalary: parseInt(sData.national?.national_PCT90, 10) || 0, + regionalSalary: parseInt( + sData.regional?.regional_PCT90, + 10 + ) || 0, + nationalSalary: parseInt( + sData.national?.national_PCT90, + 10 + ) || 0, }, ] : []; @@ -274,9 +402,12 @@ function CareerExplorer() { const fullStateName = getFullStateName(userState); let economicResponse = { data: {} }; try { - economicResponse = await axios.get(`${apiUrl}/projections/${socCode.split('.')[0]}`, { - params: { state: fullStateName }, - }); + economicResponse = await axios.get( + `${apiUrl}/projections/${socCode.split('.')[0]}`, + { + params: { state: fullStateName }, + } + ); } catch (error) { economicResponse = { data: {} }; } @@ -300,10 +431,12 @@ function CareerExplorer() { setLoading(false); } }, - [userState, apiUrl, areaTitle, userZipcode] + [userState, apiUrl, areaTitle] ); - // ===================== handleCareerFromSearch ===================== + // ------------------------------------------------------ + // handleCareerFromSearch + // ------------------------------------------------------ const handleCareerFromSearch = useCallback( (obj) => { const adapted = { @@ -317,6 +450,9 @@ function CareerExplorer() { [handleCareerClick] ); + // ------------------------------------------------------ + // pendingCareerForModal effect + // ------------------------------------------------------ useEffect(() => { if (pendingCareerForModal) { console.log('[useEffect] pendingCareerForModal =>', pendingCareerForModal); @@ -325,7 +461,9 @@ function CareerExplorer() { } }, [pendingCareerForModal, handleCareerFromSearch]); - // ===================== Load careers_with_ratings for CIP arrays ===================== + // ------------------------------------------------------ + // Load careers_with_ratings for CIP arrays + // ------------------------------------------------------ useEffect(() => { fetch('/careers_with_ratings.json') .then((res) => { @@ -336,17 +474,33 @@ function CareerExplorer() { .catch((err) => console.error('Error fetching career ratings:', err)); }, []); + // ------------------------------------------------------ + // Derived data / Helpers + // ------------------------------------------------------ const priorities = useMemo(() => { - return userProfile?.career_priorities ? JSON.parse(userProfile.career_priorities) : {}; + return userProfile?.career_priorities + ? JSON.parse(userProfile.career_priorities) + : {}; }, [userProfile]); - const priorityKeys = ['interests', 'meaning', 'stability', 'growth', 'balance', 'recognition']; + const priorityKeys = [ + 'interests', + 'meaning', + 'stability', + 'growth', + 'balance', + 'recognition', + ]; const getCareerRatingsBySocCode = (socCode) => { - return masterCareerRatings.find((c) => c.soc_code === socCode)?.ratings || {}; + return ( + masterCareerRatings.find((c) => c.soc_code === socCode)?.ratings || {} + ); }; - // ===================== Save comparison list to backend ===================== + // ------------------------------------------------------ + // Save comparison list to backend + // ------------------------------------------------------ const saveCareerListToBackend = async (newCareerList) => { try { const token = localStorage.getItem('token'); @@ -373,7 +527,9 @@ function CareerExplorer() { } }; - // ===================== Add/Remove from comparison ===================== + // ------------------------------------------------------ + // Add/Remove from comparison + // ------------------------------------------------------ const addCareerToList = (career) => { const masterRatings = getCareerRatingsBySocCode(career.code); @@ -389,7 +545,10 @@ function CareerExplorer() { : fitRatingMap[career.fit] || masterRatings.interests || 3; const meaningRating = parseInt( - prompt("How important do you feel this job is to society or the world? (1-5):", "3"), + prompt( + "How important do you feel this job is to society or the world? (1-5):", + "3" + ), 10 ); @@ -433,8 +592,10 @@ function CareerExplorer() { }); }; - // ===================== Let user pick a career from comparison => "Select for Education" ===================== - const handleSelectForEducation = (career) => { + // ------------------------------------------------------ + // "Select for Education" => navigate with CIP codes + // ------------------------------------------------------ + const handleSelectForEducation = (career) => { // 1) Confirm const confirmed = window.confirm( `Are you sure you want to move on to Educational Programs for ${career.title}?` @@ -450,8 +611,7 @@ function CareerExplorer() { // 3) Clean CIP codes const rawCips = matching.cip_codes || []; - const cleanedCips = cleanCipCodes(rawCips); // from top-level function - console.log('cleanedCips =>', cleanedCips); + const cleanedCips = cleanCipCodes(rawCips); // 4) Navigate navigate('/educational-programs', { @@ -465,22 +625,26 @@ function CareerExplorer() { }); }; - - // ===================== Filter logic for jobZone, Fit ===================== + // ------------------------------------------------------ + // Filter logic for jobZone, Fit + // ------------------------------------------------------ const filteredCareers = useMemo(() => { - return careersWithJobZone.filter((career) => { + return careerSuggestions.filter((career) => { + // If user selected a jobZone, check if career.job_zone matches const jobZoneMatches = selectedJobZone ? career.job_zone !== null && career.job_zone !== undefined && Number(career.job_zone) === Number(selectedJobZone) : true; + // If user selected a fit, check if career.fit matches const fitMatches = selectedFit ? career.fit === selectedFit : true; + return jobZoneMatches && fitMatches; }); - }, [careersWithJobZone, selectedJobZone, selectedFit]); + }, [careerSuggestions, selectedJobZone, selectedFit]); - // Weighted “match score” logic. (unchanged) + // Weighted "match score" logic. (unchanged) const priorityWeight = (priority, response) => { const weightMap = { interests: { @@ -516,6 +680,9 @@ function CareerExplorer() { return weightMap[priority][response] || 1; }; + // ------------------------------------------------------ + // Loading Overlay + // ------------------------------------------------------ const renderLoadingOverlay = () => { if (!loading) return null; return ( @@ -535,15 +702,20 @@ function CareerExplorer() { ); }; + // ------------------------------------------------------ + // Render + // ------------------------------------------------------ return (