dev1/src/components/EducationalProgramsPage.js

716 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useMemo, useState } 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';
// 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}`;
}
// Convert numeric importance (15) 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 (07) 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 } = useLocation();
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);
// If user picks a new career from CareerSearch
const handleCareerSelected = (foundObj) => {
setCareerTitle(foundObj.title || '');
setSelectedCareer(foundObj);
localStorage.setItem('selectedCareer', JSON.stringify(foundObj));
let rawCips = Array.isArray(foundObj.cip_code) ? foundObj.cip_code : [foundObj.cip_code];
const cleanedCips = rawCips.map((code) => {
const codeStr = code.toString();
return codeStr.replace('.', '').slice(0, 4);
});
setCipCodes(cleanedCips);
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)
const handleSelectSchool = (school) => {
const proceed = window.confirm(
'Youre about to move to the financial planning portion of the app, which is reserved for premium subscribers. Do you want to continue?'
);
if (proceed) {
const storedOnboarding = JSON.parse(localStorage.getItem('premiumOnboardingState') || '{}');
storedOnboarding.collegeData = storedOnboarding.collegeData || {};
storedOnboarding.collegeData.selectedSchool = school; // or any property name
localStorage.setItem('premiumOnboardingState', JSON.stringify(storedOnboarding));
navigate('/career-roadmap', { state: { selectedSchool: school } });
}
};
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(newCips);
if (newTitle) setCareerTitle(newTitle);
if (navCareer) setSelectedCareer(navCareer);
/* if *any* career info arrived we dont need the search box */
if (newSoc || navCareer) setShowSearch(false);
}, [location.state]);
// Load KSA data once
useEffect(() => {
async function loadKsaData() {
setLoadingKsa(true);
setKsaError(null);
try {
const resp = await fetch('/ksa_data.json');
if (!resp.ok) {
throw new Error('Failed to fetch ksa_data.json');
}
let data = await resp.json();
// skip possible header row
data = data.filter((item) => item.onetSocCode !== 'O*NET-SOC Code');
setAllKsaData(data);
} catch (err) {
console.error('Error loading ksa_data.json:', err);
setKsaError('Could not load KSA data');
} finally {
setLoadingKsa(false);
}
}
loadKsaData();
}, []);
// Filter: only IM >=3, then combine IM+LV
useEffect(() => {
if (!socCode) {
// no career => no KSA
setKsaForCareer([]);
return;
}
if (!allKsaData.length) {
return;
}
// Otherwise, we have local data loaded:
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);
combined = combined.filter((item) => {
return item.importanceValue !== null && item.importanceValue >= 3;
});
combined.sort((a, b) => (b.importanceValue || 0) - (a.importanceValue || 0));
if (combined.length === 0) {
// We found ZERO local KSA records for this socCode => fallback
fetchAiKsaFallback(socCode, careerTitle);
} else {
// We found local KSA data => just use it
setKsaForCareer(combined);
}
}, [socCode, allKsaData, careerTitle]);
// Load user profile
useEffect(() => {
async function loadUserProfile() {
try {
const token = localStorage.getItem('token');
if (!token) {
console.warn('No token found, cannot load user-profile.');
return;
}
const res = await fetch('/api/user-profile', {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error('Failed to fetch user profile');
const data = await res.json();
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)
let rawCips = parsed.cip_code || [];
if (!Array.isArray(rawCips)) rawCips = [rawCips].filter(Boolean);
const cleanedCips = rawCips.map((code) => code.toString().replace('.', '').slice(0, 4));
setCipCodes(cleanedCips);
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]);
// 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 (
<tr key={idx} className="border-b text-sm">
<td className="p-2 font-medium text-gray-800">
{elementName}{' '}
<span
title={definition}
className="top-0 left-0 -translate-y-3/4 translate-x-1/8 ml-.5 inline-flex h-2.5 w-2.5 items-center justify-center
rounded-full bg-blue-500 text-[0.6rem] font-bold text-white cursor-help"
>
i
</span>
</td>
<td className="p-2 text-center text-gray-800">{impStars}</td>
<td className="p-2 text-center text-gray-800">{lvlBars}</td>
{!isAbility && (
<td className="p-2">
{links?.map((link, i) => (
<div key={i}>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline"
>
{link.title}
</a>
</div>
))}
</td>
)}
</tr>
);
}
// Knowledge / Skills / Abilities in 3 columns
function renderKsaSection() {
if (loadingKsa) return <p>Loading KSA data...</p>;
if (ksaError) return <p className="text-red-600">{ksaError}</p>;
if (!socCode) return <p>Please select a career to see KSA data.</p>;
if (!ksaForCareer.length) {
return <p>No Knowledge, Skills, and Abilities data found for {careerTitle}</p>;
}
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 (
<div className="mb-6">
<h2 className="text-xl font-semibold mb-4">
Knowledge, Skills, and Abilities needed for:{' '}
{careerTitle || 'Unknown Career'}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Knowledge */}
<div>
<h3 className="font-semibold text-lg mb-2">Knowledge</h3>
{knowledge.length ? (
<table className="w-full border text-left">
<thead className="bg-gray-100 text-xs uppercase text-gray-600">
<tr>
<th className="p-2">Knowledge Domain</th>
<th className="p-2 text-center">Importance</th>
<th className="p-2 text-center">Level</th>
<th className="p-2">Courses</th>
</tr>
</thead>
<tbody>
{knowledge.map((k, idx) => renderKsaRow(k, idx, careerTitle))}
</tbody>
</table>
) : (
<p className="text-sm text-gray-600">No knowledge entries.</p>
)}
</div>
{/* Skills */}
<div>
<h3 className="font-semibold text-lg mb-2">Skills</h3>
{skillRows.length ? (
<table className="w-full border text-left">
<thead className="bg-gray-100 text-xs uppercase text-gray-600">
<tr>
<th className="p-2">Skill</th>
<th className="p-2 text-center">Importance</th>
<th className="p-2 text-center">Level</th>
<th className="p-2">Courses</th>
</tr>
</thead>
<tbody>
{skillRows.map((sk, idx) => renderKsaRow(sk, idx, careerTitle))}
</tbody>
</table>
) : (
<p className="text-sm text-gray-600">No skill entries.</p>
)}
</div>
{/* Abilities */}
<div>
<h3 className="font-semibold text-lg mb-2">Abilities</h3>
{abilities.length ? (
<table className="w-full border text-left">
<thead className="bg-gray-100 text-xs uppercase text-gray-600">
<tr>
<th className="p-2">Ability</th>
<th className="p-2 text-center">Importance</th>
<th className="p-2 text-center">Level</th>
<th className="p-2 text-center">
{/* Info icon for "Why no courses?" */}
<span className="relative inline-block">
<span>Why no courses?</span>
<span
className="top-0 left-0 -translate-y-3/4 translate-x-1/8 ml-.5 inline-flex h-2.5 w-2.5 items-center justify-center
rounded-full bg-blue-500 text-[10px] font-italics text-white cursor-help"
title="Abilities are more innate in nature, and difficult to offer courses for them."
>
i
</span>
</span>
</th>
</tr>
</thead>
<tbody>
{abilities.map((ab, idx) => renderKsaRow(ab, idx, careerTitle))}
</tbody>
</table>
) : (
<p className="text-sm text-gray-600">No ability entries.</p>
)}
</div>
</div>
</div>
);
}
// If no CIP codes => fallback
if (!cipCodes.length) {
return (
<div className="p-4">
<h2 className="text-2xl font-bold mb-4">Educational Programs</h2>
<p className="mb-4 text-gray-600">
You have not selected a career yet. Please search for one below:
</p>
<CareerSearch onCareerSelected={handleCareerSelected} />
<p className="mt-2 text-sm text-gray-500">
After you pick a career, well display matching educational programs.
</p>
</div>
);
}
if (loading) {
return <div className="p-4">Loading skills and educational programs</div>;
}
if (error) {
return (
<div className="p-4 text-red-600">
<p>Error: {error}</p>
</div>
);
}
async function fetchAiKsaFallback(socCode, careerTitle) {
// Optionally show a “loading” indicator
setLoadingKsa(true);
setKsaError(null);
try {
const token = localStorage.getItem('token');
if (!token) {
throw new Error('No auth token found; cannot fetch AI-based KSAs.');
}
// Call the new endpoint in server3.js
const resp = await fetch(
`/api/premium/ksa/${socCode}?careerTitle=${encodeURIComponent(careerTitle)}`,
{
headers: {
Authorization: `Bearer ${token}`
}
}
);
if (!resp.ok) {
throw new Error(`AI KSA endpoint returned status ${resp.status}`);
}
const json = await resp.json();
// Expect shape: { source: 'chatgpt' | 'db' | 'local', data: { knowledge, skills, abilities } }
// The arrays from server may already be in the “IM/LV” format
// so we can combine them into one array for display:
const finalKsa = [...json.data.knowledge, ...json.data.skills, ...json.data.abilities];
finalKsa.forEach(item => {
item.onetSocCode = socCode;
});
const combined = combineIMandLV(finalKsa);
setKsaForCareer(combined);
} catch (err) {
console.error('Error fetching AI-based KSAs:', err);
setKsaError('Could not load AI-based KSAs. Please try again later.');
setKsaForCareer([]);
} finally {
setLoadingKsa(false);
}
}
return (
<div className="p-4">
{/* 1. If user is allowed to search for a career again, show CareerSearch */}
{showSearch && (
<div className="mb-4">
<h2 className="text-xl font-semibold mb-2">Find a Career</h2>
<CareerSearch onCareerSelected={handleCareerSelected} />
</div>
)}
{/* 2. If the user has already selected a career and we're not showing search */}
{selectedCareer && !showSearch && (
<div className="mb-4">
<p className="text-gray-700">
<strong>Currently selected:</strong> {selectedCareer.title}
</p>
<button
onClick={handleChangeCareer}
className="mt-2 rounded bg-green-400 px-3 py-1 hover:bg-green-300"
>
Change Career
</button>
</div>
)}
{/* If were loading or errored out, handle that up front */}
{loading && (
<div>Loading skills and educational programs...</div>
)}
{error && (
<div className="text-red-600">
Error: {error}
</div>
)}
{/* 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 */}
<h2 className="text-xl font-semibold mb-4">
Schools for: {careerTitle || 'Unknown Career'}
</h2>
{/* Filter Bar */}
<div className="mb-4 flex flex-wrap items-center space-x-4">
<label className="text-sm text-gray-600">
Sort:
<select
className="ml-2 rounded border px-2 py-1 text-sm"
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
>
<option value="tuition">Tuition</option>
<option value="distance">Distance</option>
</select>
</label>
<label className="text-sm text-gray-600">
Tuition (max):
<input
type="number"
className="ml-2 w-20 rounded border px-2 py-1 text-sm"
value={maxTuition}
onChange={(e) => setMaxTuition(Number(e.target.value))}
/>
</label>
<label className="text-sm text-gray-600">
Distance (max):
<input
type="number"
className="ml-2 w-20 rounded border px-2 py-1 text-sm"
value={maxDistance}
onChange={(e) => setMaxDistance(Number(e.target.value))}
/>
</label>
{userState && (
<label className="inline-flex items-center space-x-2 text-sm text-gray-600">
<input
type="checkbox"
checked={inStateOnly}
onChange={(e) => setInStateOnly(e.target.checked)}
/>
<span>In-State Only</span>
</label>
)}
</div>
{/* Display the sorted/filtered schools */}
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(250px,1fr))]">
{filteredAndSortedSchools.map((school, idx) => {
const displayWebsite = ensureHttp(school['Website']);
return (
<div key={idx} className="rounded border p-3 text-sm">
<strong>
{school['Website'] ? (
<a
href={displayWebsite}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{school['INSTNM'] || 'Unnamed School'}
</a>
) : (
school['INSTNM'] || 'Unnamed School'
)}
</strong>
<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>
<p>Distance: {school.distance !== null ? `${school.distance} mi` : 'N/A'}</p>
<button
onClick={() => handleSelectSchool(school)}
className="mt-3 rounded bg-green-600 px-3 py-1 text-white hover:bg-blue-700"
>
Select School
</button>
</div>
);
})}
</div>
</>
) : (
/* 4. If no CIP codes, user hasn't picked a valid career or there's no data */
<div className="mt-4">
<p className="text-gray-600">
You have not selected a career (or no CIP codes found) so no programs can be shown.
</p>
</div>
)}
</div>
);
}
export default EducationalProgramsPage;