diff --git a/src/app/components/FireCalculatorForm.tsx b/src/app/components/FireCalculatorForm.tsx index 85f8580..7f8a5df 100644 --- a/src/app/components/FireCalculatorForm.tsx +++ b/src/app/components/FireCalculatorForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; @@ -22,11 +22,14 @@ import { import { Slider } from '@/components/ui/slider'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent'; -import { Calculator, Info, Percent } from 'lucide-react'; +import { Calculator, Info, Percent, Share2, Check } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import BlurThing from './blur-thing'; import Link from 'next/link'; +import type { FireCalculatorFormValues } from '@/lib/calculator-schema'; +import { fireCalculatorDefaultValues, fireCalculatorFormSchema } from '@/lib/calculator-schema'; + // Helper component for info tooltips next to form labels function InfoTooltip({ content }: Readonly<{ content: string }>) { return ( @@ -41,39 +44,8 @@ function InfoTooltip({ content }: Readonly<{ content: string }>) { ); } -// Schema for form validation -const formSchema = z.object({ - startingCapital: z.coerce.number(), - monthlySavings: z.coerce.number().min(0, 'Monthly savings must be a non-negative number'), - currentAge: z.coerce - .number() - .min(1, 'Age must be at least 1') - .max(100, 'No point in starting this late'), - cagr: z.coerce.number().min(0, 'Growth rate must be a non-negative number'), - desiredMonthlyAllowance: z.coerce.number().min(0, 'Monthly allowance must be a non-negative number'), - inflationRate: z.coerce.number().min(0, 'Inflation rate must be a non-negative number'), - lifeExpectancy: z.coerce - .number() - .min(40, 'Be a bit more optimistic buddy :(') - .max(100, 'You should be more realistic...'), - retirementAge: z.coerce - .number() - .min(18, 'Retirement age must be at least 18') - .max(100, 'Retirement age must be at most 100'), - coastFireAge: z.coerce - .number() - .min(18, 'Coast FIRE age must be at least 18') - .max(100, 'Coast FIRE age must be at most 100') - .optional(), - baristaIncome: z.coerce.number().min(0, 'Barista income must be a non-negative number').optional(), - simulationMode: z.enum(['deterministic', 'monte-carlo']).default('deterministic'), - volatility: z.coerce.number().min(0).default(15), - withdrawalStrategy: z.enum(['fixed', 'percentage']).default('fixed'), - withdrawalPercentage: z.coerce.number().min(0).max(100).default(4), -}); - -// Type for form values -type FormValues = z.infer; +const formSchema = fireCalculatorFormSchema; +type FormValues = FireCalculatorFormValues; interface YearlyData { age: number; @@ -138,30 +110,22 @@ const tooltipRenderer = ({ active, payload }: TooltipProps) return null; }; -export default function FireCalculatorForm() { +export default function FireCalculatorForm({ + initialValues, + autoCalculate = false, +}: { + initialValues?: Partial; + autoCalculate?: boolean; +}) { const [result, setResult] = useState(null); const irlYear = new Date().getFullYear(); const [showing4percent, setShowing4percent] = useState(false); + const [copied, setCopied] = useState(false); // Initialize form with default values const form = useForm, undefined, FormValues>({ resolver: zodResolver(formSchema), - defaultValues: { - startingCapital: 50000, - monthlySavings: 1500, - currentAge: 25, - cagr: 7, - desiredMonthlyAllowance: 3000, - inflationRate: 2.3, - lifeExpectancy: 84, - retirementAge: 55, - coastFireAge: undefined, - baristaIncome: 0, - simulationMode: 'deterministic', - volatility: 15, - withdrawalStrategy: 'fixed', - withdrawalPercentage: 4, - }, + defaultValues: initialValues ?? fireCalculatorDefaultValues, }); function onSubmit(values: FormValues) { @@ -350,6 +314,55 @@ export default function FireCalculatorForm() { } } + // Use effect for auto-calculation + useEffect(() => { + if (autoCalculate && !result) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + form.handleSubmit(onSubmit)(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoCalculate]); + + const handleShare = () => { + const values = form.getValues(); + const params = new URLSearchParams(); + + if (values.startingCapital !== undefined && values.startingCapital !== null) + params.set('startingCapital', String(values.startingCapital)); + if (values.monthlySavings !== undefined && values.monthlySavings !== null) + params.set('monthlySavings', String(values.monthlySavings)); + if (values.currentAge !== undefined && values.currentAge !== null) + params.set('currentAge', String(values.currentAge)); + if (values.cagr !== undefined && values.cagr !== null) params.set('cagr', String(values.cagr)); + if (values.desiredMonthlyAllowance !== undefined && values.desiredMonthlyAllowance !== null) + params.set('monthlySpend', String(values.desiredMonthlyAllowance)); + if (values.inflationRate !== undefined && values.inflationRate !== null) + params.set('inflationRate', String(values.inflationRate)); + if (values.lifeExpectancy !== undefined && values.lifeExpectancy !== null) + params.set('lifeExpectancy', String(values.lifeExpectancy)); + if (values.retirementAge !== undefined && values.retirementAge !== null) + params.set('retirementAge', String(values.retirementAge)); + if (values.coastFireAge !== undefined && values.coastFireAge !== null) + params.set('coastFireAge', String(values.coastFireAge)); + if (values.baristaIncome !== undefined && values.baristaIncome !== null) + params.set('baristaIncome', String(values.baristaIncome)); + if (values.simulationMode) params.set('simulationMode', values.simulationMode); + if (values.volatility !== undefined && values.volatility !== null) + params.set('volatility', String(values.volatility)); + if (values.withdrawalStrategy) params.set('withdrawalStrategy', values.withdrawalStrategy); + if (values.withdrawalPercentage !== undefined && values.withdrawalPercentage !== null) + params.set('withdrawalPercentage', String(values.withdrawalPercentage)); + + const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`; + // eslint-disable-next-line @typescript-eslint/no-floating-promises + navigator.clipboard.writeText(url).then(() => { + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 4000); + }); + }; + return ( <> @@ -959,17 +972,30 @@ export default function FireCalculatorForm() { )} {result && ( - +
+ + +
)} diff --git a/src/app/learn/page.tsx b/src/app/learn/page.tsx index c966b77..e56a969 100644 --- a/src/app/learn/page.tsx +++ b/src/app/learn/page.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import BlurThing from '../components/blur-thing'; +import { RETIRE_AT_AGE_PRESETS } from '@/lib/retire-at'; export const metadata = { title: 'Learn FIRE | Financial Independence Guides & Resources', @@ -8,6 +9,8 @@ export const metadata = { 'Master the art of Financial Independence and Early Retirement. Deep dives into safe withdrawal rates, asset allocation, and FIRE strategies.', }; +const retireAgeLinks = RETIRE_AT_AGE_PRESETS; + export default function LearnHubPage() { return (
@@ -107,8 +110,8 @@ export default function LearnHubPage() {

- Build a world-allocation portfolio, avoid home bias, and choose the right accounts whether - you're in the US, EU, UK, Canada, Australia, or elsewhere. + Build a world-allocation portfolio, avoid home bias, and choose the right accounts + whether you're in the US, EU, UK, Canada, Australia, or elsewhere.

@@ -128,14 +131,41 @@ export default function LearnHubPage() {

- Understand the hidden risks of overweighting your domestic market and learn practical steps - to diversify globally without creating tax headaches. + Understand the hidden risks of overweighting your domestic market and learn practical + steps to diversify globally without creating tax headaches.

+
+
+

Retire By Age

+

+ See exactly how much you need to retire at different ages, backed by the calculator. +

+
+
+ {retireAgeLinks.map((age) => ( + + + + Retire at {age} + + How much to save, what to invest, and what to tweak for age {age}. + + + + + ))} +
+
+

Ready to see the numbers?

diff --git a/src/app/learn/retire-at/[age]/page.tsx b/src/app/learn/retire-at/[age]/page.tsx new file mode 100644 index 0000000..996a114 --- /dev/null +++ b/src/app/learn/retire-at/[age]/page.tsx @@ -0,0 +1,279 @@ +import Link from 'next/link'; +import type { Metadata } from 'next'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { FaqSection, type FaqItem } from '@/app/components/FaqSection'; +import { + RETIRE_AT_AGE_PRESETS, + buildSpendScenarios, + calculateNestEggFromSpend, + extractCalculatorValuesFromSearch, + parseAgeParam, +} from '@/lib/retire-at'; +import { BASE_URL } from '@/lib/constants'; + +export const dynamic = 'force-static'; +export const dynamicParams = false; + +interface RetireAtPageProps { + params: Promise<{ age: string }>; + searchParams?: Promise>; +} + +const currencyFormatter = new Intl.NumberFormat('en', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, +}); + +const faqForAge = (age: number): FaqItem[] => { + const ageLabel = age.toString(); + return [ + { + question: `How much do I need to retire at ${ageLabel}?`, + answer: + 'A quick rule is your desired annual spending divided by a safe withdrawal rate. Using 4%, multiply your yearly spend by 25. Spending $60k/year means roughly $1.5M. Use the calculator below to tailor the projection to your own savings, growth, and inflation assumptions.', + }, + { + question: `What savings rate should I target to retire at ${ageLabel}?`, + answer: + 'Aim for a 40–60% savings rate if you want to retire in 10–15 years. The exact rate depends on your starting capital, investment returns, and spending goal. Slide the monthly savings input to see how it moves your FIRE number and timeline.', + }, + { + question: 'Is the 4% rule safe for this timeline?', + answer: + 'The 4% rule is a starting point, not a guarantee. Consider 3.5–4% for longer retirements or higher inflation periods. The calculator supports both fixed and percentage-based withdrawals so you can stress-test more conservative plans.', + }, + { + question: 'What if markets underperform?', + answer: + 'Use a lower CAGR (e.g., 5–6%) and a higher inflation rate (e.g., 3%) in the calculator. Switch to Monte Carlo mode to see success probabilities with volatility. Also build flexibility into spending: trimming costs in bad years greatly improves durability.', + }, + ]; +}; + +export const generateStaticParams = () => + RETIRE_AT_AGE_PRESETS.map((age) => ({ + age: age.toString(), + })); + +export const generateMetadata = async ({ params }: RetireAtPageProps): Promise => { + const { age: slugAge } = await params; + const age = parseAgeParam(slugAge); + const ageLabel = age.toString(); + const title = `How Much Do You Need to Retire at ${ageLabel}? | InvestingFIRE`; + const description = `Instant answer plus calculator: see how much you need saved to retire at ${ageLabel}, modeled with your spending, returns, and inflation assumptions.`; + const canonical = `${BASE_URL.replace(/\/$/, '')}/learn/retire-at/${ageLabel}`; + + return { + title, + description, + alternates: { + canonical, + }, + openGraph: { + title, + description, + url: canonical, + type: 'article', + }, + }; +}; + +export default async function RetireAtPage({ params, searchParams }: RetireAtPageProps) { + const { age: slugAge } = await params; + const resolvedSearch = (await searchParams) ?? {}; + const age = parseAgeParam(slugAge); + const ageLabel = age.toString(); + const initialValues = extractCalculatorValuesFromSearch(resolvedSearch, age); + const monthlySpend = initialValues.desiredMonthlyAllowance ?? 4000; + const withdrawalRate = 0.04; + const quickNestEgg = calculateNestEggFromSpend(monthlySpend, withdrawalRate); + const scenarios = buildSpendScenarios(monthlySpend, withdrawalRate); + + const canonical = `${BASE_URL.replace(/\/$/, '')}/learn/retire-at/${ageLabel}`; + const jsonLd = { + '@context': 'https://schema.org', + '@type': 'Article', + headline: `How Much Do You Need to Retire at ${ageLabel}?`, + description: + 'Detailed guidance plus an interactive calculator showing exactly how much you need saved to retire at your target age.', + mainEntityOfPage: canonical, + datePublished: '2025-01-25', + dateModified: new Date().toISOString().split('T')[0], + publisher: { + '@type': 'Organization', + name: 'InvestingFIRE', + logo: { + '@type': 'ImageObject', + url: `${BASE_URL}apple-icon.png`, + }, + }, + }; + + const queryParams = new URLSearchParams(); + if (initialValues.currentAge) queryParams.set('currentAge', initialValues.currentAge.toString()); + queryParams.set('retirementAge', age.toString()); + queryParams.set('monthlySpend', monthlySpend.toString()); + if (initialValues.monthlySavings) + queryParams.set('monthlySavings', initialValues.monthlySavings.toString()); + if (initialValues.startingCapital) + queryParams.set('startingCapital', initialValues.startingCapital.toString()); + + return ( +

+