diff --git a/src/components/LoanRepayment.js b/src/components/LoanRepayment.js index 515a654..9b95186 100644 --- a/src/components/LoanRepayment.js +++ b/src/components/LoanRepayment.js @@ -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; diff --git a/src/components/LoanRepaymentDrawer.js b/src/components/LoanRepaymentDrawer.js index 8261a9d..517f11b 100644 --- a/src/components/LoanRepaymentDrawer.js +++ b/src/components/LoanRepaymentDrawer.js @@ -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())} /> {suggestions.map((s, i) => ( @@ -187,6 +266,19 @@ useEffect(() => { + {/* Residency */} +
+ + +
+ {/* Degree */}
@@ -196,7 +288,9 @@ useEffect(() => { className="mt-1 w-full rounded border px-3 py-2 text-sm" > - {DEGREE_OPTS.map((d, i) => )} + {degreeMenu.map((d, i) => ( + + ))}
@@ -206,11 +300,13 @@ useEffect(() => { 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 + }} /> @@ -227,6 +323,7 @@ useEffect(() => { {showCalc && ( <> { {/* small separator */}
- {/* PREMIUM CTA */} - {showUpsell ? ( -
-

- Want to see how this loan fits into a full financial plan? -

-

- Premium subscribers can model salary growth, living costs, - retirement goals, and more. -

- -
- ) : ( - /* If already premium just show a helpful note */ -
-

- You’re on Premium — open the ​College Planning wizard to store this tuition in your plan - (Profile ▸ Premium Onboarding ▸ College Details). -

-
- )} + {/* PREMIUM CTA */} + {showUpsell ? ( + /* ── user is NOT premium ───────────────────────────── */ +
+

+ Your estimate for  + {degree || 'Unspecified program'},  + {tuitionType === 'inState' ? 'In-State' : 'Out-of-State'} +  tuition is  + {currencyFmt(annualTuition)}. +

+

+ Unlock Premium to see exactly how this loan fits into a full financial plan + — salary growth, living costs, retirement goals, and more. +

+ +
+ ) : ( + /* ── user IS premium ───────────────────────────────── */ +
+

+ You’re on Premium — we’ve estimated your + {tuitionType === 'inState' ? 'In-State' : 'Out-of-State'}{' '} + tuition for a {degree || 'program'} at  + {currencyFmt(annualTuition)}. +
+ Head to Profile ▸ Premium Onboarding to put this into your plan. +

+
+ )} + )} @@ -275,26 +382,37 @@ useEffect(() => { Estimated payments - - - - - - - - - - - {results.map((r, i) => ( - - - - - +
+
SchoolMonthlyTotal CostNet Gain
{r.name}${r.totalMonthlyPayment}${r.totalLoanCost}${r.netGain}
+ + + + + + - ))} - -
SchoolMonthlyTotal CostNet Gain
+ + + + {results.map((r, i) => ( + + + {r.name || r.INSTNM || '—'} + + + {currencyFmt(r.totalMonthlyPayment)} + + + {currencyFmt(r.totalLoanCost)} + + + {currencyFmt(r.netGain)} + + + ))} + + + )} diff --git a/src/components/PreparingLanding.js b/src/components/PreparingLanding.js index 50158a6..4415f06 100644 --- a/src/components/PreparingLanding.js +++ b/src/components/PreparingLanding.js @@ -57,7 +57,7 @@ function PreparingLanding() {