285 lines
9.7 KiB
TypeScript
285 lines
9.7 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback } from 'react'
|
|
import type { MortgageSimulatorConfig } from '@/lib/mortgage-types'
|
|
|
|
interface Props {
|
|
config: MortgageSimulatorConfig
|
|
onChange: (config: MortgageSimulatorConfig) => void
|
|
currentMonth: number
|
|
maxMonth: number
|
|
onMonthChange: (month: number) => void
|
|
playing: boolean
|
|
onPlayToggle: () => void
|
|
speed: number
|
|
onSpeedChange: (speed: number) => void
|
|
}
|
|
|
|
function formatCurrency(n: number): string {
|
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n)
|
|
}
|
|
|
|
export default function MortgageControls({
|
|
config, onChange, currentMonth, maxMonth,
|
|
onMonthChange, playing, onPlayToggle, speed, onSpeedChange,
|
|
}: Props) {
|
|
const update = useCallback((partial: Partial<MortgageSimulatorConfig>) => {
|
|
onChange({ ...config, ...partial })
|
|
}, [config, onChange])
|
|
|
|
const totalPrincipal = config.propertyValue * (1 - config.downPaymentPercent / 100)
|
|
const numTranches = Math.ceil(totalPrincipal / config.trancheSize)
|
|
|
|
const years = Math.floor(currentMonth / 12)
|
|
const months = currentMonth % 12
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5 text-sm">
|
|
{/* Property */}
|
|
<Section title="Property">
|
|
<Slider
|
|
label="Value"
|
|
value={config.propertyValue}
|
|
min={100_000} max={2_000_000} step={25_000}
|
|
format={formatCurrency}
|
|
onChange={v => update({ propertyValue: v })}
|
|
/>
|
|
<Slider
|
|
label="Down Payment"
|
|
value={config.downPaymentPercent}
|
|
min={0} max={50} step={5}
|
|
format={v => `${v}% (${formatCurrency(config.propertyValue * v / 100)})`}
|
|
onChange={v => update({ downPaymentPercent: v })}
|
|
/>
|
|
<div className="text-slate-400 text-xs mt-1">
|
|
Loan: {formatCurrency(totalPrincipal)}
|
|
</div>
|
|
</Section>
|
|
|
|
{/* Tranches */}
|
|
<Section title="Tranches">
|
|
<Slider
|
|
label="Size"
|
|
value={config.trancheSize}
|
|
min={1_000} max={25_000} step={1_000}
|
|
format={formatCurrency}
|
|
onChange={v => update({ trancheSize: v })}
|
|
/>
|
|
<div className="text-slate-400 text-xs mt-1">
|
|
= {numTranches} lenders
|
|
</div>
|
|
<Slider
|
|
label="Funded"
|
|
value={config.fundingPercent}
|
|
min={50} max={100} step={5}
|
|
format={v => `${v}% (${Math.round(numTranches * v / 100)} / ${numTranches})`}
|
|
onChange={v => update({ fundingPercent: v })}
|
|
/>
|
|
</Section>
|
|
|
|
{/* Rate */}
|
|
<Section title="Interest">
|
|
<Slider
|
|
label="Base Rate"
|
|
value={config.interestRate * 100}
|
|
min={1} max={12} step={0.25}
|
|
format={v => `${v.toFixed(2)}%`}
|
|
onChange={v => update({ interestRate: v / 100 })}
|
|
/>
|
|
<Slider
|
|
label="Rate Spread"
|
|
value={config.rateVariation * 100}
|
|
min={0} max={3} step={0.25}
|
|
format={v => v === 0 ? 'None' : `+/- ${v.toFixed(2)}%`}
|
|
onChange={v => update({ rateVariation: v / 100 })}
|
|
/>
|
|
</Section>
|
|
|
|
{/* Term & Tiers */}
|
|
<Section title="Lending Terms">
|
|
<Slider
|
|
label="Max Term"
|
|
value={config.termYears}
|
|
min={5} max={30} step={5}
|
|
format={v => `${v} years`}
|
|
onChange={v => update({ termYears: v })}
|
|
/>
|
|
<div className="flex items-center gap-2 mt-2 mb-2">
|
|
<button
|
|
onClick={() => update({ useVariableTerms: !config.useVariableTerms })}
|
|
className={`px-2 py-1 rounded text-xs transition-colors ${
|
|
config.useVariableTerms
|
|
? 'bg-emerald-600 text-white'
|
|
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
|
|
}`}
|
|
>
|
|
{config.useVariableTerms ? 'Variable Tiers' : 'Uniform Term'}
|
|
</button>
|
|
</div>
|
|
{config.useVariableTerms && (
|
|
<div className="space-y-2 mt-2">
|
|
{config.lendingTiers.map((tier, i) => (
|
|
<div key={tier.label} className="flex items-center gap-2 text-xs">
|
|
<span className="w-10 text-slate-300 font-mono">{tier.label}</span>
|
|
<span className="text-slate-400">{(tier.rate * 100).toFixed(1)}%</span>
|
|
<input
|
|
type="range"
|
|
min={0} max={50} step={5}
|
|
value={tier.allocation * 100}
|
|
onChange={e => {
|
|
const newTiers = [...config.lendingTiers]
|
|
newTiers[i] = { ...tier, allocation: Number(e.target.value) / 100 }
|
|
update({ lendingTiers: newTiers })
|
|
}}
|
|
className="flex-1 accent-emerald-500 h-1"
|
|
/>
|
|
<span className="w-8 text-right text-slate-400">{(tier.allocation * 100).toFixed(0)}%</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Section>
|
|
|
|
{/* Overpayment */}
|
|
<Section title="Overpayment">
|
|
<Slider
|
|
label="Extra / mo"
|
|
value={config.overpayment}
|
|
min={0} max={2000} step={50}
|
|
format={formatCurrency}
|
|
onChange={v => update({ overpayment: v })}
|
|
/>
|
|
{config.overpayment > 0 && (
|
|
<div className="flex gap-1 mt-2">
|
|
{(['extra_principal', 'community_fund', 'split'] as const).map(target => (
|
|
<button
|
|
key={target}
|
|
onClick={() => update({ overpaymentTarget: target })}
|
|
className={`px-2 py-1 rounded text-xs transition-colors ${
|
|
config.overpaymentTarget === target
|
|
? 'bg-emerald-600 text-white'
|
|
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
|
|
}`}
|
|
>
|
|
{target === 'extra_principal' ? 'Principal' : target === 'community_fund' ? 'Community' : 'Split'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Section>
|
|
|
|
{/* Reinvestment */}
|
|
<Section title="Reinvestment">
|
|
<Slider
|
|
label="Reinvestors"
|
|
value={config.reinvestorPercent}
|
|
min={0} max={80} step={10}
|
|
format={v => `${v}%`}
|
|
onChange={v => update({ reinvestorPercent: v })}
|
|
/>
|
|
{config.reinvestorPercent > 0 && (
|
|
<>
|
|
<div className="text-slate-400 text-xs mt-1 mb-2">
|
|
Relend rates:
|
|
</div>
|
|
<div className="flex gap-1">
|
|
{[[0.03, 0.05], [0.02, 0.04], [0.03], [0.05]].map((rates, i) => {
|
|
const label = rates.map(r => `${(r * 100).toFixed(0)}%`).join(' / ')
|
|
const isActive = JSON.stringify(config.reinvestmentRates) === JSON.stringify(rates)
|
|
return (
|
|
<button
|
|
key={i}
|
|
onClick={() => update({ reinvestmentRates: rates })}
|
|
className={`px-2 py-1 rounded text-xs transition-colors ${
|
|
isActive
|
|
? 'bg-purple-600 text-white'
|
|
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</Section>
|
|
|
|
{/* Timeline */}
|
|
<Section title="Timeline">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<button
|
|
onClick={onPlayToggle}
|
|
className="w-8 h-8 rounded bg-slate-700 hover:bg-slate-600 flex items-center justify-center text-lg transition-colors"
|
|
>
|
|
{playing ? '\u23F8' : '\u25B6'}
|
|
</button>
|
|
<button
|
|
onClick={() => onMonthChange(0)}
|
|
className="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-xs transition-colors"
|
|
>
|
|
Reset
|
|
</button>
|
|
<div className="flex gap-1 ml-auto">
|
|
{[1, 2, 4].map(s => (
|
|
<button
|
|
key={s}
|
|
onClick={() => onSpeedChange(s)}
|
|
className={`px-2 py-1 rounded text-xs transition-colors ${
|
|
speed === s ? 'bg-sky-600 text-white' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'
|
|
}`}
|
|
>
|
|
{s}x
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min={0} max={maxMonth}
|
|
value={currentMonth}
|
|
onChange={e => onMonthChange(Number(e.target.value))}
|
|
className="w-full accent-sky-500"
|
|
/>
|
|
<div className="text-slate-400 text-xs text-center mt-1">
|
|
Month {currentMonth} ({years}y {months}m)
|
|
</div>
|
|
</Section>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Sub-components ──────────────────────────────────────
|
|
|
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
return (
|
|
<div>
|
|
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">{title}</h3>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Slider({
|
|
label, value, min, max, step, format, onChange,
|
|
}: {
|
|
label: string; value: number; min: number; max: number; step: number
|
|
format: (v: number) => string; onChange: (v: number) => void
|
|
}) {
|
|
return (
|
|
<div className="mb-2">
|
|
<div className="flex justify-between mb-1">
|
|
<span className="text-slate-300">{label}</span>
|
|
<span className="text-white font-mono">{format(value)}</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min={min} max={max} step={step}
|
|
value={value}
|
|
onChange={e => onChange(Number(e.target.value))}
|
|
className="w-full accent-sky-500"
|
|
/>
|
|
</div>
|
|
)
|
|
}
|