425 lines
17 KiB
JavaScript
425 lines
17 KiB
JavaScript
// 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 (
|
||
<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> (it’s free).</li>
|
||
<li>Check state <strong>grant / Promise / 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 / 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
|
||
<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>
|
||
)}
|
||
|
||
</>
|
||
)}
|
||
|
||
{/* 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 Cost</th>
|
||
<th className="p-2 text-right">Net 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>
|
||
|
||
|
||
);
|
||
}
|