// src/components/LoanRepaymentDrawer.js import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { X } from 'lucide-react'; import LoanRepayment from './LoanRepayment.js'; import { Button } from './ui/button.js'; import { getNearbySchools } from '../utils/getNearbySchools.js'; import UpsellSummary from './UpsellSummary.js'; import { useNavigate } from 'react-router-dom'; /* ───────────────────────── CONSTANTS ───────────────────────── */ const DEGREE_OPTS = [ "Associate's Degree", "Bachelor's Degree", "Master's Degree", 'Graduate / Professional Certificate', 'First Professional Degree', 'Doctoral Degree', ]; /* ──────────────────────── COMPONENT ────────────────────────── */ export default function LoanRepaymentDrawer({ open, onClose, schools, // comes from PreparingLanding setSchools, results, setResults, user, cipCodes = [], userZip = '', userState='', }) { /* Hooks must always run – return null later if !open */ /* ── 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 ─ */ const [showCalc, setShowCalc] = useState(false); 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; useEffect(() => { if (!open) return; if (schools.length) return; if (!cipCodes.length) return; (async () => { try { const seed = await getNearbySchools(cipCodes, userZip, userState); if (seed.length) setSchools(seed); } catch (e) { console.warn('auto-seed schools failed:', e); } })(); }, [open, schools.length, cipCodes.join('-'), userZip, userState, setSchools]); /* ════════════════════════════════════════════════════ FETCH CIP DATA (only once the drawer is ever opened) ════════════════════════════════════════════════════ */ useEffect(() => { if (!open || cipData.length) return; fetch('/cip_institution_mapping_new.json') .then(r => r.text()) .then(text => text .split('\n') .map(l => { try { return JSON.parse(l); } catch { return null; } }) .filter(Boolean) ) .then(arr => setCipData(arr)) .catch(e => console.error('CIP fetch error', e)); }, [open, cipData.length]); /* ════════════════════════════════════════════════════ SCHOOL AUTOCOMPLETE LIST (memoised) ════════════════════════════════════════════════════ */ const suggestions = useMemo(() => { if (!schoolSearch.trim()) return []; const low = schoolSearch.toLowerCase(); const set = new Set( cipData .filter(r => r.INSTNM.toLowerCase().includes(low)) .map(r => r.INSTNM) ); 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 ════════════════════════════════════════════════════ */ const escHandler = useCallback(e => { if (e.key === 'Escape') onClose(); }, [onClose]); useEffect(() => { if (open) window.addEventListener('keydown', escHandler); return () => window.removeEventListener('keydown', escHandler); }, [open, escHandler]); /* ─── “CONTINUE” HANDLER ───────────────────────────────────────── */ const handleContinue = () => { // Guard-rail: tuition required if (!tuition.trim()) { setErr('Please enter an annual tuition estimate.'); return; } setErr(''); // 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, tuition : parseFloat(tuition), ...base, }; setSchools([stub]); // overwrite – single-school calc setShowCalc(true); }; /* ════════════════════════════════════════════════════ RENDER ════════════════════════════════════════════════════ */ if (!open) return null; return (
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.
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.
School | Monthly | Total Cost | Net Gain |
---|---|---|---|
{r.name || r.INSTNM || '—'} | {currencyFmt(r.totalMonthlyPayment)} | {currencyFmt(r.totalLoanCost)} | {currencyFmt(r.netGain)} |