diff --git a/app/interactives/mortgage-refinancing-calculator-v2/page.tsx b/app/interactives/mortgage-refinancing-calculator-v2/page.tsx new file mode 100644 index 0000000..3a5b2f3 --- /dev/null +++ b/app/interactives/mortgage-refinancing-calculator-v2/page.tsx @@ -0,0 +1,832 @@ +"use client" + +import { useState, useEffect } from "react" +import { Card, CardContent, CardDescription, CardHeader } from "@/app/ui/components/card" +import { Label } from "@/app/ui/components/label" +import { Input } from "@/app/ui/components/input" +import { Button } from "@/app/ui/components/button" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/ui/components/tabs" +import ThemeToggle from "@/app/lib/theme-toggle" +import { FaRotateLeft, FaArrowRight, FaArrowLeft, FaCircleInfo } from "react-icons/fa6" + +const formatCurrency = (amount: number) => + amount.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + +export default function MortgageCalculator() { + const [activeTab, setActiveTab] = useState("current-balance") + + const [monthsRemaining, setMonthsRemaining] = useState("") + const [annualRate, setAnnualRate] = useState("") + const [monthlyPayment, setMonthlyPayment] = useState("") + const [currentBalance, setCurrentBalance] = useState(null) + + const [refCurrentBalance, setRefCurrentBalance] = useState("") + const [refCurrentMonthlyPayment, setRefCurrentMonthlyPayment] = useState("") + const [refCurrentMonths, setRefCurrentMonths] = useState("") + const [refCurrentRate, setRefCurrentRate] = useState("") + const [refNewLoanAmount, setRefNewLoanAmount] = useState("") + const [refNewRate, setRefNewRate] = useState("") + const [refNewMonths, setRefNewMonths] = useState("") + const [refClosingCosts, setRefClosingCosts] = useState("") + const [refYearsIn, setRefYearsIn] = useState("") + const [refinanceResults, setRefinanceResults] = useState<{ + currentMonthlyPayment: number + newMonthlyPayment: number + monthlySavings: number + totalCurrentCost: number + totalNewCost: number + totalSavings: number + breakEvenMonths: number + breakEvenMessage: string + } | null>(null) + + const [refErrors, setRefErrors] = useState<{ + newLoanAmount?: string + newRate?: string + newMonths?: string + }>({}) + + // ── Live balance calculation ────────────────────────────────────────────── + useEffect(() => { + const months = Number.parseFloat(monthsRemaining) + const rate = Number.parseFloat(annualRate) / 100 / 12 + const payment = Number.parseFloat(monthlyPayment) + + if ( + !monthsRemaining || !annualRate || !monthlyPayment || + isNaN(months) || isNaN(rate) || isNaN(payment) || + months <= 0 || rate < 0 || payment <= 0 + ) { + setCurrentBalance(null) + return + } + + const balance = rate === 0 + ? payment * months + : payment * ((1 - Math.pow(1 + rate, -months)) / rate) + + setCurrentBalance(balance) + // Keep refinance tab pre-filled in sync + setRefCurrentBalance(balance.toFixed(2)) + setRefCurrentMonths(monthsRemaining) + setRefCurrentRate(annualRate) + setRefCurrentMonthlyPayment(monthlyPayment) + }, [monthsRemaining, annualRate, monthlyPayment]) + + const handleReset = () => { + setCurrentBalance(null) + setMonthsRemaining("") + setAnnualRate("") + setMonthlyPayment("") + setRefCurrentBalance("") + setRefCurrentMonths("") + setRefCurrentRate("") + setRefCurrentMonthlyPayment("") + } + + const calculateRefinance = () => { + const errors: { + newLoanAmount?: string; + newRate?: string; + newMonths?: string; + } = {}; + + const currentBal = Number.parseFloat(refCurrentBalance); + const currentR = Number.parseFloat(refCurrentRate) / 100 / 12; + const currentM = Number.parseFloat(refCurrentMonths); + const newR = Number.parseFloat(refNewRate) / 100 / 12; + const newM = Number.parseFloat(refNewMonths); + const closingCosts = Number.parseFloat(refClosingCosts) || 0; + const newLoanAmount = Number.parseFloat(refNewLoanAmount); + + if (!refNewLoanAmount || isNaN(newLoanAmount) || newLoanAmount <= 0) + errors.newLoanAmount = "Please enter a valid loan amount greater than 0."; + if (!refNewRate || isNaN(newR) || newR < 0) + errors.newRate = "Please enter a valid interest rate."; + if (!refNewMonths || isNaN(newM) || newM <= 0) + errors.newMonths = "Please enter a valid loan term greater than 0."; + + setRefErrors(errors); + + if (Object.keys(errors).length > 0) return; + + const currentMonthlyPayment = currentR === 0 + ? currentBal / currentM + : (currentBal * (currentR * Math.pow(1 + currentR, currentM))) / (Math.pow(1 + currentR, currentM) - 1) + + const newMonthlyPayment = newR === 0 + ? newLoanAmount / newM + : (newLoanAmount * (newR * Math.pow(1 + newR, newM))) / (Math.pow(1 + newR, newM) - 1) + + const monthlySavings = currentMonthlyPayment - newMonthlyPayment + + const monthsInHouse = refYearsIn ? Number.parseFloat(refYearsIn) * 12 : 0 + + if (monthsInHouse > 0) { + // ── Planned-stay comparison ───────────────────────────────── + // Months actually used for each loan (can't exceed the loan term) + const currentMonthsToUse = Math.min(monthsInHouse, currentM) + const newMonthsToUse = Math.min(monthsInHouse, newM) + + // Helper: remaining principal after n payments + const remainingBalance = ( + principal: number, + monthlyRate: number, + termMonths: number, + paymentsMade: number, + ) => { + if (monthlyRate === 0) + return Math.max( + 0, + principal - (principal / termMonths) * paymentsMade, + ); + const payment = + (principal * (monthlyRate * Math.pow(1 + monthlyRate, termMonths))) / + (Math.pow(1 + monthlyRate, termMonths) - 1); + return Math.max( + 0, + principal * Math.pow(1 + monthlyRate, paymentsMade) - + payment * + ((Math.pow(1 + monthlyRate, paymentsMade) - 1) / monthlyRate), + ); + }; + + // Total paid out of pocket over the planned stay + const totalCurrentPaid = currentMonthlyPayment * currentMonthsToUse + const totalNewPaid = newMonthlyPayment * newMonthsToUse + closingCosts + + // Remaining principal at point of sale — higher balance = more owed at sale + const currentRemainingBal = remainingBalance(currentBal, currentR, currentM, currentMonthsToUse) + const newRemainingBal = remainingBalance(newLoanAmount, newR, newM, newMonthsToUse) + + // Net cost = what you paid + what you still owe at sale + // Lower net cost = better deal + const currentNetCost = totalCurrentPaid + currentRemainingBal + const newNetCost = totalNewPaid + newRemainingBal + + const totalSavings = currentNetCost - newNetCost + + let breakEvenMonths: number + let breakEvenMessage: string + + if (monthlySavings > 0) { + breakEvenMonths = closingCosts > 0 ? closingCosts / monthlySavings : 0 + breakEvenMessage = closingCosts > 0 + ? `You'll recover closing costs in ${breakEvenMonths.toFixed(1)} months` + : "No closing costs to recover - immediate savings!" + } else if (monthlySavings < 0) { + breakEvenMonths = closingCosts > 0 ? closingCosts / (-monthlySavings) : 0 + breakEvenMessage = closingCosts > 0 + ? `Despite higher monthly payments, you'll save overall if you stay ${breakEvenMonths.toFixed(1)}+ months` + : "Despite higher monthly payments, you save overall on total interest" + } else { + breakEvenMonths = totalSavings > 0 ? 0 : Infinity + breakEvenMessage = totalSavings > 0 + ? "Same monthly payment, but you save on total interest" + : "No financial benefit" + } + + setRefinanceResults({ + currentMonthlyPayment, + newMonthlyPayment, + monthlySavings, + totalCurrentCost: currentNetCost, + totalNewCost: newNetCost, + totalSavings, + breakEvenMonths, + breakEvenMessage, + }) + + } else { + // ── Full-term comparison (no planned stay entered) ────────── + const totalCurrentCost = currentMonthlyPayment * currentM + const totalNewCost = newMonthlyPayment * newM + closingCosts + const totalSavings = totalCurrentCost - totalNewCost + + let breakEvenMonths: number + let breakEvenMessage: string + + if (monthlySavings > 0) { + breakEvenMonths = closingCosts > 0 ? closingCosts / monthlySavings : 0 + breakEvenMessage = closingCosts > 0 + ? `You'll recover closing costs in ${breakEvenMonths.toFixed(1)} months` + : "No closing costs to recover - immediate savings!" + } else if (monthlySavings < 0) { + if (totalSavings > 0) { + breakEvenMonths = closingCosts > 0 ? closingCosts / (-monthlySavings) : 0 + breakEvenMessage = closingCosts > 0 + ? `Despite higher monthly payments, you'll save overall if you stay ${breakEvenMonths.toFixed(1)}+ months` + : "Despite higher monthly payments, you save overall on total interest" + } else { + breakEvenMonths = Infinity + breakEvenMessage = "This refinance costs more overall - not recommended" + } + } else { + breakEvenMonths = totalSavings > 0 ? 0 : Infinity + breakEvenMessage = totalSavings > 0 + ? "Same monthly payment, but you save on total interest" + : "No financial benefit" + } + + setRefinanceResults({ + currentMonthlyPayment, + newMonthlyPayment, + monthlySavings, + totalCurrentCost, + totalNewCost, + totalSavings, + breakEvenMonths, + breakEvenMessage, + }) + } + } + + return ( +
+
+ +

Mortgage Calculator Suite

+
+ + + + Current Balance + + + Refinance Analysis + + + + {/* ── Tab 1: Current Balance ─────────────────────────────────── */} + + + +
+ {/* Left — inputs */} +
+

+ Calculate your remaining mortgage balance (the present + value of your remaining monthly mortgage payments). +

+ +
+ +
+ setMonthsRemaining(e.target.value)} + min="0" + step="1" + className="pr-16 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> + {monthsRemaining && ( + + months + + )} +
+ {monthsRemaining && ( +

+ {monthsRemaining} months ={" "} + {(Number.parseFloat(monthsRemaining) / 12).toFixed( + 1, + )}{" "} + years +

+ )} +
+ +
+
+ +
+ + +
+
+
+ setAnnualRate(e.target.value)} + min="0" + step="0.1" + className="pr-8 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> + {annualRate && ( + + % + + )} +
+
+ +
+ +
+ setMonthlyPayment(e.target.value)} + min="0" + step="0.01" + autoComplete="off" + data-lpignore="true" // LastPass + data-1p-ignore // 1Password + data-bwignore // Bitwarden + data-form-type="other" // Dashlane specifically + className="w-full pl-8 pr-4 py-3 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> + $ +
+
+ + {/* Reset — only visible once there's a result */} + {currentBalance !== null && ( + + )} +
+ + {/* Right — live result or empty state */} +
+ {currentBalance !== null ? ( + <> +

+ Estimated current balance +

+

+ ${formatCurrency(currentBalance)} +

+

+ Based on the remaining monthly payments +

+ + + ) : ( + <> +

+ Your current mortgage balance will appear here. +

+

+ Enter your loan details to see your estimated + remaining balance. +

+ + )} +
+
+
+
+
+ + {/* ── Tab 2: Refinance Analysis ──────────────────────────────── */} + + + +

+ Analyze if refinancing makes financial sense for your + situation. +

+ {!currentBalance ? ( +
+

+ Start by calculating your current mortgage balance + first. Enter your details in the Current Balance tab, + then come back here to explore refinance options. + +

+
+ ) : null} + +
+ {/* ── Left column ─────────────────────────────────────────── */} +
+ {/* Current Loan Terms — read-only */} +
+

+ Current Loan Terms +

+ {currentBalance ? ( +
+
+
+
+ Current balance: +
+
+ ${formatCurrency(currentBalance)} +
+
+
+
+ Time remaining: +
+
+ {refCurrentMonths} months + {refCurrentMonths && + ` (${(Number.parseFloat(refCurrentMonths) / 12).toFixed(1)} years)`} +
+
+
+
+ Interest rate: +
+
+ {refCurrentRate}% +
+
+
+
+ Monthly payment: +
+
+ ${refCurrentMonthlyPayment} +
+
+
+ +
+
+
+ ) : ( +
+

+ No balance calculated yet. +

+
+ )} +
+ + {/* New Loan Terms — inputs */} +
+

+ New Loan Terms +

+ +
+ +
+ + setRefNewLoanAmount(e.target.value) + } + min="0" + step="0.01" + className={`[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${refNewLoanAmount ? "pl-8" : "pl-8 pr-4"} ${refErrors.newLoanAmount ? "border-error border-2" : ""}`} + /> + $ +
+ {refErrors.newLoanAmount && ( +

+ {refErrors.newLoanAmount} +

+ )} +
+ +
+ +
+ setRefNewMonths(e.target.value)} + min="0" + step="1" + className={`pr-16 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${refErrors.newMonths ? "border-error border-2" : ""}`} + /> + + months + +
+ {refNewMonths && ( +

+ {refNewMonths} months ={" "} + {(Number.parseFloat(refNewMonths) / 12).toFixed( + 1, + )}{" "} + years +

+ )} + {refErrors.newMonths && ( +

+ {refErrors.newMonths} +

+ )} +
+ +
+ +
+ setRefNewRate(e.target.value)} + min="0" + step="0.1" + className={`pr-8 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${refErrors.newRate ? "border-error border-2" : ""}`} + /> + {refNewRate && ( + + % + + )} +
+ {refErrors.newRate && ( +

+ {refErrors.newRate} +

+ )} +
+ +
+ +
+ +
+ {refClosingCosts && ( + + $ + + )} + + setRefClosingCosts(e.target.value) + } + min="0" + step="0.01" + className={`[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${refClosingCosts ? "pl-6" : ""}`} + /> +
+
+ +
+ +
+ setRefYearsIn(e.target.value)} + min="0" + step="1" + className="pr-14 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> + {refYearsIn && ( + + years + + )} +
+
+ + {/* Action buttons */} + {refinanceResults ? ( +
+ + +
+ ) : ( + + )} +
+
+ + {/* ── Right column — results or empty state ───────────────── */} +
+ {refinanceResults ? ( +
+
+ {refinanceResults.totalSavings >= 0 ? ( + <> +

+ Refinancing may be worth it +

+

+ You could save money over the time you plan to + stay in the home. +

+ + ) : ( + <> +

+ Refinancing may not be worth it +

+

+ Based on the planned time in the home, this + refinance may cost more than staying with the + current loan. +

+ + )} +
+ +
+
+
+
+ New monthly payment: +
+
+ $ + {formatCurrency( + refinanceResults.newMonthlyPayment, + )} +
+
+ +
+
+ {refinanceResults.monthlySavings >= 0 + ? "Monthly savings:" + : "Monthly increase:"} +
+
+ $ + {formatCurrency( + Math.abs(refinanceResults.monthlySavings), + )} +
+
+ +
+
+ Closing costs & fees: +
+
+ $ + {formatCurrency( + Number.parseFloat(refClosingCosts) || 0, + )} +
+
+ +
+
+ {refinanceResults.totalSavings >= 0 + ? "Estimated savings over planned stay:" + : "Estimated total cost difference:"} +
+
+ $ + {formatCurrency( + Math.abs(refinanceResults.totalSavings), + )} +
+
+
+
+
+ ) : ( +
+

+ Your refinance analysis will appear here. +

+

Enter your new loan details to compare options.

+
+ )} +
+
+ + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/app/ui/globals.css b/app/ui/globals.css index f0c916f..56dce2f 100644 --- a/app/ui/globals.css +++ b/app/ui/globals.css @@ -45,6 +45,7 @@ --color-sky-dark: rgba(14, 59, 79, 1); --color-berry: rgba(195, 31, 112, 1); --color-berry-light: rgba(255, 237, 246, 1); + --color-error: #8C1515; --color-palo-verde: rgba(39, 153, 137, 1); --color-palo-verde-light: rgba(239, 247, 239, 1); --color-lagunita: rgba(0, 124, 146, 1); @@ -57,7 +58,6 @@ --color-grey-light: rgba(251, 251, 255, 1); --color-grey-border: rgba(207, 207, 207, 1); --color-pinecone: rgba(233, 255, 251, 1); - --color-badge-green: rgba(135, 205, 168, 1); --color-badge-yellow: rgba(253, 225, 131, 1); --color-badge-red: rgba(249, 80, 72, 1); @@ -90,6 +90,7 @@ --results-blue-background: rgba(255, 255, 255, 1); --results-white-background: rgba(255, 255, 255, 1); --results-year-background: rgba(233, 255, 251, 1); + --results-card-grey-background: rgba(248, 248, 248, 1); --year-by-year-table: rgba(246, 246, 246, 1); --year-by-year-table-line: rgba(200, 200, 200, 1); --accent: oklch(0.97 0 0); @@ -119,6 +120,8 @@ --foreground: oklch(0.145 0 0); --button-green: rgba(30, 113, 102, 1); --button-berry: rgba(151, 24, 87, 1); + --results-card-empty: rgba(102, 112, 133, 1); + --info-popup-background: rgba(239, 255, 252, 1); } .dark { @@ -142,6 +145,7 @@ --results-blue-background: rgba(25, 47, 57, 1); --results-white-background: oklch(0.145 0 0); --results-year-background: oklch(0.145 0 0); + --results-card-grey-background: oklch(0.145 0 0); --year-by-year-table: oklch(0.145 0 0); --year-by-year-table-line: rgba(255, 255, 255, 1); --muted-foreground: oklch(0.708 0 0); @@ -171,6 +175,8 @@ --additional-background: rgb(52, 51, 51); --button-green: rgba(30, 113, 102, 1); --button-berry: rgba(151, 24, 87, 1); + --results-card-empty: oklch(0.985 0 0); + --info-popup-background: rgba(239, 255, 252, 1); } @layer base {