← back to StudentLoanTracker
src/app/pslf/page.tsx
351 lines
'use client';
import { useState } from 'react';
import { ShieldCheck, TrendingUp } from 'lucide-react';
import {
calculatePSLF,
type LoanInputs,
type FilingStatus,
} from '@/lib/calculators';
import type { PSLFResult } from '@/lib/calculators';
function formatCurrency(n: number): string {
return n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
}
export default function PSLFPage() {
const [inputs, setInputs] = useState({
totalBalance: 60000,
weightedRate: 0.06,
agi: 55000,
familySize: 1,
filingStatus: 'single' as FilingStatus,
state: 'CA',
qualifyingPayments: 48,
selectedPlan: 'SAVE' as 'SAVE' | 'IBR' | 'PAYE' | 'ICR' | 'Standard',
employerType: 'government' as 'government' | 'nonprofit' | 'for_profit',
});
const [result, setResult] = useState<PSLFResult | null>(null);
function handleCalculate() {
const r = calculatePSLF({
totalBalance: inputs.totalBalance,
weightedRate: inputs.weightedRate,
agi: inputs.agi,
familySize: inputs.familySize,
filingStatus: inputs.filingStatus,
state: inputs.state,
qualifyingPayments: inputs.qualifyingPayments,
selectedPlan: inputs.selectedPlan,
employerType: inputs.employerType,
});
setResult(r);
}
const progressPct = Math.min(100, (inputs.qualifyingPayments / 120) * 100);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="max-w-3xl mb-10">
<div className="flex items-center gap-3 mb-4">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-lg bg-emerald-500/10 text-emerald-600">
<ShieldCheck className="w-5 h-5" />
</div>
<h1 className="text-3xl font-bold">PSLF Progress Tracker</h1>
</div>
<p className="text-gray-600 text-lg leading-relaxed">
Track your progress toward 120 qualifying payments for Public Service Loan Forgiveness.
See how much would be forgiven and when.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Inputs */}
<div className="lg:col-span-1">
<div className="bg-white rounded-xl border border-gray-200 p-6 sticky top-6 space-y-4">
<h2 className="text-lg font-semibold">Your PSLF Details</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Qualifying Payments Made</label>
<input type="number" min="0" max="120" value={inputs.qualifyingPayments}
onChange={(e) => setInputs(p => ({...p, qualifyingPayments: Number(e.target.value)}))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Employer Type</label>
<select value={inputs.employerType}
onChange={(e) => setInputs(p => ({...p, employerType: e.target.value as typeof inputs.employerType}))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
>
<option value="government">Government</option>
<option value="nonprofit">501(c)(3) Nonprofit</option>
<option value="for_profit">For-Profit (not eligible)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Repayment Plan</label>
<select value={inputs.selectedPlan}
onChange={(e) => setInputs(p => ({...p, selectedPlan: e.target.value as typeof inputs.selectedPlan}))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
>
<option value="SAVE">SAVE</option>
<option value="IBR">IBR</option>
<option value="PAYE">PAYE</option>
<option value="ICR">ICR</option>
<option value="Standard">Standard (10-Year)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Loan Balance</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">$</span>
<input type="number" value={inputs.totalBalance}
onChange={(e) => setInputs(p => ({...p, totalBalance: Number(e.target.value)}))}
className="w-full pl-7 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Interest Rate (%)</label>
<input type="number" step="0.1" value={(inputs.weightedRate * 100).toFixed(1)}
onChange={(e) => setInputs(p => ({...p, weightedRate: Number(e.target.value) / 100}))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Annual Income (AGI)</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">$</span>
<input type="number" value={inputs.agi}
onChange={(e) => setInputs(p => ({...p, agi: Number(e.target.value)}))}
className="w-full pl-7 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Family Size</label>
<input type="number" min="1" max="20" value={inputs.familySize}
onChange={(e) => setInputs(p => ({...p, familySize: Number(e.target.value)}))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
</div>
<button onClick={handleCalculate}
className="w-full mt-2 px-6 py-3 bg-emerald-500 hover:bg-emerald-600 text-white font-semibold rounded-lg transition-colors"
>
Calculate PSLF
</button>
</div>
</div>
{/* Results */}
<div className="lg:col-span-2 space-y-6">
{/* Thermometer Countdown */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-semibold mb-6 text-center">Payment Progress</h3>
<div className="flex items-center gap-8">
{/* SVG Thermometer */}
<div className="flex flex-col items-center shrink-0">
<svg width="80" height="320" viewBox="0 0 80 320" className="drop-shadow-lg">
{/* Outer shell */}
<rect x="20" y="10" width="40" height="240" rx="20" ry="20"
fill="#f1f5f9" stroke="#e2e8f0" strokeWidth="2" />
{/* Fill (bottom-up) */}
<clipPath id="thermClip">
<rect x="22" y="12" width="36" height="236" rx="18" ry="18" />
</clipPath>
<rect
x="22"
y={12 + 236 * (1 - progressPct / 100)}
width="36"
height={236 * (progressPct / 100)}
clipPath="url(#thermClip)"
className="transition-all duration-700 ease-out"
fill="url(#thermoGrad)"
/>
{/* Gradient */}
<defs>
<linearGradient id="thermoGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#10b981" />
<stop offset="50%" stopColor="#34d399" />
<stop offset="100%" stopColor="#6ee7b7" />
</linearGradient>
</defs>
{/* Bulb at bottom */}
<circle cx="40" cy="280" r="30"
fill={progressPct >= 100 ? '#10b981' : '#f1f5f9'}
stroke="#e2e8f0" strokeWidth="2"
className="transition-all duration-700"
/>
<circle cx="40" cy="280" r="24"
fill={progressPct > 0 ? 'url(#thermoGrad)' : '#e2e8f0'}
className="transition-all duration-700"
/>
{/* Tick marks every 30 payments */}
{[0, 30, 60, 90, 120].map((tick) => {
const y = 248 - (tick / 120) * 236;
return (
<g key={tick}>
<line x1="60" y1={y} x2="70" y2={y} stroke="#94a3b8" strokeWidth="1.5" />
<text x="74" y={y + 4} fontSize="10" fill="#64748b" fontFamily="sans-serif">
{tick}
</text>
</g>
);
})}
{/* Current level indicator */}
{progressPct > 0 && progressPct < 100 && (
<g>
<line
x1="10" y1={248 - (inputs.qualifyingPayments / 120) * 236}
x2="20" y2={248 - (inputs.qualifyingPayments / 120) * 236}
stroke="#059669" strokeWidth="2"
/>
<text
x="4" y={248 - (inputs.qualifyingPayments / 120) * 236 + 4}
fontSize="10" fill="#059669" fontWeight="bold" textAnchor="end"
fontFamily="sans-serif"
>
</text>
</g>
)}
{/* 120 goal star */}
{progressPct >= 100 && (
<text x="40" y="285" textAnchor="middle" fontSize="20">
⭐
</text>
)}
</svg>
</div>
{/* Stats beside thermometer */}
<div className="flex-1 space-y-4">
<div>
<div className="text-5xl font-bold text-emerald-600 tracking-tight">
{inputs.qualifyingPayments}
</div>
<div className="text-gray-400 text-sm mt-1">of 120 qualifying payments</div>
</div>
<div className="h-px bg-gray-100" />
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-2xl font-bold text-[#0f1b2d]">
{120 - inputs.qualifyingPayments}
</div>
<div className="text-xs text-gray-500">payments remaining</div>
</div>
<div>
<div className="text-2xl font-bold text-[#0f1b2d]">
{Math.ceil((120 - inputs.qualifyingPayments) / 12)}yr {(120 - inputs.qualifyingPayments) % 12}mo
</div>
<div className="text-xs text-gray-500">time remaining</div>
</div>
</div>
<div className="h-px bg-gray-100" />
{/* Mini progress percentage */}
<div className="flex items-center gap-3">
<div className="flex-1 bg-gray-100 rounded-full h-2 overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full transition-all duration-700"
style={{ width: `${progressPct}%` }}
/>
</div>
<span className="text-sm font-semibold text-emerald-600 w-14 text-right">
{progressPct.toFixed(1)}%
</span>
</div>
{progressPct >= 100 && (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-3 text-center">
<span className="text-emerald-700 font-semibold">
Congratulations! You've reached 120 qualifying payments!
</span>
</div>
)}
</div>
</div>
</div>
{inputs.employerType === 'for_profit' && (
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
<p className="text-red-800 font-medium">
For-profit employers do not qualify for PSLF. You must work full-time for a
government organization or 501(c)(3) nonprofit.
</p>
</div>
)}
{result && inputs.employerType !== 'for_profit' && (
<>
{/* Key Metrics */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
<div className="text-sm text-gray-500 mb-1">Monthly Payment</div>
<div className="text-2xl font-bold">{formatCurrency(result.monthlyPayment)}</div>
<div className="text-xs text-gray-400 mt-1">on {inputs.selectedPlan} plan</div>
</div>
<div className="bg-emerald-50 border border-emerald-200 rounded-xl p-6 text-center">
<div className="text-sm text-emerald-600 mb-1">Amount Forgiven</div>
<div className="text-2xl font-bold text-emerald-700">{formatCurrency(result.forgivenessAmount)}</div>
<div className="text-xs text-emerald-500 mt-1">tax-free under PSLF</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
<div className="text-sm text-gray-500 mb-1">Forgiveness Date</div>
<div className="text-2xl font-bold">{result.estimatedForgivenessDate}</div>
<div className="text-xs text-gray-400 mt-1">{result.paymentsRemaining} months away</div>
</div>
</div>
{/* Plan Comparison */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-semibold mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-emerald-500" />
Monthly Payment by Plan
</h3>
<div className="space-y-3">
{Object.entries(result.monthlyPaymentByPlan).map(([plan, payment]) => {
const maxPayment = Math.max(...Object.values(result.monthlyPaymentByPlan));
const widthPct = maxPayment > 0 ? (payment / maxPayment) * 100 : 0;
return (
<div key={plan}>
<div className="flex justify-between text-sm mb-1">
<span className={`font-medium ${plan === inputs.selectedPlan ? 'text-emerald-600' : 'text-gray-700'}`}>
{plan} {plan === inputs.selectedPlan && '(selected)'}
</span>
<span className="font-semibold">{formatCurrency(payment)}</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-2">
<div
className={`h-full rounded-full ${plan === inputs.selectedPlan ? 'bg-emerald-500' : 'bg-gray-300'}`}
style={{ width: `${widthPct}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
<div className="text-xs text-gray-400 text-center">
PSLF forgiveness is tax-free. Estimates assume continuous qualifying employment and on-time payments.
</div>
</>
)}
</div>
</div>
</div>
);
}