Fixed issues with LoanRepayment
This commit is contained in:
parent
7b810ff2de
commit
4ca8d11156
@ -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;
|
||||
|
@ -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);
|
||||
|
||||
|
||||
/* ── memo’ed 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”
|
||||
════════════════════════════════════════════════════ */
|
||||
const handleContinue = () => {
|
||||
// Tuition is the only truly required field
|
||||
if (!tuition.trim()) {
|
||||
setErr('Please enter an annual tuition estimate.');
|
||||
return;
|
||||
}
|
||||
setErr('');
|
||||
/* ─── “CONTINUE” HANDLER ───────────────────────────────────────── */
|
||||
const handleContinue = () => {
|
||||
// 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
|
||||
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),
|
||||
};
|
||||
|
||||
setSchools([stub]); // overwrite – single-school calc
|
||||
setShowCalc(true);
|
||||
// Selectively populate the ONE tuition field that matches
|
||||
const base = {
|
||||
inState: 0,
|
||||
outOfState: 0,
|
||||
inStateGraduate: 0,
|
||||
outStateGraduate: 0,
|
||||
};
|
||||
|
||||
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
|
||||
@ -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 / 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}
|
||||
@ -236,35 +333,45 @@ useEffect(() => {
|
||||
{/* small separator */}
|
||||
<hr className="my-6" />
|
||||
|
||||
{/* PREMIUM CTA */}
|
||||
{showUpsell ? (
|
||||
<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?
|
||||
</p>
|
||||
<p>
|
||||
Premium subscribers can model salary growth, living costs,
|
||||
retirement goals, and more.
|
||||
</p>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate('/paywall');
|
||||
}}
|
||||
>
|
||||
Unlock Premium Features →
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
/* If already premium just show a helpful note */
|
||||
<div className="bg-green-50 border-l-4 border-green-400 p-4 rounded text-sm">
|
||||
<p className="font-medium">
|
||||
You’re on Premium — open the <strong>College Planning wizard</strong> to store this tuition in your plan
|
||||
(Profile ▸ Premium Onboarding ▸ College Details).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* 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">
|
||||
Your estimate for
|
||||
<strong>{degree || 'Unspecified program'}</strong>,
|
||||
<strong>{tuitionType === 'inState' ? 'In-State' : 'Out-of-State'}</strong>
|
||||
tuition is
|
||||
<strong>{currencyFmt(annualTuition)}</strong>.
|
||||
</p>
|
||||
<p>
|
||||
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"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate('/paywall');
|
||||
}}
|
||||
>
|
||||
Unlock Premium Features →
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
/* ── user IS premium ───────────────────────────────── */
|
||||
<div className="bg-green-50 border-l-4 border-green-400 p-4 rounded text-sm">
|
||||
<p>
|
||||
You’re on <strong>Premium</strong> — we’ve estimated your
|
||||
<strong> {tuitionType === 'inState' ? 'In-State' : 'Out-of-State'}</strong>{' '}
|
||||
tuition for a <strong>{degree || 'program'}</strong> at
|
||||
<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
|
||||
</h4>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="p-2 text-left">School</th>
|
||||
<th className="p-2 text-right">Monthly</th>
|
||||
<th className="p-2 text-right">Total Cost</th>
|
||||
<th className="p-2 text-right">Net Gain</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
|
||||
<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>
|
||||
|
@ -57,7 +57,7 @@ function PreparingLanding() {
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => setShowLoan(true)}>
|
||||
How to Pay for Education
|
||||
Cost of Education & Loan Repayment
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
Loading…
Reference in New Issue
Block a user