← back to StudentLoanTracker
src/app/calculator/page.tsx
347 lines
'use client';
import { useState } from 'react';
import { Calculator, ChevronDown, ChevronUp, Download, Info } from 'lucide-react';
import {
compareAllPlans,
type LoanInputs,
type ComparisonResult,
type FilingStatus,
} from '@/lib/calculators';
const FILING_OPTIONS: { value: FilingStatus; label: string }[] = [
{ value: 'single', label: 'Single' },
{ value: 'married_filing_jointly', label: 'Married Filing Jointly' },
{ value: 'married_filing_separately', label: 'Married Filing Separately' },
{ value: 'head_of_household', label: 'Head of Household' },
];
const US_STATES = [
'AL','AK','AZ','AR','CA','CO','CT','DE','FL','GA','HI','ID','IL','IN','IA',
'KS','KY','LA','ME','MD','MA','MI','MN','MS','MO','MT','NE','NV','NH','NJ',
'NM','NY','NC','ND','OH','OK','OR','PA','RI','SC','SD','TN','TX','UT','VT',
'VA','WA','WV','WI','WY','DC',
];
function formatCurrency(n: number): string {
return n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
}
function formatMonths(m: number): string {
const years = Math.floor(m / 12);
const months = m % 12;
if (years === 0) return `${months}mo`;
if (months === 0) return `${years}yr`;
return `${years}yr ${months}mo`;
}
export default function CalculatorPage() {
const [inputs, setInputs] = useState<LoanInputs>({
totalBalance: 35000,
weightedRate: 0.055,
agi: 45000,
familySize: 1,
filingStatus: 'single',
state: 'CA',
});
const [result, setResult] = useState<ComparisonResult | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [expandedPlan, setExpandedPlan] = useState<string | null>(null);
function handleCalculate() {
const comparison = compareAllPlans(inputs);
setResult(comparison);
}
function updateInput<K extends keyof LoanInputs>(key: K, value: LoanInputs[K]) {
setInputs((prev) => ({ ...prev, [key]: value }));
setResult(null); // Clear results when inputs change
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Header */}
<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-teal-500/10 text-teal-600">
<Calculator className="w-5 h-5" />
</div>
<h1 className="text-3xl font-bold">Repayment Plan Comparison</h1>
</div>
<p className="text-gray-600 text-lg leading-relaxed">
Compare SAVE, IBR, PAYE, ICR, and Standard plans side-by-side.
All calculations run in your browser — nothing is sent to a server.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Input Form */}
<div className="lg:col-span-1">
<div className="bg-white rounded-xl border border-gray-200 p-6 sticky top-6">
<h2 className="text-lg font-semibold mb-4">Your Information</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Total 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) => updateInput('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-teal-500 focus:border-teal-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) => updateInput('weightedRate', Number(e.target.value) / 100)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-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) => updateInput('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-teal-500 focus:border-teal-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) => updateInput('familySize', Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Filing Status
</label>
<select
value={inputs.filingStatus}
onChange={(e) => updateInput('filingStatus', e.target.value as FilingStatus)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
>
{FILING_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">State</label>
<select
value={inputs.state}
onChange={(e) => updateInput('state', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
>
{US_STATES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
{/* Advanced Toggle */}
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1 text-sm text-teal-600 hover:text-teal-700"
>
{showAdvanced ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
Advanced Options
</button>
{showAdvanced && (
<div className="space-y-4 pt-2 border-t border-gray-100">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Spouse Income (if MFJ)
</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.spouseIncome || 0}
onChange={(e) => updateInput('spouseIncome', Number(e.target.value))}
className="w-full pl-7 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
/>
</div>
</div>
</div>
)}
</div>
<button
onClick={handleCalculate}
className="w-full mt-6 px-6 py-3 bg-teal-500 hover:bg-teal-600 text-white font-semibold rounded-lg transition-colors"
>
Compare Plans
</button>
</div>
</div>
{/* Results */}
<div className="lg:col-span-2">
{!result ? (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<Calculator className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500 text-lg">
Enter your loan details and click “Compare Plans” to see results.
</p>
</div>
) : (
<div className="space-y-6">
{/* Recommendations */}
<div className="bg-teal-50 border border-teal-200 rounded-xl p-6">
<h3 className="font-semibold text-teal-800 mb-3">Recommendations</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm">
<div>
<div className="text-teal-600 font-medium">Lowest Monthly</div>
<div className="text-teal-900 font-bold">{result.lowestMonthly}</div>
</div>
<div>
<div className="text-teal-600 font-medium">Lowest Total Cost</div>
<div className="text-teal-900 font-bold">{result.recommended}</div>
</div>
<div>
<div className="text-teal-600 font-medium">Fastest Payoff</div>
<div className="text-teal-900 font-bold">{result.fastestPayoff}</div>
</div>
</div>
</div>
{/* Plan Cards */}
{result.plans.map((plan) => {
const isExpanded = expandedPlan === plan.planName;
const isRecommended = plan.planName === result.recommended;
return (
<div
key={plan.planName}
className={`bg-white rounded-xl border ${
isRecommended ? 'border-teal-300 ring-1 ring-teal-200' : 'border-gray-200'
} overflow-hidden`}
>
<div className="p-6">
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">{plan.planName}</h3>
{isRecommended && (
<span className="px-2 py-0.5 bg-teal-100 text-teal-700 text-xs font-medium rounded-full">
Recommended
</span>
)}
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-[#0f1b2d]">
{formatCurrency(plan.monthlyPayment)}
</div>
<div className="text-sm text-gray-500">per month</div>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
<div>
<div className="text-gray-500">Total Paid</div>
<div className="font-semibold">{formatCurrency(plan.totalPaid)}</div>
</div>
<div>
<div className="text-gray-500">Total Interest</div>
<div className="font-semibold">{formatCurrency(plan.totalInterest)}</div>
</div>
<div>
<div className="text-gray-500">Forgiven</div>
<div className="font-semibold">
{plan.forgivenessAmount > 0
? formatCurrency(plan.forgivenessAmount)
: '—'}
</div>
</div>
<div>
<div className="text-gray-500">Timeline</div>
<div className="font-semibold">
{formatMonths(plan.repaymentMonths)}
</div>
</div>
</div>
{/* Expand/Collapse */}
<button
onClick={() => setExpandedPlan(isExpanded ? null : plan.planName)}
className="mt-4 flex items-center gap-1 text-sm text-teal-600 hover:text-teal-700"
>
<Info className="w-4 h-4" />
{isExpanded ? 'Hide details' : 'How we calculated this'}
</button>
</div>
{isExpanded && (
<div className="border-t border-gray-100 bg-gray-50 p-6 text-sm">
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<span className="text-gray-500">Poverty Guideline:</span>{' '}
<span className="font-medium">{formatCurrency(plan.povertyGuideline)}</span>
</div>
<div>
<span className="text-gray-500">Discretionary Income:</span>{' '}
<span className="font-medium">{formatCurrency(plan.discretionaryIncome)}</span>
</div>
</div>
{plan.forgivenessMonths > 0 && (
<p className="text-gray-600">
After {formatMonths(plan.forgivenessMonths)} of qualifying payments,
remaining balance of {formatCurrency(plan.forgivenessAmount)} would be
forgiven. Note: forgiven amounts may be taxable income (except PSLF).
</p>
)}
</div>
)}
</div>
);
})}
{/* Disclaimer */}
<div className="text-xs text-gray-400 text-center mt-8 space-y-1">
<p>
Estimates are for informational purposes only. Actual payments depend on
your specific loan terms, servicer, and certification.
</p>
<p>
Not affiliated with the U.S. Department of Education. Not legal or financial advice.
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}