Fixed issues with LoanRepayment
This commit is contained in:
parent
7b810ff2de
commit
4ca8d11156
@ -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;
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
|
||||||
|
/* ── 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(() => {
|
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 = () => {
|
|
||||||
// Tuition is the only truly required field
|
|
||||||
if (!tuition.trim()) {
|
if (!tuition.trim()) {
|
||||||
setErr('Please enter an annual tuition estimate.');
|
setErr('Please enter an annual tuition estimate.');
|
||||||
return;
|
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 = {
|
||||||
|
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 = {
|
const stub = {
|
||||||
name : schoolSearch || 'Unknown School',
|
name : schoolSearch || 'Unknown School',
|
||||||
degreeType : degree || 'Unspecified',
|
degreeType : degree || 'Unspecified',
|
||||||
programLength : 4, // sensible default
|
programLength : 4,
|
||||||
inState : parseFloat(tuition),
|
tuition : parseFloat(tuition),
|
||||||
outOfState : parseFloat(tuition),
|
...base,
|
||||||
inStateGraduate : parseFloat(tuition),
|
|
||||||
outStateGraduate: parseFloat(tuition),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setSchools([stub]); // overwrite – single-school calc
|
setSchools([stub]); // overwrite – single-school calc
|
||||||
setShowCalc(true);
|
setShowCalc(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showUpsell = user && !user.is_premium && !user.is_pro_premium;
|
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════
|
||||||
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 / program type</label>
|
<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"
|
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}
|
||||||
@ -238,13 +335,18 @@ useEffect(() => {
|
|||||||
|
|
||||||
{/* PREMIUM CTA */}
|
{/* PREMIUM CTA */}
|
||||||
{showUpsell ? (
|
{showUpsell ? (
|
||||||
|
/* ── user is NOT premium ───────────────────────────── */
|
||||||
<div className="bg-blue-50 border-l-4 border-blue-400 p-4 rounded space-y-2 text-sm">
|
<div className="bg-blue-50 border-l-4 border-blue-400 p-4 rounded space-y-2 text-sm">
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
Want to see how this loan fits into a full financial plan?
|
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>
|
||||||
<p>
|
<p>
|
||||||
Premium subscribers can model salary growth, living costs,
|
Unlock Premium to see exactly how this loan fits into a full financial plan
|
||||||
retirement goals, and more.
|
— salary growth, living costs, retirement goals, and more.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@ -257,14 +359,19 @@ useEffect(() => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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">
|
<div className="bg-green-50 border-l-4 border-green-400 p-4 rounded text-sm">
|
||||||
<p className="font-medium">
|
<p>
|
||||||
You’re on Premium — open the <strong>College Planning wizard</strong> to store this tuition in your plan
|
You’re on <strong>Premium</strong> — we’ve estimated your
|
||||||
(Profile ▸ Premium Onboarding ▸ College Details).
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -275,27 +382,38 @@ useEffect(() => {
|
|||||||
Estimated payments
|
Estimated payments
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm border">
|
<table className="w-full text-sm border">
|
||||||
<thead className="bg-gray-100">
|
<thead className="bg-gray-100">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="p-2">School</th>
|
<th className="p-2 text-left">School</th>
|
||||||
<th className="p-2">Monthly</th>
|
<th className="p-2 text-right">Monthly</th>
|
||||||
<th className="p-2">Total Cost</th>
|
<th className="p-2 text-right">Total Cost</th>
|
||||||
<th className="p-2">Net Gain</th>
|
<th className="p-2 text-right">Net Gain</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{results.map((r, i) => (
|
{results.map((r, i) => (
|
||||||
<tr key={i} className="border-t">
|
<tr key={i} className="border-t">
|
||||||
<td className="p-2">{r.name}</td>
|
<td className="p-2">
|
||||||
<td className="p-2">${r.totalMonthlyPayment}</td>
|
{r.name || r.INSTNM || '—'}
|
||||||
<td className="p-2">${r.totalLoanCost}</td>
|
</td>
|
||||||
<td className="p-2">${r.netGain}</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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>
|
||||||
|
Loading…
Reference in New Issue
Block a user