Fixed issues with LoanRepayment

This commit is contained in:
Josh 2025-06-30 18:30:37 +00:00
parent 7b810ff2de
commit 4ca8d11156
3 changed files with 220 additions and 89 deletions

View File

@ -55,10 +55,23 @@ function LoanRepayment({
if (!validateInputs()) return;
setLoading?.(true);
const pickTuition = (school, resid, grad) => {
const tryNum = v => isNaN(v) ? 0 : Number(v);
if (grad) {
return resid === 'inState'
? tryNum(school.inStateGraduate) || tryNum(school.inState) || tryNum(school.tuition)
: tryNum(school.outStateGraduate) || tryNum(school.outOfState)|| tryNum(school.tuition);
}
/* under-grad */
return resid === 'inState'
? tryNum(school.inState) || tryNum(school.inStateGraduate) || tryNum(school.tuition)
: tryNum(school.outOfState) || tryNum(school.outStateGraduate) || tryNum(school.tuition);
};
const results = schools.map((school) => {
/* your existing repayment logic — unchanged */
const programLen = Number(school.programLength);
const tuition = tuitionType === 'inState' ? school.inState : school.outOfState;
let ugYears=0, gradYears=0;
if (school.degreeType.includes('Associate')) ugYears=2;
@ -69,11 +82,11 @@ function LoanRepayment({
else if (school.degreeType.includes('Certificate')) ugYears=1;
else { ugYears=Math.min(programLen,4); gradYears=Math.max(programLen-4,0); }
let totalTuition = ugYears*tuition;
if (gradYears>0) {
const gradTuit = tuitionType==='inState'?school.inStateGraduate:school.outStateGraduate;
totalTuition += gradYears*gradTuit;
}
const ugTuit = pickTuition(school, tuitionType, /*grad?*/ false);
const gradTuit = pickTuition(school, tuitionType, /*grad?*/ true);
let totalTuition = ugYears * ugTuit +
gradYears* gradTuit;
const r = Number(interestRate)/12/100;
const n = Number(loanTerm)*12;

View File

@ -34,10 +34,13 @@ export default function LoanRepaymentDrawer({
/* ── Remote data for auto-suggest ─ */
const [cipData, setCipData] = useState([]);
const [schoolSearch, setSchoolSearch] = useState('');
const [icData, setIcData] = useState([]);
/* ── Simple form fields ─ */
const [degree, setDegree] = useState('');
const [tuitionType, setTuitionType] = useState('inState');
const [tuition, setTuition] = useState('');
const [tuitionManual, setTuitionManual] = useState(false);
const [err, setErr] = useState('');
/* ── When “Continue” is pressed show true calculator ─ */
@ -45,13 +48,40 @@ export default function LoanRepaymentDrawer({
const navigate = useNavigate();
/* ─── helpers (declare FIRST so everything below can use them) ─── */
const currencyFmt = n =>
Number(n).toLocaleString('en-US', { style:'currency', currency:'USD', maximumFractionDigits:0 });
const pickField = ({ inState, outOfState, inStateGraduate, outStateGraduate },
isGrad, resid) =>
isGrad
? resid === 'inState' ? inStateGraduate : outStateGraduate
: resid === 'inState' ? inState : outOfState;
const getAnnualTuition = (schoolsArr, typed, isGrad, resid) =>
pickField(schoolsArr?.[0] ?? {}, isGrad, resid) ||
(typed ? Number(typed) : 0);
/* ── memoed degree list for current school ── */
const schoolDegrees = useMemo(() => {
if (!schoolSearch.trim()) return [];
const list = cipData
.filter(r => r.INSTNM.toLowerCase() === schoolSearch.toLowerCase())
.map(r => r.CREDDESC);
return [...new Set(list)];
}, [schoolSearch, cipData]);
const degreeMenu = schoolDegrees.length ? schoolDegrees : DEGREE_OPTS;
const isGrad = /(Master|Doctoral|First Professional|Graduate|Certificate)/i.test(degree);
const annualTuition= getAnnualTuition(schools, tuition, isGrad, tuitionType);
const showUpsell = user && !user.is_premium && !user.is_pro_premium;
/* ▒▒▒ NEW auto-seed effect ▒▒▒ */
useEffect(() => {
// run once every time the drawer opens
if (!open) return; // drawer closed
if (schools.length) return; // already have data
if (!cipCodes.length) return; // no career → nothing to seed
if (!open) return;
if (schools.length) return;
if (!cipCodes.length) return;
(async () => {
try {
@ -94,6 +124,45 @@ useEffect(() => {
return [...set].slice(0, 10);
}, [schoolSearch, cipData]);
useEffect(() => {
if (!open || icData.length) return;
fetch('/ic2023_ay.csv')
.then(r => r.text())
.then(text => {
const [header, ...rows] = text.split('\n').map(l => l.split(','));
return rows.map(row =>
Object.fromEntries(row.map((v, i) => [header[i], v]))
);
})
.then(setIcData)
.catch(e => console.error('iPEDS load fail', e));
}, [open, icData.length]);
/* ───────── auto-tuition when schoolSearch settles ───────── */
useEffect(() => {
if (!schoolSearch.trim() || !icData.length) return;
const rec = cipData.find(r => r.INSTNM.toLowerCase() === schoolSearch.toLowerCase());
if (!rec) return;
const match = icData.find(r => r.UNITID === rec.UNITID);
if (!match) return;
const calc = () => {
const grad = /(Master|Doctoral|First Professional|Graduate|Certificate)/i.test(degree);
if (!grad) {
return tuitionType === 'inState'
? parseFloat(match.TUITION1 || match.TUITION2 || '')
: parseFloat(match.TUITION3 || '');
}
return tuitionType === 'inState'
? parseFloat(match.TUITION5 || match.TUITION6 || '')
: parseFloat(match.TUITION7 || '');
};
const est = calc();
if (est && !tuitionManual) setTuition(String(est));
}, [schoolSearch, tuitionType, degree, cipData, icData, tuitionManual]);
/*
ESC --> close convenience
*/
@ -105,34 +174,43 @@ useEffect(() => {
return () => window.removeEventListener('keydown', escHandler);
}, [open, escHandler]);
/*
HANDLE CONTINUE
*/
/* ─── “CONTINUE” HANDLER ───────────────────────────────────────── */
const handleContinue = () => {
// Tuition is the only truly required field
// Guard-rail: tuition required
if (!tuition.trim()) {
setErr('Please enter an annual tuition estimate.');
return;
}
setErr('');
// Build a stub “school” object so LoanRepayment
// can work with the same shape it expects
// Selectively populate the ONE tuition field that matches
const base = {
inState: 0,
outOfState: 0,
inStateGraduate: 0,
outStateGraduate: 0,
};
if (!isGrad) {
if (tuitionType === 'inState') base.inState = parseFloat(tuition);
else base.outOfState = parseFloat(tuition);
} else {
if (tuitionType === 'inState') base.inStateGraduate = parseFloat(tuition);
else base.outStateGraduate = parseFloat(tuition);
}
const stub = {
name : schoolSearch || 'Unknown School',
degreeType : degree || 'Unspecified',
programLength : 4, // sensible default
inState : parseFloat(tuition),
outOfState : parseFloat(tuition),
inStateGraduate : parseFloat(tuition),
outStateGraduate: parseFloat(tuition),
programLength : 4,
tuition : parseFloat(tuition),
...base,
};
setSchools([stub]); // overwrite single-school calc
setShowCalc(true);
};
const showUpsell = user && !user.is_premium && !user.is_pro_premium;
/*
RENDER
@ -179,6 +257,7 @@ useEffect(() => {
list="school-suggestions"
placeholder="Start typing…"
className="mt-1 w-full rounded border px-3 py-2 text-sm"
onBlur={e => setSchoolSearch(e.target.value.trim())}
/>
<datalist id="school-suggestions">
{suggestions.map((s, i) => (
@ -187,6 +266,19 @@ useEffect(() => {
</datalist>
</div>
{/* Residency */}
<div>
<label className="text-sm font-medium">Residency status</label>
<select
value={tuitionType} // NEW local state or reuse one from parent
onChange={e => setTuitionType(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2 text-sm"
>
<option value="inState">In-State</option>
<option value="outOfState">Out-of-State</option>
</select>
</div>
{/* Degree */}
<div>
<label className="text-sm font-medium">Degree&nbsp;/ program type</label>
@ -196,7 +288,9 @@ useEffect(() => {
className="mt-1 w-full rounded border px-3 py-2 text-sm"
>
<option value="">Select</option>
{DEGREE_OPTS.map((d, i) => <option key={i} value={d}>{d}</option>)}
{degreeMenu.map((d, i) => (
<option key={i} value={d}>{d}</option>
))}
</select>
</div>
@ -206,11 +300,13 @@ useEffect(() => {
<input
type="number"
min="0"
step="100"
step="1"
value={tuition}
onChange={e => setTuition(e.target.value)}
placeholder="e.g. 28000"
className="mt-1 w-full rounded border px-3 py-2 text-sm"
onChange={e => {
setTuition(e.target.value);
setTuitionManual(true); // user has taken control
}}
/>
</div>
@ -227,6 +323,7 @@ useEffect(() => {
{showCalc && (
<>
<LoanRepayment
tuitionTypeDefault={tuitionType}
schools={schools}
setSchools={setSchools}
setResults={setResults}
@ -238,13 +335,18 @@ useEffect(() => {
{/* PREMIUM CTA */}
{showUpsell ? (
/* ── user is NOT premium ───────────────────────────── */
<div className="bg-blue-50 border-l-4 border-blue-400 p-4 rounded space-y-2 text-sm">
<p className="font-medium">
Want to see how this loan fits into a full financial plan?
Your estimate for&nbsp;
<strong>{degree || 'Unspecified program'}</strong>,&nbsp;
<strong>{tuitionType === 'inState' ? 'In-State' : 'Out-of-State'}</strong>
&nbsp;tuition is&nbsp;
<strong>{currencyFmt(annualTuition)}</strong>.
</p>
<p>
Premium subscribers can model salary growth, living costs,
retirement goals, and more.
Unlock Premium to see exactly how this loan fits into a full financial plan
salary growth, living costs, retirement goals, and more.
</p>
<Button
className="w-full"
@ -257,14 +359,19 @@ useEffect(() => {
</Button>
</div>
) : (
/* If already premium just show a helpful note */
/* ── user IS premium ───────────────────────────────── */
<div className="bg-green-50 border-l-4 border-green-400 p-4 rounded text-sm">
<p className="font-medium">
Youre on Premium open the <strong>College Planning wizard</strong> to store this tuition in your plan
(Profile Premium Onboarding College Details).
<p>
Youre on <strong>Premium</strong> weve estimated your
<strong> {tuitionType === 'inState' ? 'In-State' : 'Out-of-State'}</strong>{' '}
tuition for a <strong>{degree || 'program'}</strong> at&nbsp;
<strong>{currencyFmt(annualTuition)}</strong>.
<br />
Head to <em>Profile Premium Onboarding</em> to put this into your plan.
</p>
</div>
)}
</>
)}
@ -275,27 +382,38 @@ useEffect(() => {
Estimated payments
</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm border">
<thead className="bg-gray-100">
<tr>
<th className="p-2">School</th>
<th className="p-2">Monthly</th>
<th className="p-2">Total Cost</th>
<th className="p-2">Net Gain</th>
<th className="p-2 text-left">School</th>
<th className="p-2 text-right">Monthly</th>
<th className="p-2 text-right">Total&nbsp;Cost</th>
<th className="p-2 text-right">Net&nbsp;Gain</th>
</tr>
</thead>
<tbody>
{results.map((r, i) => (
<tr key={i} className="border-t">
<td className="p-2">{r.name}</td>
<td className="p-2">${r.totalMonthlyPayment}</td>
<td className="p-2">${r.totalLoanCost}</td>
<td className="p-2">${r.netGain}</td>
<td className="p-2">
{r.name || r.INSTNM || '—'}
</td>
<td className="p-2 text-right">
{currencyFmt(r.totalMonthlyPayment)}
</td>
<td className="p-2 text-right">
{currencyFmt(r.totalLoanCost)}
</td>
<td className="p-2 text-right">
{currencyFmt(r.netGain)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>

View File

@ -57,7 +57,7 @@ function PreparingLanding() {
</Button>
<Button onClick={() => setShowLoan(true)}>
How to Pay for Education
Cost of Education & Loan Repayment
</Button>
</div>
</section>