CareerExplorer build out

This commit is contained in:
Josh 2025-05-13 19:49:08 +00:00
parent 6388522f8a
commit 5b557c0a44
19 changed files with 82874 additions and 65 deletions

View File

@ -144,38 +144,7 @@ app.post('/api/register', async (req, res) => {
});
// Route for login
app.post('/api/login', (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
const query = 'SELECT * FROM user_auth WHERE username = ?';
db.get(query, [username], async (err, row) => {
if (err) {
console.error('Error fetching user:', err.message);
return res.status(500).json({ error: 'Internal server error' });
}
if (!row) {
return res.status(401).json({ error: 'Invalid username or password' });
}
// Verify password
const isPasswordValid = await bcrypt.compare(password, row.hashed_password);
if (!isPasswordValid) {
return res.status(401).json({ error: 'Invalid username or password' });
}
const { user_id } = row; // This gets the correct user_id from the row object
const token = jwt.sign({ userId: row.user_id }, SECRET_KEY, { expiresIn: '2h' });
res.status(200).json({ token });
});
});
// Route to handle user sign-in (customized)
// Route to handle user sign-in (updated with career_priorities and career_list)
app.post('/api/signin', async (req, res) => {
const { username, password } = req.body;
@ -183,7 +152,7 @@ app.post('/api/signin', async (req, res) => {
return res.status(400).json({ error: 'Both username and password are required' });
}
// JOIN user_profile to fetch is_premium, email, or whatever columns you need
// Updated query includes career_priorities and career_list
const query = `
SELECT
user_auth.user_id,
@ -194,7 +163,9 @@ app.post('/api/signin', async (req, res) => {
user_profile.career_situation,
user_profile.email,
user_profile.firstname,
user_profile.lastname
user_profile.lastname,
user_profile.career_priorities, -- new field
user_profile.career_list -- new field
FROM user_auth
LEFT JOIN user_profile ON user_auth.user_id = user_profile.user_id
WHERE user_auth.username = ?
@ -217,20 +188,16 @@ app.post('/api/signin', async (req, res) => {
return res.status(401).json({ error: 'Invalid username or password' });
}
// user_id from the row
const { user_id } = row;
// Generate JWT
const token = jwt.sign({ userId: user_id }, SECRET_KEY, { expiresIn: '2h' });
const token = jwt.sign({ userId: row.user_id }, SECRET_KEY, { expiresIn: '2h' });
// Return user object including is_premium and other columns
// The front end can store this in state (e.g. setUser).
// Return fully updated user object
res.status(200).json({
message: 'Login successful',
token,
userId: user_id,
userId: row.user_id,
user: {
user_id,
user_id: row.user_id,
firstname: row.firstname,
lastname: row.lastname,
email: row.email,
@ -238,11 +205,14 @@ app.post('/api/signin', async (req, res) => {
is_premium: row.is_premium,
is_pro_premium: row.is_pro_premium,
career_situation: row.career_situation,
career_priorities: row.career_priorities, // newly added
career_list: row.career_list, // newly added
}
});
});
});
/// Check if username already exists
app.get('/api/check-username/:username', (req, res) => {
const { username } = req.params;
@ -263,6 +233,129 @@ app.get('/api/check-username/:username', (req, res) => {
});
});
// Upsert user profile with career_priorities and career_list
app.post('/api/user-profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Authorization token is required' });
}
let userId;
try {
const decoded = jwt.verify(token, SECRET_KEY);
userId = decoded.userId;
} catch (error) {
console.error('JWT verification failed:', error);
return res.status(401).json({ error: 'Invalid or expired token' });
}
const {
firstName,
lastName,
email,
zipCode,
state,
area,
careerSituation,
interest_inventory_answers,
career_priorities,
career_list,
} = req.body;
if (!firstName || !lastName || !email || !zipCode || !state || !area) {
return res.status(400).json({ error: 'All fields are required (except interest_inventory_answers, career_priorities, career_list)' });
}
// Check for existing user profile
const checkQuery = `SELECT * FROM user_profile WHERE user_id = ?;`;
db.get(checkQuery, [userId], (err, existingRow) => {
if (err) {
console.error('Error checking profile:', err.message);
return res.status(500).json({ error: 'Database error' });
}
// Preserve existing data if not provided
const finalAnswers = interest_inventory_answers === undefined
? existingRow?.interest_inventory_answers || null
: interest_inventory_answers;
const finalCareerPriorities = career_priorities === undefined
? existingRow?.career_priorities || null
: career_priorities;
const finalCareerList = career_list === undefined
? existingRow?.career_list || null
: career_list;
if (existingRow) {
// Update existing profile
const updateQuery = `
UPDATE user_profile
SET firstname = ?,
lastname = ?,
email = ?,
zipcode = ?,
state = ?,
area = ?,
career_situation = ?,
interest_inventory_answers = ?,
career_priorities = ?,
career_list = ?
WHERE user_id = ?
`;
const params = [
firstName,
lastName,
email,
zipCode,
state,
area,
careerSituation || existingRow.career_situation,
finalAnswers,
finalCareerPriorities,
finalCareerList,
userId,
];
db.run(updateQuery, params, function (err) {
if (err) {
console.error('Error updating profile:', err.message);
return res.status(500).json({ error: 'Failed to update user profile' });
}
return res.status(200).json({ message: 'User profile updated successfully' });
});
} else {
// Insert new profile
const insertQuery = `
INSERT INTO user_profile
(firstname, lastname, email, zipcode, state, area, career_situation, interest_inventory_answers, career_priorities, career_list, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const params = [
firstName,
lastName,
email,
zipCode,
state,
area,
careerSituation || null,
finalAnswers,
finalCareerPriorities,
finalCareerList,
userId,
];
db.run(insertQuery, params, function (err) {
if (err) {
console.error('Error inserting profile:', err.message);
return res.status(500).json({ error: 'Failed to create user profile' });
}
return res
.status(201)
.json({ message: 'User profile created successfully', id: this.lastID });
});
}
});
});
// Route to fetch user profile

17379
final_master_career_list.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,8 @@ import GettingStarted from './components/GettingStarted.js';
import SignIn from './components/SignIn.js';
import SignUp from './components/SignUp.js';
import PlanningLanding from './components/PlanningLanding.js';
import CareerExplorer from './components/CareerExplorer.js';
import EducationalPrograms from './components/EducationalPrograms.js';
import PreparingLanding from './components/PreparingLanding.js';
import EnhancingLanding from './components/EnhancingLanding.js';
import RetirementLanding from './components/RetirementLanding.js';
@ -236,6 +238,8 @@ function App() {
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/profile" element={<UserProfile />} />
<Route path="/planning" element={<PlanningLanding />} />
<Route path="/career-explorer" element={<CareerExplorer />} />
<Route path="/educational-programs" element={<EducationalPrograms />} />
<Route path="/preparing" element={<PreparingLanding />} />
<Route path="/enhancing" element={<EnhancingLanding />} />
<Route path="/retirement" element={<RetirementLanding />} />

View File

@ -0,0 +1,367 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import CareerSuggestions from './CareerSuggestions.js';
import CareerPrioritiesModal from './CareerPrioritiesModal.js';
import CareerModal from './CareerModal.js';
import CareerSearch from './CareerSearch.js';
import axios from 'axios';
function CareerExplorer({ handleCareerClick, userState, areaTitle }) {
const location = useLocation();
const apiUrl = process.env.REACT_APP_API_URL || '';
const [userProfile, setUserProfile] = useState(null);
const [masterCareerRatings, setMasterCareerRatings] = useState([]);
const [careerList, setCareerList] = useState([]);
const [showModal, setShowModal] = useState(false);
const [careerSuggestions, setCareerSuggestions] = useState([]);
const [careersWithJobZone, setCareersWithJobZone] = useState([]);
const [selectedJobZone, setSelectedJobZone] = useState('');
const [selectedFit, setSelectedFit] = useState('');
const [selectedCareer, setSelectedCareer] = useState(null);
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(0);
const jobZoneLabels = {
'1': 'Little or No Preparation',
'2': 'Some Preparation Needed',
'3': 'Medium Preparation Needed',
'4': 'Considerable Preparation Needed',
'5': 'Extensive Preparation Needed',
};
const fitLabels = {
Best: 'Best - Very Strong Match',
Great: 'Great - Strong Match',
Good: 'Good - Less Strong Match',
};
useEffect(() => {
fetch('/careers_with_ratings.json')
.then((res) => {
if (!res.ok) throw new Error('Failed to fetch ratings JSON');
return res.json();
})
.then((data) => setMasterCareerRatings(data))
.catch((err) => console.error('Error fetching career ratings:', err));
}, []);
const priorities = useMemo(() => {
return userProfile?.career_priorities ? JSON.parse(userProfile.career_priorities) : {};
}, [userProfile]);
const priorityKeys = ['interests', 'meaning', 'stability', 'growth', 'balance', 'recognition'];
const getCareerRatingsBySocCode = (socCode) => {
return masterCareerRatings.find(c => c.soc_code === socCode)?.ratings || {};
};
const addCareerToList = (career) => {
const masterRatings = getCareerRatingsBySocCode(career.code);
const fitRatingMap = {
Best: 5,
Great: 4,
Good: 3,
};
const interestsRating = priorities.interests === 'Im not sure yet'
? parseInt(prompt('Rate your interest in this career (1-5):', '3'), 10)
: fitRatingMap[career.fit] || masterRatings.interests || 3;
const meaningRating = parseInt(prompt('How meaningful is this career to you? (1-5):', '3'), 10);
const stabilityRating = career.ratings && career.ratings.stability !== undefined
? career.ratings.stability
: masterRatings.stability || 3;
const growthRating = masterRatings.growth || 3;
const balanceRating = masterRatings.balance || 3;
const recognitionRating = masterRatings.recognition || 3;
const careerWithUserRatings = {
...career,
ratings: {
interests: interestsRating,
meaning: meaningRating,
stability: stabilityRating,
growth: growthRating,
balance: balanceRating,
recognition: recognitionRating,
},
};
setCareerList((prevList) => {
if (prevList.some((c) => c.code === career.code)) {
alert('Career already in comparison list.');
return prevList;
}
return [...prevList, careerWithUserRatings];
});
};
const removeCareerFromList = (careerCode) => {
setCareerList((prevList) => prevList.filter((c) => c.code !== careerCode));
};
useEffect(() => {
const fetchUserProfile = async () => {
try {
const token = localStorage.getItem('token');
const res = await axios.get(`${apiUrl}/user-profile`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 200) {
const profileData = res.data;
setUserProfile(profileData);
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); // profile not found, show modal by default
}
} catch (err) {
console.error('Error fetching user profile:', err);
setShowModal(true); // on error, default to showing modal
}
};
fetchUserProfile();
}, [apiUrl]);
// Load suggestions from Interest Inventory if provided (optional)
useEffect(() => {
if (location.state?.careerSuggestions) {
setCareerSuggestions(location.state.careerSuggestions);
}
}, [location.state]);
// Fetch Job Zones if suggestions are provided
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]);
// Filtering logic (Job Zone and Fit)
const filteredCareers = useMemo(() => {
return careersWithJobZone.filter((career) => {
const jobZoneMatches = selectedJobZone
? career.job_zone !== null &&
career.job_zone !== undefined &&
Number(career.job_zone) === Number(selectedJobZone)
: true;
const fitMatches = selectedFit ? career.fit === selectedFit : true;
return jobZoneMatches && fitMatches;
});
}, [careersWithJobZone, selectedJobZone, selectedFit]);
const priorityWeight = (priority, response) => {
const weightMap = {
interests: {
'I know my interests (completed inventory)': 5,
'Im not sure yet': 1,
},
meaning: {
'Yes, very important': 5,
'Somewhat important': 3,
'Not as important': 1,
},
stability: {
'Very important': 5,
'Somewhat important': 3,
'Not as important': 1,
},
growth: {
'Yes, very important': 5,
'Somewhat important': 3,
'Not as important': 1,
},
balance: {
'Yes, very important': 5,
'Somewhat important': 3,
'Not as important': 1,
},
recognition: {
'Very important': 5,
'Somewhat important': 3,
'Not as important': 1,
},
};
return weightMap[priority][response] || 1;
};
return (
<div className="career-explorer-container bg-white p-6 rounded shadow">
{showModal && (
<CareerPrioritiesModal
userProfile={userProfile}
onClose={() => setShowModal(false)}
/>
)}
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">Explore Careers</h2>
<CareerSearch
onCareerSelected={(careerObj) => handleCareerClick(careerObj)}
/>
</div>
<h2 className="text-xl font-semibold mb-4">Career Comparison Matrix</h2>
{careerList.length ? (
<table className="w-full mb-4">
<thead>
<tr>
<th className="border p-2">Career</th>
{priorityKeys.map((priority) => (
<th key={priority} className="border p-2 capitalize">{priority}</th>
))}
<th className="border p-2">Match</th>
<th className="border p-2">Actions</th>
</tr>
</thead>
<tbody>
{careerList.map((career) => {
const ratings = career.ratings || {};
const interestsRating = ratings.interests || 3; // default to 3 if not rated via InterestInventory
const meaningRating = ratings.meaning || 3;
const stabilityRating = ratings.stability || 3;
const growthRating = ratings.growth || 3;
const balanceRating = ratings.balance || 3;
const recognitionRating = ratings.recognition || 3;
const userInterestsWeight = priorityWeight('interests', priorities.interests || 'Im not sure yet');
const userMeaningWeight = priorityWeight('meaning', priorities.meaning);
const userStabilityWeight = priorityWeight('stability', priorities.stability);
const userGrowthWeight = priorityWeight('growth', priorities.growth);
const userBalanceWeight = priorityWeight('balance', priorities.balance);
const userRecognitionWeight = priorityWeight('recognition', priorities.recognition);
const totalWeight =
userInterestsWeight +
userMeaningWeight +
userStabilityWeight +
userGrowthWeight +
userBalanceWeight +
userRecognitionWeight;
const weightedScore =
interestsRating * userInterestsWeight +
meaningRating * userMeaningWeight +
stabilityRating * userStabilityWeight +
growthRating * userGrowthWeight +
balanceRating * userBalanceWeight +
recognitionRating * userRecognitionWeight;
const matchScore = (weightedScore / (totalWeight * 5)) * 100;
return (
<tr key={career.code}>
<td className="border p-2">{career.title}</td>
<td className="border p-2">{interestsRating}</td>
<td className="border p-2">{meaningRating}</td>
<td className="border p-2">{stabilityRating}</td>
<td className="border p-2">{growthRating}</td>
<td className="border p-2">{balanceRating}</td>
<td className="border p-2">{recognitionRating}</td>
<td className="border p-2 font-bold">{matchScore.toFixed(1)}%</td>
<td className="border p-2">
<button className="text-red-500" onClick={() => removeCareerFromList(career.code)}>Remove</button>
</td>
</tr>
);
})}
</tbody>
</table>
) : <p>No careers added to comparison.</p>}
<div className="flex gap-4 mb-4">
<select
className="border px-3 py-1 rounded"
value={selectedJobZone}
onChange={(e) => setSelectedJobZone(e.target.value)}
>
<option value="">All Preparation Levels</option>
{Object.entries(jobZoneLabels).map(([zone, label]) => (
<option key={zone} value={zone}>{label}</option>
))}
</select>
<select
className="border px-3 py-1 rounded"
value={selectedFit}
onChange={(e) => setSelectedFit(e.target.value)}
>
<option value="">All Fit Levels</option>
{Object.entries(fitLabels).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
<CareerSuggestions
careerSuggestions={filteredCareers}
onCareerClick={(career) => {
setSelectedCareer(career);
handleCareerClick(career);
}}
setLoading={setLoading}
setProgress={setProgress}
userState={userState}
areaTitle={areaTitle}
/>
{selectedCareer && (
<CareerModal
career={selectedCareer}
closeModal={() => setSelectedCareer(null)}
userState={userState}
areaTitle={areaTitle}
userZipcode={userProfile?.zipcode}
addCareerToList={addCareerToList} // <-- explicitly added here
/>
)}
<div className="mt-6 text-xs text-gray-500 border-t pt-2">
Career results and details provided by
<a href="https://www.onetcenter.org" target="_blank" rel="noopener noreferrer"> O*Net</a>,
in partnership with
<a href="https://www.bls.gov" target="_blank" rel="noopener noreferrer"> Bureau of Labor Statistics</a>
and
<a href="https://nces.ed.gov" target="_blank" rel="noopener noreferrer"> NCES</a>.
</div>
</div>
);
}
export default CareerExplorer;

View File

@ -0,0 +1,219 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { getFullStateName } from '../utils/stateUtils.js';
import { fetchSchools, clientGeocodeZip, haversineDistance} from '../utils/apiUtils.js';
const apiUrl = process.env.REACT_APP_API_URL;
function CareerModal({ career, userState, areaTitle, userZipcode, closeModal, addCareerToList }) {
const [careerDetails, setCareerDetails] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const handleCareerClick = async () => {
const socCode = career.code;
setLoading(true);
setError(null);
try {
const cipResponse = await fetch(`${apiUrl}/cip/${socCode}`);
if (!cipResponse.ok) throw new Error('Failed to fetch CIP Code');
const { cipCode } = await cipResponse.json();
const cleanedCipCode = cipCode.replace('.', '').slice(0, 4);
const jobDetailsResponse = await fetch(`${apiUrl}/onet/career-description/${socCode}`);
if (!jobDetailsResponse.ok) throw new Error('Failed to fetch job description');
const { description, tasks } = await jobDetailsResponse.json();
const salaryResponse = await axios.get(`${apiUrl}/salary`, {
params: { socCode: socCode.split('.')[0], area: areaTitle },
}).catch(() => ({ data: {} }));
const economicResponse = await axios.get(`${apiUrl}/projections/${socCode.split('.')[0]}`, {
params: { state: getFullStateName(userState) },
}).catch(() => ({ data: {} }));
const filteredSchools = await fetchSchools(cleanedCipCode, userState);
let userLat = null, userLng = null;
if (userZipcode) {
try {
const geocodeResult = await clientGeocodeZip(userZipcode);
userLat = geocodeResult.lat;
userLng = geocodeResult.lng;
} catch (err) {
console.warn('Unable to geocode user ZIP.');
}
}
const schoolsWithDistance = filteredSchools.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)} mi` };
}
return { ...sch, distance: 'N/A' };
});
const sData = salaryResponse.data || {};
const salaryDataPoints = sData && Object.keys(sData).length > 0 ? [
{ percentile: '10th', regionalSalary: sData.regional?.regional_PCT10 || 0, nationalSalary: sData.national?.national_PCT10 || 0 },
{ percentile: '25th', regionalSalary: sData.regional?.regional_PCT25 || 0, nationalSalary: sData.national?.national_PCT25 || 0 },
{ percentile: 'Median', regionalSalary: sData.regional?.regional_MEDIAN || 0, nationalSalary: sData.national?.national_MEDIAN || 0 },
{ percentile: '75th', regionalSalary: sData.regional?.regional_PCT75 || 0, nationalSalary: sData.national?.national_PCT75 || 0 },
{ percentile: '90th', regionalSalary: sData.regional?.regional_PCT90 || 0, nationalSalary: sData.national?.national_PCT90 || 0 },
] : [];
setCareerDetails({
...career,
jobDescription: description,
tasks,
economicProjections: economicResponse.data || {},
salaryData: salaryDataPoints,
schools: schoolsWithDistance,
});
} catch (error) {
console.error(error);
setError('Failed to load career details.');
} finally {
setLoading(false);
}
};
handleCareerClick();
}, [career, userState, areaTitle, userZipcode]);
if (loading) return <div>Loading...</div>;
if (error) return <div>{error}</div>;
const calculateStabilityRating = (salaryData) => {
const medianSalaryObj = salaryData.find(s => s.percentile === 'Median');
const medianSalary = medianSalaryObj?.regionalSalary || medianSalaryObj?.nationalSalary || 0;
if (medianSalary >= 90000) return 5;
if (medianSalary >= 70000) return 4;
if (medianSalary >= 50000) return 3;
if (medianSalary >= 30000) return 2;
return 1;
};
return (
<div className="fixed inset-0 bg-gray-900 bg-opacity-70 flex justify-center items-center overflow-auto z-50">
<div className="bg-white rounded-lg shadow-lg w-full max-w-5xl p-6 m-4 max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4 pb-2 border-b">
<h2 className="text-2xl font-bold text-blue-600">{careerDetails.title}</h2>
<div className="flex gap-2">
<button
onClick={() => {
const stabilityRating = calculateStabilityRating(careerDetails.salaryData);
addCareerToList({
...careerDetails,
ratings: {
stability: stabilityRating
}
});
}}
className="text-white bg-green-500 hover:bg-green-600 rounded px-3 py-1"
>
Add to Comparison
</button>
<button
onClick={closeModal}
className="text-white bg-red-500 hover:bg-red-600 rounded px-3 py-1"
>
Close
</button>
</div>
</div>
{/* Job Description */}
<div className="mb-4">
<h3 className="text-lg font-semibold mb-1">Job Description:</h3>
<p className="text-gray-700">{careerDetails.jobDescription}</p>
</div>
{/* Tasks (full width) */}
<div className="mb-4 border-t pt-3">
<h3 className="text-lg font-semibold mb-2">Tasks:</h3>
<ul className="list-disc pl-5 space-y-1">
{careerDetails.tasks.map((task, i) => (
<li key={i}>{task}</li>
))}
</ul>
</div>
{/* Salary and Economic Projections side-by-side */}
<div className="flex flex-col md:flex-row gap-4 border-t pt-3">
{/* Salary Data */}
<div className="md:w-1/2 overflow-x-auto">
<h3 className="text-lg font-semibold mb-2">Salary Data:</h3>
<table className="w-full text-left border border-gray-300 rounded">
<thead className="bg-gray-100">
<tr>
<th className="px-3 py-2 border-b">Percentile</th>
<th className="px-3 py-2 border-b">Regional Salary</th>
<th className="px-3 py-2 border-b">National Salary</th>
</tr>
</thead>
<tbody>
{careerDetails.salaryData.map((s, i) => (
<tr key={i}>
<td className="px-3 py-2 border-b">{s.percentile}</td>
<td className="px-3 py-2 border-b">${s.regionalSalary.toLocaleString()}</td>
<td className="px-3 py-2 border-b">${s.nationalSalary.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Economic Projections */}
<div className="md:w-1/2 overflow-x-auto">
<h3 className="text-lg font-semibold mb-2">Economic Projections:</h3>
<table className="w-full text-left border border-gray-300 rounded">
<thead className="bg-gray-100">
<tr>
<th className="px-3 py-2 border-b">Region</th>
<th className="px-3 py-2 border-b">Current Jobs</th>
<th className="px-3 py-2 border-b">Jobs in 10 yrs</th>
<th className="px-3 py-2 border-b">Growth %</th>
<th className="px-3 py-2 border-b">Annual Openings</th>
</tr>
</thead>
<tbody>
{careerDetails.economicProjections.state && (
<tr>
<td className="px-3 py-2 border-b">{careerDetails.economicProjections.state.area}</td>
<td className="px-3 py-2 border-b">{careerDetails.economicProjections.state.base.toLocaleString()}</td>
<td className="px-3 py-2 border-b">{careerDetails.economicProjections.state.projection.toLocaleString()}</td>
<td className="px-3 py-2 border-b">{careerDetails.economicProjections.state.percentChange}%</td>
<td className="px-3 py-2 border-b">{careerDetails.economicProjections.state.annualOpenings.toLocaleString()}</td>
</tr>
)}
{careerDetails.economicProjections.national && (
<tr>
<td className="px-3 py-2 border-b">National</td>
<td className="px-3 py-2 border-b">{careerDetails.economicProjections.national.base.toLocaleString()}</td>
<td className="px-3 py-2 border-b">{careerDetails.economicProjections.national.projection.toLocaleString()}</td>
<td className="px-3 py-2 border-b">{careerDetails.economicProjections.national.percentChange}%</td>
<td className="px-3 py-2 border-b">{careerDetails.economicProjections.national.annualOpenings.toLocaleString()}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}
export default CareerModal;

View File

@ -0,0 +1,113 @@
import React, { useState, useEffect } from 'react';
import authFetch from '../utils/authFetch.js';
const CareerPrioritiesModal = ({ userProfile, onClose }) => {
const [responses, setResponses] = useState({});
useEffect(() => {
if (userProfile?.career_priorities) {
setResponses(JSON.parse(userProfile.career_priorities));
}
}, [userProfile]);
const questions = [
{
id: 'interests',
text: 'What kinds of activities do you naturally enjoy?',
options: ['I know my interests (completed inventory)', 'Im not sure yet'],
},
{
id: 'meaning',
text: 'Is it important your job helps others or makes a difference?',
options: ['Yes, very important', 'Somewhat important', 'Not as important'],
},
{
id: 'stability',
text: 'How important is it that your career pays well?',
options: ['Very important', 'Somewhat important', 'Not as important'],
},
{
id: 'growth',
text: 'Do you want clear chances to advance and grow professionally?',
options: ['Yes, very important', 'Somewhat important', 'Not as important'],
},
{
id: 'balance',
text: 'Do you prefer a job with flexible hours and time outside work?',
options: ['Yes, very important', 'Somewhat important', 'Not as important'],
},
{
id: 'recognition',
text: 'How important is it to have a career that others admire?',
options: ['Very important', 'Somewhat important', 'Not as important'],
},
];
const handleSave = async () => {
const payload = {
firstName: userProfile.firstname,
lastName: userProfile.lastname,
email: userProfile.email,
zipCode: userProfile.zipcode,
state: userProfile.state,
area: userProfile.area,
careerSituation: userProfile.career_situation || null,
career_priorities: JSON.stringify(responses),
};
try {
await authFetch('/api/user-profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
onClose();
} catch (error) {
console.error('Error saving priorities:', error);
}
};
const allAnswered = questions.every(q => responses[q.id]);
return (
<div className="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center">
<div className="bg-white p-6 rounded-lg shadow-lg w-full max-w-2xl overflow-y-auto max-h-[90vh]">
<h2 className="text-xl font-bold mb-4">Tell us what's important to you</h2>
{questions.map(q => (
<div key={q.id} className="mb-4">
<label className="block mb-2 font-medium">{q.text}</label>
<select
value={responses[q.id] || ''}
onChange={(e) =>
setResponses({ ...responses, [q.id]: e.target.value })
}
className="w-full border px-3 py-2 rounded"
>
<option value="" disabled>
Select an answer
</option>
{q.options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
))}
<div className="flex justify-end space-x-2">
<button
onClick={handleSave}
disabled={!allAnswered}
className={`px-4 py-2 rounded ${allAnswered ? 'bg-blue-600 text-white' : 'bg-gray-300 cursor-not-allowed'}`}
>
Save Answers
</button>
</div>
</div>
</div>
);
};
export default CareerPrioritiesModal;

View File

@ -20,7 +20,7 @@ export function CareerSuggestions({
setLoading(false);
return;
}
const token = localStorage.getItem('token');
const checkCareerDataAvailability = async () => {

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { fetchSchools } from '../utils/apiUtils.ja';
import { fetchSchools } from '../utils/apiUtils.js';
function EducationalPrograms({ cipCode, userState }) {
const [schools, setSchools] = useState([]);

View File

@ -156,7 +156,7 @@ const InterestInventory = () => {
const { careers: careerSuggestions, riaSecScores } = data;
if (Array.isArray(careerSuggestions) && Array.isArray(riaSecScores)) {
navigate('/dashboard', { state: { careerSuggestions, riaSecScores } });
navigate('/career-explorer', { state: { careerSuggestions, riaSecScores } });
} else {
throw new Error('Invalid data format from the server.');
}

View File

@ -13,31 +13,39 @@ function PlanningLanding() {
</h1>
<p className="text-gray-600 mb-6 text-center">
Discover career options that match your interests, skills, and potential.
AptivaAI helps you find your ideal career path, provides insights into the educational requirements,
AptivaAI helps you find your ideal career path, provides insights into educational requirements,
expected salaries, job market trends, and more.
</p>
<div className="grid grid-cols-1 gap-4">
<Button
className="w-full"
onClick={() => navigate('/interest-inventory')}
>
Take Interest Inventory
</Button>
<div className="grid grid-cols-1 gap-6">
<Button
className="w-full"
onClick={() => navigate('/career-explorer')}
>
Explore Career Paths
</Button>
<div>
<Button className="w-full" onClick={() => navigate('/interest-inventory')}>
Take Interest Inventory
</Button>
<p className="mt-2 text-sm text-gray-500">
Identify your interests and discover careers aligned with your strengths.
</p>
</div>
<div>
<Button className="w-full" onClick={() => navigate('/career-explorer')}>
Explore Career Paths
</Button>
<p className="mt-2 text-sm text-gray-500">
Research detailed career profiles, job descriptions, salaries, and employment outlooks.
</p>
</div>
<div>
<Button className="w-full" onClick={() => navigate('/educational-programs')}>
Discover Educational Programs
</Button>
<p className="mt-2 text-sm text-gray-500">
Find the right educational programs, degrees, and certifications needed to pursue your chosen career.
</p>
</div>
<Button
className="w-full"
onClick={() => navigate('/educational-programs')}
>
Discover Educational Programs
</Button>
</div>
</div>
</div>

View File

@ -0,0 +1,75 @@
import axios from 'axios';
import { promises as fs } from 'fs';
const careersFile = '../../updated_career_data_final.json';
const outputFile = '../../careers_with_ratings.json';
const onetUsername = 'aptivaai';
const onetPassword = '2296ahq';
// Sleep function for delay between calls
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// Helper function to convert O*Net numeric scores (0-100) to a 1-5 scale
const mapScoreToScale = (score) => {
if(score >= 80) return 5;
if(score >= 60) return 4;
if(score >= 40) return 3;
if(score >= 20) return 2;
return 1;
};
// Fully corrected function to fetch ratings from O*Net API
const fetchCareerRatingsCorrected = async (socCode) => {
try {
const response = await axios.get(
`https://services.onetcenter.org/ws/online/occupations/${socCode}/details`,
{ auth: { username: onetUsername, password: onetPassword } }
);
const data = response.data;
// Correctly parse the work_values array from O*Net's structure
const workValues = Array.isArray(data.work_values?.element) ? data.work_values.element : [];
// Correctly parse the bright_outlook boolean from O*Net's structure
const brightOutlook = data.occupation.tags.bright_outlook === true;
// Extract numerical scores for balance and recognition
const balanceValue = workValues.find(v => v.name === 'Working Conditions')?.score.value || 0;
const recognitionValue = workValues.find(v => v.name === 'Recognition')?.score.value || 0;
// Return mapped ratings accurately reflecting O*Net data
return {
growth: brightOutlook ? 5 : 1,
balance: mapScoreToScale(balanceValue),
recognition: mapScoreToScale(recognitionValue)
};
} catch (error) {
console.error(`Error fetching details for ${socCode}:`, error.message);
return {
growth: 1,
balance: 1,
recognition: 1
};
}
};
// Main function to populate ratings for all careers
const populateRatingsCorrected = async () => {
const careersData = JSON.parse(await fs.readFile(careersFile, 'utf8'));
for (let career of careersData) {
const ratings = await fetchCareerRatingsCorrected(career.soc_code);
if (ratings) {
career.ratings = ratings;
console.log(`Fetched ratings for: ${career.title}`);
}
// Wait for .5 seconds between API calls to avoid hitting API limits
await sleep(500);
}
await fs.writeFile(outputFile, JSON.stringify(careersData, null, 2));
console.log(`Ratings populated to: ${outputFile}`);
};
// Call the corrected function to populate ratings
populateRatingsCorrected();

View File

@ -17,6 +17,35 @@ export const fetchAreasByState = async (state) => {
}
};
export async function clientGeocodeZip(zip) {
const apiKey = process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(zip)}&key=${apiKey}`;
const resp = await axios.get(url);
if (resp.data.status === 'OK' && resp.data.results && resp.data.results.length > 0) {
return resp.data.results[0].geometry.location; // { lat, lng }
}
throw new Error('Geocoding failed.');
}
// utils/apiUtils.js
export function haversineDistance(lat1, lon1, lat2, lon2) {
const R = 3959; // radius of earth in miles
const toRad = (val) => (val * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) *
Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
// Fetch schools
export const fetchSchools = async (cipCode, state = '', level = '', type = '') => {

View File

@ -0,0 +1,158 @@
// ============= handleCareerClick =============
const handleCareerClick = useCallback(
async (career) => {
console.log('[handleCareerClick] career =>', career);
const socCode = career.code;
console.log('[handleCareerClick] career.code =>', socCode);
setSelectedCareer(career);
setLoading(true);
setError(null);
setCareerDetails({});
setSchools([]);
setSalaryData([]);
setEconomicProjections({});
setTuitionData([]);
if (!socCode) {
console.error('SOC Code is missing');
setError('SOC Code is missing');
return;
}
try {
// CIP fetch
const cipResponse = await fetch(`${apiUrl}/cip/${socCode}`);
if (!cipResponse.ok) throw new Error('Failed to fetch CIP Code');
const { cipCode } = await cipResponse.json();
const cleanedCipCode = cipCode.replace('.', '').slice(0, 4);
// Job details
const jobDetailsResponse = await fetch(`${apiUrl}/onet/career-description/${socCode}`);
if (!jobDetailsResponse.ok) throw new Error('Failed to fetch job description');
const { description, tasks } = await jobDetailsResponse.json();
// Salary
let salaryResponse;
try {
salaryResponse = await axios.get(`${apiUrl}/salary`, {
params: { socCode: socCode.split('.')[0], area: areaTitle },
});
} catch (error) {
salaryResponse = { data: {} };
}
const fullName = getFullStateName(userState);
// Economic
let economicResponse;
try {
economicResponse = await axios.get(`${apiUrl}/projections/${socCode.split('.')[0]}`, {
params: { state: fullName }, // e.g. "Kentucky"
});
} catch (error) {
economicResponse = { data: {} };
}
// Tuition
let tuitionResponse;
try {
tuitionResponse = await axios.get(`${apiUrl}/tuition`, {
params: { cipCode: cleanedCipCode, state: userState },
});
} catch (error) {
tuitionResponse = { data: {} };
}
// ** FETCH SCHOOLS NORMALLY **
const filteredSchools = await fetchSchools(cleanedCipCode, userState);
// ** 1) Geocode user zip once on the client **
let userLat = null;
let userLng = null;
if (userZipcode) {
try {
const geocodeResult = await clientGeocodeZip(userZipcode);
userLat = geocodeResult.lat;
userLng = geocodeResult.lng;
} catch (err) {
console.warn('Unable to geocode user ZIP, distances will be N/A.');
}
}
// ** 2) Compute Haversine distance locally for each school **
const schoolsWithDistance = filteredSchools.map((sch) => {
// only if we have lat/lon for both user + school
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) + ' mi',
duration: 'N/A',
};
} else {
return {
...sch,
distance: 'N/A',
duration: 'N/A',
};
}
});
// 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,
},
{
percentile: '25th Percentile',
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,
},
{
percentile: '75th Percentile',
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,
},
]
: [];
// Build final details
const updatedCareerDetails = {
...career,
jobDescription: description,
tasks: tasks,
economicProjections: economicResponse.data || {},
salaryData: salaryDataPoints,
schools: schoolsWithDistance,
tuitionData: tuitionResponse.data || [],
};
setCareerDetails(updatedCareerDetails);
updateChatbotContext({ careerDetails: updatedCareerDetails });
} catch (error) {
console.error('Error processing career click:', error.message);
setError('Failed to load data');
} finally {
setLoading(false);
}
},
[userState, apiUrl, areaTitle, userZipcode, updateChatbotContext]
);

35
src/utils/stateUtils.js Normal file
View File

@ -0,0 +1,35 @@
// src/utils/stateUtils.js
export 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' },
];
export function getFullStateName(code) {
const found = STATES.find((s) => s.code === code?.toUpperCase());
return found ? found.name : '';
}

View File

@ -0,0 +1,71 @@
import axios from 'axios';
import { promises as fs } from 'fs';
const testOutputFile = '../../test_careers_with_ratings.json';
const onetUsername = 'aptivaai';
const onetPassword = '2296ahq';
const testSocCodes = [
'19-4071.00',
'15-1241.00',
];
const mapScoreToScale = (score) => {
if(score >= 80) return 5;
if(score >= 60) return 4;
if(score >= 40) return 3;
if(score >= 20) return 2;
return 1;
};
const fetchCareerRatingsTest = async (socCode) => {
try {
const response = await axios.get(
`https://services.onetcenter.org/ws/online/occupations/${socCode}/details`,
{ auth: { username: onetUsername, password: onetPassword } }
);
const data = response.data;
const workValues = Array.isArray(data.work_values?.element) ? data.work_values.element : [];
const brightOutlook = data.occupation.tags.bright_outlook === true;
const balanceValue = workValues.find(v => v.name === 'Working Conditions')?.score.value || 0;
const recognitionValue = workValues.find(v => v.name === 'Recognition')?.score.value || 0;
return {
soc_code: socCode,
title: data.occupation.title,
ratings: {
growth: brightOutlook ? 5 : 1,
balance: mapScoreToScale(balanceValue),
recognition: mapScoreToScale(recognitionValue)
}
};
} catch (error) {
console.error(`Error fetching details for ${socCode}:`, error.message);
return {
soc_code,
title: "Fetch Error",
ratings: { growth: 1, balance: 1, recognition: 1 }
};
}
};
const runQuickTest = async () => {
const results = [];
for (const socCode of testSocCodes) {
const careerData = await fetchCareerRatingsTest(socCode);
if (careerData) {
results.push(careerData);
console.log(`Fetched ratings for: ${careerData.title}`);
}
}
await fs.writeFile(testOutputFile, JSON.stringify(results, null, 2));
console.log(`Test ratings populated to: ${testOutputFile}`);
};
runQuickTest();

View File

@ -0,0 +1,20 @@
[
{
"soc_code": "19-4071.00",
"title": "Forest and Conservation Technicians",
"ratings": {
"growth": 1,
"balance": 3,
"recognition": 2
}
},
{
"soc_code": "15-1241.00",
"title": "Computer Network Architects",
"ratings": {
"growth": 5,
"balance": 4,
"recognition": 3
}
}
]

32531
updated_career_data_final.json Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.