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

View File

@ -34,10 +34,13 @@ export default function LoanRepaymentDrawer({
/* ── Remote data for auto-suggest ─ */ /* ── Remote data for auto-suggest ─ */
const [cipData, setCipData] = useState([]); const [cipData, setCipData] = useState([]);
const [schoolSearch, setSchoolSearch] = useState(''); const [schoolSearch, setSchoolSearch] = useState('');
const [icData, setIcData] = useState([]);
/* ── Simple form fields ─ */ /* ── Simple form fields ─ */
const [degree, setDegree] = useState(''); const [degree, setDegree] = useState('');
const [tuitionType, setTuitionType] = useState('inState');
const [tuition, setTuition] = useState(''); const [tuition, setTuition] = useState('');
const [tuitionManual, setTuitionManual] = useState(false);
const [err, setErr] = useState(''); const [err, setErr] = useState('');
/* ── When “Continue” is pressed show true calculator ─ */ /* ── When “Continue” is pressed show true calculator ─ */
@ -45,13 +48,40 @@ export default function LoanRepaymentDrawer({
const navigate = useNavigate(); 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(() => { useEffect(() => {
// run once every time the drawer opens if (!open) return;
if (!open) return; // drawer closed if (schools.length) return;
if (schools.length) return; // already have data if (!cipCodes.length) return;
if (!cipCodes.length) return; // no career → nothing to seed
(async () => { (async () => {
try { try {
@ -94,6 +124,45 @@ useEffect(() => {
return [...set].slice(0, 10); return [...set].slice(0, 10);
}, [schoolSearch, cipData]); }, [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 ESC --> close convenience
*/ */
@ -105,34 +174,43 @@ useEffect(() => {
return () => window.removeEventListener('keydown', escHandler); return () => window.removeEventListener('keydown', escHandler);
}, [open, escHandler]); }, [open, escHandler]);
/* /* ─── “CONTINUE” HANDLER ───────────────────────────────────────── */
HANDLE CONTINUE const handleContinue = () => {
*/ // Guard-rail: tuition required
const handleContinue = () => { if (!tuition.trim()) {
// Tuition is the only truly required field setErr('Please enter an annual tuition estimate.');
if (!tuition.trim()) { return;
setErr('Please enter an annual tuition estimate.'); }
return; setErr('');
}
setErr('');
// Build a stub “school” object so LoanRepayment // Selectively populate the ONE tuition field that matches
// can work with the same shape it expects const base = {
const stub = { inState: 0,
name : schoolSearch || 'Unknown School', outOfState: 0,
degreeType : degree || 'Unspecified', inStateGraduate: 0,
programLength : 4, // sensible default outStateGraduate: 0,
inState : parseFloat(tuition),
outOfState : parseFloat(tuition),
inStateGraduate : parseFloat(tuition),
outStateGraduate: parseFloat(tuition),
};
setSchools([stub]); // overwrite single-school calc
setShowCalc(true);
}; };
const showUpsell = user && !user.is_premium && !user.is_pro_premium; 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,
tuition : parseFloat(tuition),
...base,
};
setSchools([stub]); // overwrite single-school calc
setShowCalc(true);
};
/* /*
RENDER RENDER
@ -179,6 +257,7 @@ useEffect(() => {
list="school-suggestions" list="school-suggestions"
placeholder="Start typing…" placeholder="Start typing…"
className="mt-1 w-full rounded border px-3 py-2 text-sm" className="mt-1 w-full rounded border px-3 py-2 text-sm"
onBlur={e => setSchoolSearch(e.target.value.trim())}
/> />
<datalist id="school-suggestions"> <datalist id="school-suggestions">
{suggestions.map((s, i) => ( {suggestions.map((s, i) => (
@ -187,6 +266,19 @@ useEffect(() => {
</datalist> </datalist>
</div> </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 */} {/* Degree */}
<div> <div>
<label className="text-sm font-medium">Degree&nbsp;/ program type</label> <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" className="mt-1 w-full rounded border px-3 py-2 text-sm"
> >
<option value="">Select</option> <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> </select>
</div> </div>
@ -206,11 +300,13 @@ useEffect(() => {
<input <input
type="number" type="number"
min="0" min="0"
step="100" step="1"
value={tuition} value={tuition}
onChange={e => setTuition(e.target.value)}
placeholder="e.g. 28000" 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> </div>
@ -227,6 +323,7 @@ useEffect(() => {
{showCalc && ( {showCalc && (
<> <>
<LoanRepayment <LoanRepayment
tuitionTypeDefault={tuitionType}
schools={schools} schools={schools}
setSchools={setSchools} setSchools={setSchools}
setResults={setResults} setResults={setResults}
@ -236,35 +333,45 @@ useEffect(() => {
{/* small separator */} {/* small separator */}
<hr className="my-6" /> <hr className="my-6" />
{/* PREMIUM CTA */} {/* PREMIUM CTA */}
{showUpsell ? ( {showUpsell ? (
<div className="bg-blue-50 border-l-4 border-blue-400 p-4 rounded space-y-2 text-sm"> /* ── user is NOT premium ───────────────────────────── */
<p className="font-medium"> <div className="bg-blue-50 border-l-4 border-blue-400 p-4 rounded space-y-2 text-sm">
Want to see how this loan fits into a full financial plan? <p className="font-medium">
</p> Your estimate for&nbsp;
<p> <strong>{degree || 'Unspecified program'}</strong>,&nbsp;
Premium subscribers can model salary growth, living costs, <strong>{tuitionType === 'inState' ? 'In-State' : 'Out-of-State'}</strong>
retirement goals, and more. &nbsp;tuition is&nbsp;
</p> <strong>{currencyFmt(annualTuition)}</strong>.
<Button </p>
className="w-full" <p>
onClick={() => { Unlock Premium to see exactly how this loan fits into a full financial plan
onClose(); salary growth, living costs, retirement goals, and more.
navigate('/paywall'); </p>
}} <Button
> className="w-full"
Unlock Premium Features onClick={() => {
</Button> onClose();
</div> navigate('/paywall');
) : ( }}
/* If already premium just show a helpful note */ >
<div className="bg-green-50 border-l-4 border-green-400 p-4 rounded text-sm"> Unlock Premium Features
<p className="font-medium"> </Button>
Youre on Premium open the <strong>College Planning wizard</strong> to store this tuition in your plan </div>
(Profile Premium Onboarding College Details). ) : (
</p> /* ── user IS premium ───────────────────────────────── */
</div> <div className="bg-green-50 border-l-4 border-green-400 p-4 rounded text-sm">
)} <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,26 +382,37 @@ useEffect(() => {
Estimated payments Estimated payments
</h4> </h4>
<table className="w-full text-sm border"> <div className="overflow-x-auto">
<thead className="bg-gray-100"> <table className="w-full text-sm border">
<tr> <thead className="bg-gray-100">
<th className="p-2">School</th> <tr>
<th className="p-2">Monthly</th> <th className="p-2 text-left">School</th>
<th className="p-2">Total Cost</th> <th className="p-2 text-right">Monthly</th>
<th className="p-2">Net Gain</th> <th className="p-2 text-right">Total&nbsp;Cost</th>
</tr> <th className="p-2 text-right">Net&nbsp;Gain</th>
</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>
</tr> </tr>
))} </thead>
</tbody>
</table> <tbody>
{results.map((r, i) => (
<tr key={i} className="border-t">
<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> </div>

View File

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