dev1/src/components/LoanRepaymentDrawer.js

425 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
/* ── 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;
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 (
<div className="fixed inset-0 z-50 flex justify-end bg-black/40">
<div className="h-full w-full max-w-md bg-white shadow-xl flex flex-col">
{/* Header */}
<div className="flex justify-between items-center px-5 py-4 border-b">
<h2 className="font-semibold">Estimate Student-Loan Payments</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
<X size={20} />
</button>
</div>
{/* Content */}
<div className="overflow-y-auto p-6 flex-1">
{/* Free money first */}
<section className="text-sm mb-6">
<h3 className="font-semibold mb-1">Free money first</h3>
<ol className="list-decimal list-inside space-y-1">
<li>Submit the <strong>FAFSA</strong> (its free).</li>
<li>Check state <strong>grant&nbsp;/ Promise&nbsp;/ HOPE</strong> programs.</li>
<li>Apply for <strong>scholarships</strong> (FastWeb, Bold.org, local).</li>
<li>Take gift-aid first, loans last.</li>
</ol>
</section>
{/* STEP 1: QUICK FORM */}
{!showCalc && (
<form
className="space-y-4"
onSubmit={e => { e.preventDefault(); handleContinue(); }}
>
{/* School name (optional) */}
<div>
<label className="text-sm font-medium">School name</label>
<input
type="text"
value={schoolSearch}
onChange={e => setSchoolSearch(e.target.value)}
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) => (
<option key={i} value={s} />
))}
</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&nbsp;/ program type</label>
<select
value={degree}
onChange={e => setDegree(e.target.value)}
className="mt-1 w-full rounded border px-3 py-2 text-sm"
>
<option value="">Select</option>
{degreeMenu.map((d, i) => (
<option key={i} value={d}>{d}</option>
))}
</select>
</div>
{/* Tuition */}
<div>
<label className="text-sm font-medium">Estimated annual tuition *</label>
<input
type="number"
min="0"
step="1"
value={tuition}
placeholder="e.g. 28000"
onChange={e => {
setTuition(e.target.value);
setTuitionManual(true); // user has taken control
}}
/>
</div>
{/* Error */}
{err && <p className="text-red-600 text-sm">{err}</p>}
<Button type="submit" className="w-full">
Continue
</Button>
</form>
)}
{/* STEP 2: REAL CALCULATOR */}
{showCalc && (
<>
<LoanRepayment
tuitionTypeDefault={tuitionType}
schools={schools}
setSchools={setSchools}
setResults={setResults}
results={results}
/>
{/* small separator */}
<hr className="my-6" />
{/* 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&nbsp;
<strong>{degree || 'Unspecified program'}</strong>,&nbsp;
<strong>{tuitionType === 'inState' ? 'In-State' : 'Out-of-State'}</strong>
&nbsp;tuition is&nbsp;
<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>
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>
)}
</>
)}
{/* STEP 3 : results table */}
{results?.length > 0 && (
<div className="mt-8">
<h4 className="font-semibold mb-2 text-center">
Estimated payments
</h4>
<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&nbsp;Cost</th>
<th className="p-2 text-right">Net&nbsp;Gain</th>
</tr>
</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>
</div>
</div>
);
}