307 lines
12 KiB
JavaScript
307 lines
12 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('');
|
||
|
||
/* ── Simple form fields ─ */
|
||
const [degree, setDegree] = useState('');
|
||
const [tuition, setTuition] = useState('');
|
||
const [err, setErr] = useState('');
|
||
|
||
/* ── When “Continue” is pressed show true calculator ─ */
|
||
const [showCalc, setShowCalc] = useState(false);
|
||
|
||
const navigate = useNavigate();
|
||
|
||
|
||
/* ▒▒▒ 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
|
||
|
||
(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]);
|
||
|
||
/* ════════════════════════════════════════════════════
|
||
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]);
|
||
|
||
/* ════════════════════════════════════════════════════
|
||
HANDLE “CONTINUE”
|
||
════════════════════════════════════════════════════ */
|
||
const handleContinue = () => {
|
||
// Tuition is the only truly required field
|
||
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);
|
||
};
|
||
|
||
const showUpsell = user && !user.is_premium && !user.is_pro_premium;
|
||
|
||
/* ════════════════════════════════════════════════════
|
||
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"
|
||
/>
|
||
<datalist id="school-suggestions">
|
||
{suggestions.map((s, i) => (
|
||
<option key={i} value={s} />
|
||
))}
|
||
</datalist>
|
||
</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>
|
||
{DEGREE_OPTS.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="100"
|
||
value={tuition}
|
||
onChange={e => setTuition(e.target.value)}
|
||
placeholder="e.g. 28000"
|
||
className="mt-1 w-full rounded border px-3 py-2 text-sm"
|
||
/>
|
||
</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
|
||
schools={schools}
|
||
setSchools={setSchools}
|
||
setResults={setResults}
|
||
results={results}
|
||
/>
|
||
|
||
{/* small separator */}
|
||
<hr className="my-6" />
|
||
|
||
{/* PREMIUM CTA */}
|
||
{showUpsell ? (
|
||
<div className="bg-blue-50 border-l-4 border-blue-400 p-4 rounded space-y-2 text-sm">
|
||
<p className="font-medium">
|
||
Want to see how this loan fits into a full financial plan?
|
||
</p>
|
||
<p>
|
||
Premium subscribers can model salary growth, living costs,
|
||
retirement goals, and more.
|
||
</p>
|
||
<Button
|
||
className="w-full"
|
||
onClick={() => {
|
||
onClose();
|
||
navigate('/paywall');
|
||
}}
|
||
>
|
||
Unlock Premium Features →
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
/* If already premium just show a helpful note */
|
||
<div className="bg-green-50 border-l-4 border-green-400 p-4 rounded text-sm">
|
||
<p className="font-medium">
|
||
You’re on Premium — open the <strong>College Planning wizard</strong> to store this tuition in your plan
|
||
(Profile ▸ Premium Onboarding ▸ College Details).
|
||
</p>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* STEP 3 : results table */}
|
||
{results?.length > 0 && (
|
||
<div className="mt-8">
|
||
<h4 className="font-semibold mb-2 text-center">
|
||
Estimated payments
|
||
</h4>
|
||
|
||
<table className="w-full text-sm border">
|
||
<thead className="bg-gray-100">
|
||
<tr>
|
||
<th className="p-2">School</th>
|
||
<th className="p-2">Monthly</th>
|
||
<th className="p-2">Total Cost</th>
|
||
<th className="p-2">Net Gain</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{results.map((r, i) => (
|
||
<tr key={i} className="border-t">
|
||
<td className="p-2">{r.name}</td>
|
||
<td className="p-2">${r.totalMonthlyPayment}</td>
|
||
<td className="p-2">${r.totalLoanCost}</td>
|
||
<td className="p-2">${r.netGain}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
);
|
||
}
|