Compare commits
21 Commits
renovate/r
...
886afab1ef
Author | SHA1 | Date | |
---|---|---|---|
886afab1ef | |||
0f6cd57f3d | |||
ffb8e8d506 | |||
c3867ccbd4 | |||
6bc7be6336 | |||
26d2ec68b8 | |||
5e0ff2891a | |||
1a0428a8e0 | |||
9267018d06 | |||
acec849428 | |||
1e9f2cbc2d | |||
9c460bab22 | |||
4e7705ce53 | |||
d5962bbf9e | |||
64669e5f58 | |||
f05f3fe37c | |||
896b0bf063 | |||
716bcc6fef | |||
31415c10a2 | |||
fe03807739 | |||
30d27a212e |
14
.env.example
Normal file
14
.env.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Since the ".env" file is gitignored, you can use the ".env.example" file to
|
||||||
|
# build a new ".env" file when you clone the repo. Keep this file up-to-date
|
||||||
|
# when you add new variables to `.env`.
|
||||||
|
|
||||||
|
# This file will be committed to version control, so make sure not to have any
|
||||||
|
# secrets in it. If you are cloning this repo, create a copy of this file named
|
||||||
|
# ".env" and populate it with your secrets.
|
||||||
|
|
||||||
|
# When adding additional environment variables, the schema in "/src/env.js"
|
||||||
|
# should be updated accordingly.
|
||||||
|
|
||||||
|
# Example:
|
||||||
|
# SERVERVAR="foo"
|
||||||
|
# NEXT_PUBLIC_CLIENTVAR="bar"
|
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*.png filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.ico filter=lfs diff=lfs merge=lfs -text
|
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# database
|
||||||
|
/prisma/db.sqlite
|
||||||
|
/prisma/db.sqlite-journal
|
||||||
|
db.sqlite
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# idea files
|
||||||
|
.idea
|
21
components.json
Normal file
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/styles/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
48
eslint.config.js
Normal file
48
eslint.config.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: import.meta.dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: [".next"],
|
||||||
|
},
|
||||||
|
...compat.extends("next/core-web-vitals"),
|
||||||
|
{
|
||||||
|
files: ["**/*.ts", "**/*.tsx"],
|
||||||
|
extends: [
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/array-type": "off",
|
||||||
|
"@typescript-eslint/consistent-type-definitions": "off",
|
||||||
|
"@typescript-eslint/consistent-type-imports": [
|
||||||
|
"warn",
|
||||||
|
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{ argsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
"@typescript-eslint/require-await": "off",
|
||||||
|
"@typescript-eslint/no-misused-promises": [
|
||||||
|
"error",
|
||||||
|
{ checksVoidReturn: { attributes: false } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
linterOptions: {
|
||||||
|
reportUnusedDisableDirectives: true,
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
10
next.config.js
Normal file
10
next.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
|
||||||
|
* for Docker builds.
|
||||||
|
*/
|
||||||
|
import "./src/env.js";
|
||||||
|
|
||||||
|
/** @type {import("next").NextConfig} */
|
||||||
|
const config = {};
|
||||||
|
|
||||||
|
export default config;
|
7266
package-lock.json
generated
Normal file
7266
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
package.json
Normal file
57
package.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "fire",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "next build",
|
||||||
|
"check": "next lint && tsc --noEmit",
|
||||||
|
"dev": "next dev --turbo",
|
||||||
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
|
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
|
"lint": "next lint",
|
||||||
|
"lint:fix": "next lint --fix",
|
||||||
|
"preview": "next build && next start",
|
||||||
|
"start": "next start",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.0.1",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.8",
|
||||||
|
"@radix-ui/react-label": "^2.1.4",
|
||||||
|
"@radix-ui/react-select": "^2.2.2",
|
||||||
|
"@radix-ui/react-slot": "^1.2.0",
|
||||||
|
"@t3-oss/env-nextjs": "^0.12.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.503.0",
|
||||||
|
"next": "^15.2.3",
|
||||||
|
"next-plausible": "^3.12.4",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-hook-form": "^7.56.1",
|
||||||
|
"recharts": "^2.15.3",
|
||||||
|
"tailwind-merge": "^3.2.0",
|
||||||
|
"zod": "^3.24.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
"@tailwindcss/postcss": "^4.0.15",
|
||||||
|
"@types/node": "^20.14.10",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"eslint": "^9.23.0",
|
||||||
|
"eslint-config-next": "^15.2.3",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
"tailwindcss": "^4.0.15",
|
||||||
|
"tw-animate-css": "^1.2.8",
|
||||||
|
"typescript": "^5.8.2",
|
||||||
|
"typescript-eslint": "^8.27.0"
|
||||||
|
},
|
||||||
|
"ct3aMetadata": {
|
||||||
|
"initVersion": "7.39.3"
|
||||||
|
},
|
||||||
|
"packageManager": "npm@11.2.0"
|
||||||
|
}
|
5
postcss.config.js
Normal file
5
postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
4
prettier.config.js
Normal file
4
prettier.config.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
||||||
|
export default {
|
||||||
|
plugins: ["prettier-plugin-tailwindcss"],
|
||||||
|
};
|
1
public/investingfire_logo_no-bg.svg
Normal file
1
public/investingfire_logo_no-bg.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" width="1000" height="1000" viewBox="0 0 264.58 264.58"><defs><linearGradient id="b"><stop offset="0" stop-color="#fd8315"/><stop offset="1" stop-color="#fa6b14"/></linearGradient><linearGradient id="a"><stop offset="0" stop-color="#f24b1b"/><stop offset="1" stop-color="#dc2f12"/></linearGradient><linearGradient xlink:href="#a" id="d" x1="172.49" x2="179.1" y1="64.48" y2="197.19" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#b" id="c" x1="118.9" x2="117.99" y1="14.21" y2="194.34" gradientUnits="userSpaceOnUse"/></defs><g stroke-linecap="round" stroke-linejoin="round" stroke-width=".13"><path fill="url(#c)" stroke="#f14a1b" d="m115.13 9.96-.26.01c-.97.11-1.29 1.02-.75 2.38a45.6 45.6 0 0 1 3.02 15.68c-.09 8.46.04 12.87-7.31 23.68s-23.16 21.9-33.96 34.66c-10.8 12.76-12.28 16.6-16.1 26.2A90.42 90.42 0 0 0 53.05 146c0 41.29 27.68 76.09 65.49 86.91 35.3-25.9 55.47-125.62 55.47-125.62s-14.45-10.54-18.57-18.89c-1.26-2.56-1.97-6.15-1.97-9.58.01-2.54 1.3-8.72 1.47-9.41a42.4 42.4 0 0 0 .94-12.14c-.07-.95-.17-1.9-.3-2.84v-.01a59.45 59.45 0 0 0-7.6-19h0a60.34 60.34 0 0 0-10.24-12.13 66.97 66.97 0 0 0-21.7-13.15 2.94 2.94 0 0 0-.92-.18z"/><path fill="url(#d)" stroke="#510a0c" d="M170.01 58.08a66.66 66.66 0 0 0-10.24 15.94 66.66 66.66 0 0 0-6.3 27.82 66.8 66.8 0 0 0 3.66 20.86h-.08l7.08 105.8s37.38-25.1 45.9-61a74.13 74.13 0 0 0 2.04-25.93c-1.34-14.35-4.35-21.67-9.85-30.3-5.5-8.62-10.36-14-17.63-22.78-6.17-7.43-9.44-18.25-10.39-28.87-.28-3.13-2.13-3.91-4.19-1.54z"/></g><path fill="#510a0c" d="M93.45 115.81h77.91c9.81 0 17.71 7.9 17.71 17.7v104.53c0 9.81-7.9 17.71-17.7 17.71H93.44c-9.81 0-17.71-7.9-17.71-17.7V133.51c0-9.81 7.9-17.71 17.7-17.71z"/><path fill="#e83c1b" d="M91.95 163.12h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H91.95a6.67 6.67 0 0 1-6.68-6.68v-24.8c0-3.7 2.98-6.68 6.68-6.68zm0 45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H91.95a6.67 6.67 0 0 1-6.68-6.69v-24.8c0-3.7 2.98-6.67 6.68-6.67zm51.25-45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H143.2a6.67 6.67 0 0 1-6.68-6.68v-24.8c0-3.7 2.98-6.68 6.68-6.68zm0 45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H143.2a6.67 6.67 0 0 1-6.68-6.69v-24.8c0-3.7 2.98-6.67 6.68-6.67z"/><g fill="#520a0c"><path d="M148.74 179.98h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84zm-51.54.04h18.29a2.41 2.41 0 1 1 0 4.83h-18.3a2.41 2.41 0 1 1 0-4.83z"/><path d="M108.76 173.3v18.28a2.41 2.41 0 1 1-4.84 0V173.3a2.41 2.41 0 1 1 4.84 0zm-10.59 59.18 12.93-12.93a2.41 2.41 0 1 1 3.42 3.42l-12.93 12.93a2.41 2.41 0 1 1-3.42-3.42z"/><path d="m101.59 219.55 12.93 12.93a2.41 2.41 0 1 1-3.42 3.42l-12.93-12.93a2.41 2.41 0 1 1 3.42-3.42zm47.15 1.49h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84zm0 10.73h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84z"/></g><path fill="#fcf2e4" d="M92.35 125.2h79.67a7.07 7.07 0 0 1 7.09 7.1v14.36a7.07 7.07 0 0 1-7.09 7.1H92.35a7.07 7.07 0 0 1-7.08-7.1V132.3a7.07 7.07 0 0 1 7.08-7.09z"/></svg>
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/web-app-manifest-192x192.png
(Stored with Git LFS)
Normal file
BIN
public/web-app-manifest-192x192.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
public/web-app-manifest-512x512.png
(Stored with Git LFS)
Normal file
BIN
public/web-app-manifest-512x512.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
src/app/apple-icon.png
(Stored with Git LFS)
Normal file
BIN
src/app/apple-icon.png
(Stored with Git LFS)
Normal file
Binary file not shown.
739
src/app/components/FireCalculatorForm.tsx
Normal file
739
src/app/components/FireCalculatorForm.tsx
Normal file
@@ -0,0 +1,739 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
ReferenceLine,
|
||||||
|
} from "recharts";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
// Schema for form validation
|
||||||
|
const formSchema = z.object({
|
||||||
|
startingCapital: z.coerce
|
||||||
|
.number()
|
||||||
|
.min(0, "Starting capital must be a non-negative number"),
|
||||||
|
monthlySavings: z.coerce
|
||||||
|
.number()
|
||||||
|
.min(0, "Monthly savings must be a non-negative number"),
|
||||||
|
currentAge: z.coerce.number().min(18, "Age must be at least 18"),
|
||||||
|
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(50, "Life expectancy must be at least 50"),
|
||||||
|
retirementStrategy: z.enum(["Depletion", "Maintenance", "4% Rule"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type for form values
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
interface CalculationResult {
|
||||||
|
fireNumber: number | null;
|
||||||
|
retirementAge: number | null;
|
||||||
|
inflationAdjustedAllowance: number | null;
|
||||||
|
retirementYears: number | null;
|
||||||
|
error?: string;
|
||||||
|
yearlyData?: Array<{
|
||||||
|
age: number;
|
||||||
|
year: number;
|
||||||
|
balance: number;
|
||||||
|
phase: "accumulation" | "retirement";
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FireCalculatorForm() {
|
||||||
|
const [result, setResult] = useState<CalculationResult | null>(null);
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
// Initialize form with default values
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
startingCapital: 50000,
|
||||||
|
monthlySavings: 1500,
|
||||||
|
currentAge: 25,
|
||||||
|
cagr: 7,
|
||||||
|
desiredMonthlyAllowance: 2000,
|
||||||
|
inflationRate: 2,
|
||||||
|
lifeExpectancy: 84,
|
||||||
|
retirementStrategy: "Depletion",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(values: FormValues) {
|
||||||
|
setResult(null); // Reset previous results
|
||||||
|
|
||||||
|
const startingCapital = values.startingCapital;
|
||||||
|
const monthlySavings = values.monthlySavings;
|
||||||
|
const currentAge = values.currentAge;
|
||||||
|
const annualGrowthRate = values.cagr / 100;
|
||||||
|
const initialMonthlyAllowance = values.desiredMonthlyAllowance;
|
||||||
|
const annualInflation = values.inflationRate / 100;
|
||||||
|
const lifeExpectancy = values.lifeExpectancy;
|
||||||
|
const retirementStrategy = values.retirementStrategy;
|
||||||
|
|
||||||
|
const monthlyGrowthRate = Math.pow(1 + annualGrowthRate, 1 / 12) - 1;
|
||||||
|
const monthlyInflationRate = Math.pow(1 + annualInflation, 1 / 12) - 1;
|
||||||
|
const maxIterations = 100; // Adjusted max iterations for age limit
|
||||||
|
|
||||||
|
let requiredCapital: number | null = null;
|
||||||
|
let retirementAge: number | null = null;
|
||||||
|
let finalInflationAdjustedAllowance: number | null = null;
|
||||||
|
let canRetire = false;
|
||||||
|
let errorMessage: string | undefined = undefined;
|
||||||
|
|
||||||
|
// Array to store yearly data for the chart
|
||||||
|
const yearlyData: CalculationResult["yearlyData"] = [];
|
||||||
|
yearlyData.push({
|
||||||
|
age: currentAge,
|
||||||
|
year: currentYear,
|
||||||
|
balance: startingCapital,
|
||||||
|
phase: "accumulation",
|
||||||
|
});
|
||||||
|
|
||||||
|
let currentCapital = startingCapital;
|
||||||
|
let age = currentAge;
|
||||||
|
let monthlyAllowance = initialMonthlyAllowance;
|
||||||
|
|
||||||
|
// --- Calculation Logic based on Strategy ---
|
||||||
|
|
||||||
|
if (retirementStrategy === "4% Rule") {
|
||||||
|
// --- 4% Rule Calculation ---
|
||||||
|
requiredCapital = (initialMonthlyAllowance * 12) / 0.04;
|
||||||
|
|
||||||
|
// Simulate accumulation until the 4% rule target is met
|
||||||
|
while (age < lifeExpectancy) {
|
||||||
|
if (currentCapital >= requiredCapital) {
|
||||||
|
canRetire = true;
|
||||||
|
retirementAge = age;
|
||||||
|
finalInflationAdjustedAllowance = monthlyAllowance;
|
||||||
|
break; // Found retirement age
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate one year of saving and growth
|
||||||
|
for (let month = 0; month < 12; month++) {
|
||||||
|
currentCapital += monthlySavings;
|
||||||
|
currentCapital *= 1 + monthlyGrowthRate;
|
||||||
|
monthlyAllowance *= 1 + monthlyInflationRate; // Keep track of inflation-adjusted allowance
|
||||||
|
}
|
||||||
|
age++;
|
||||||
|
|
||||||
|
yearlyData.push({
|
||||||
|
age: age,
|
||||||
|
year: currentYear + (age - currentAge),
|
||||||
|
balance: Math.round(currentCapital),
|
||||||
|
phase: "accumulation",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (age >= lifeExpectancy) break; // Stop if life expectancy is reached
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canRetire) {
|
||||||
|
errorMessage =
|
||||||
|
"Cannot reach FIRE goal (4% Rule) before life expectancy.";
|
||||||
|
requiredCapital = null; // Cannot retire, so no specific FIRE number applies this way
|
||||||
|
} else if (retirementAge !== null) {
|
||||||
|
// Simulate retirement phase for chart data (using 4% withdrawal adjusted for inflation)
|
||||||
|
let simulationCapital = currentCapital;
|
||||||
|
let simulationAge = retirementAge;
|
||||||
|
|
||||||
|
// Mark retirement phase in existing data
|
||||||
|
yearlyData.forEach((data) => {
|
||||||
|
if (data.age >= retirementAge!) {
|
||||||
|
data.phase = "retirement";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
while (simulationAge < lifeExpectancy) {
|
||||||
|
let yearlyWithdrawal = requiredCapital * 0.04; // Initial 4%
|
||||||
|
// Adjust for inflation annually from retirement start
|
||||||
|
yearlyWithdrawal *= Math.pow(
|
||||||
|
1 + annualInflation,
|
||||||
|
simulationAge - retirementAge,
|
||||||
|
);
|
||||||
|
const monthlyWithdrawal = yearlyWithdrawal / 12;
|
||||||
|
|
||||||
|
for (let month = 0; month < 12; month++) {
|
||||||
|
simulationCapital -=
|
||||||
|
monthlyWithdrawal * Math.pow(1 + monthlyInflationRate, month); // Approximate intra-year inflation on withdrawal
|
||||||
|
simulationCapital *= 1 + monthlyGrowthRate;
|
||||||
|
}
|
||||||
|
simulationAge++;
|
||||||
|
|
||||||
|
yearlyData.push({
|
||||||
|
age: simulationAge,
|
||||||
|
year: currentYear + (simulationAge - currentAge),
|
||||||
|
balance: Math.round(simulationCapital),
|
||||||
|
phase: "retirement",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// --- Depletion and Maintenance Calculation (Simulation-based) ---
|
||||||
|
let iterations = 0;
|
||||||
|
|
||||||
|
while (age < lifeExpectancy && iterations < maxIterations) {
|
||||||
|
// Simulate one year of saving and growth
|
||||||
|
for (let month = 0; month < 12; month++) {
|
||||||
|
currentCapital += monthlySavings;
|
||||||
|
currentCapital *= 1 + monthlyGrowthRate;
|
||||||
|
monthlyAllowance *= 1 + monthlyInflationRate;
|
||||||
|
}
|
||||||
|
age++;
|
||||||
|
iterations++;
|
||||||
|
|
||||||
|
yearlyData.push({
|
||||||
|
age: age,
|
||||||
|
year: currentYear + (age - currentAge),
|
||||||
|
balance: Math.round(currentCapital),
|
||||||
|
phase: "accumulation",
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Check if retirement is possible at this age ---
|
||||||
|
let testCapital = currentCapital;
|
||||||
|
let testAge = age;
|
||||||
|
let testAllowance = monthlyAllowance;
|
||||||
|
let isSufficient = true;
|
||||||
|
|
||||||
|
// Simulate retirement phase to check sufficiency
|
||||||
|
while (testAge < lifeExpectancy) {
|
||||||
|
const yearlyStartCapital = testCapital;
|
||||||
|
|
||||||
|
for (let month = 0; month < 12; month++) {
|
||||||
|
const withdrawal = testAllowance;
|
||||||
|
testCapital -= withdrawal;
|
||||||
|
const growth = testCapital * monthlyGrowthRate;
|
||||||
|
testCapital += growth; // Apply growth *after* withdrawal for the month
|
||||||
|
testAllowance *= 1 + monthlyInflationRate; // Inflate allowance for next month
|
||||||
|
}
|
||||||
|
testAge++;
|
||||||
|
|
||||||
|
if (testCapital <= 0) {
|
||||||
|
// Depleted capital before life expectancy
|
||||||
|
isSufficient = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retirementStrategy === "Maintenance") {
|
||||||
|
// Maintenance check: Withdrawal should not exceed growth for the year
|
||||||
|
// Use average capital for a slightly more stable check? Or end-of-year growth vs start-of-year withdrawal?
|
||||||
|
// Let's check if end-of-year capital is less than start-of-year capital
|
||||||
|
if (testCapital < yearlyStartCapital) {
|
||||||
|
isSufficient = false;
|
||||||
|
break; // Capital decreased, maintenance failed
|
||||||
|
}
|
||||||
|
// Alternative check: yearlyWithdrawal > yearlyGrowth
|
||||||
|
// if (yearlyWithdrawal > yearlyGrowth) {
|
||||||
|
// isSufficient = false;
|
||||||
|
// break; // Withdrawals exceed growth, maintenance failed
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
} // End retirement simulation check
|
||||||
|
|
||||||
|
if (isSufficient) {
|
||||||
|
canRetire = true;
|
||||||
|
retirementAge = age;
|
||||||
|
requiredCapital = currentCapital; // The capital needed at this point
|
||||||
|
finalInflationAdjustedAllowance = monthlyAllowance; // Allowance level at retirement
|
||||||
|
break; // Found retirement age
|
||||||
|
}
|
||||||
|
} // End accumulation simulation loop
|
||||||
|
|
||||||
|
if (!canRetire) {
|
||||||
|
errorMessage = `Cannot reach FIRE goal (${retirementStrategy}) before life expectancy or within ${maxIterations} years.`;
|
||||||
|
requiredCapital = null;
|
||||||
|
} else if (retirementAge !== null) {
|
||||||
|
// Simulate the actual retirement phase for chart data if retirement is possible
|
||||||
|
let simulationCapital = requiredCapital!;
|
||||||
|
let simulationAge = retirementAge;
|
||||||
|
let simulationAllowance = finalInflationAdjustedAllowance!;
|
||||||
|
|
||||||
|
// Mark retirement phase in existing data
|
||||||
|
yearlyData.forEach((data) => {
|
||||||
|
if (data.age >= retirementAge!) {
|
||||||
|
data.phase = "retirement";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate remaining years until life expectancy
|
||||||
|
while (simulationAge < lifeExpectancy) {
|
||||||
|
for (let month = 0; month < 12; month++) {
|
||||||
|
simulationCapital -= simulationAllowance;
|
||||||
|
simulationCapital *= 1 + monthlyGrowthRate;
|
||||||
|
simulationAllowance *= 1 + monthlyInflationRate;
|
||||||
|
}
|
||||||
|
simulationAge++;
|
||||||
|
|
||||||
|
// Ensure capital doesn't go below zero for chart visibility in Depletion
|
||||||
|
const displayBalance =
|
||||||
|
retirementStrategy === "Depletion"
|
||||||
|
? Math.max(0, simulationCapital)
|
||||||
|
: simulationCapital;
|
||||||
|
|
||||||
|
yearlyData.push({
|
||||||
|
age: simulationAge,
|
||||||
|
year: currentYear + (simulationAge - currentAge),
|
||||||
|
balance: Math.round(displayBalance),
|
||||||
|
phase: "retirement",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // End Depletion/Maintenance logic
|
||||||
|
|
||||||
|
// --- Set Final Result ---
|
||||||
|
if (
|
||||||
|
canRetire &&
|
||||||
|
retirementAge !== null &&
|
||||||
|
requiredCapital !== null &&
|
||||||
|
finalInflationAdjustedAllowance !== null
|
||||||
|
) {
|
||||||
|
// Ensure yearlyData covers up to lifeExpectancy if retirement happens early
|
||||||
|
const lastDataYear =
|
||||||
|
yearlyData[yearlyData.length - 1]?.year ?? currentYear;
|
||||||
|
const expectedEndYear = currentYear + (lifeExpectancy - currentAge);
|
||||||
|
if (lastDataYear < expectedEndYear) {
|
||||||
|
// Need to continue simulation purely for charting if the main calc stopped early
|
||||||
|
// (This might already be covered by the post-retirement simulation loops added above)
|
||||||
|
console.warn(
|
||||||
|
"Chart data might not extend fully to life expectancy in some scenarios.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult({
|
||||||
|
fireNumber: requiredCapital,
|
||||||
|
retirementAge: retirementAge,
|
||||||
|
inflationAdjustedAllowance: finalInflationAdjustedAllowance,
|
||||||
|
retirementYears: lifeExpectancy - retirementAge,
|
||||||
|
yearlyData: yearlyData,
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setResult({
|
||||||
|
fireNumber: null,
|
||||||
|
retirementAge: null,
|
||||||
|
inflationAdjustedAllowance: null,
|
||||||
|
retirementYears: null,
|
||||||
|
yearlyData: yearlyData, // Show accumulation data even if goal not reached
|
||||||
|
error:
|
||||||
|
errorMessage ?? "Calculation failed to find a retirement scenario.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format currency without specific symbols
|
||||||
|
const formatNumber = (value: number | null) => {
|
||||||
|
if (value === null) return "N/A";
|
||||||
|
return new Intl.NumberFormat("en", {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl">FIRE Calculator</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Calculate your path to financial independence and retirement
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="startingCapital"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Starting Capital</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., 10000"
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="monthlySavings"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Monthly Savings</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., 500"
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="currentAge"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Current Age</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., 30"
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="cagr"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Expected Annual Growth Rate (%)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., 7"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="desiredMonthlyAllowance"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Desired Monthly Allowance (Today's Value)
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., 2000"
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="inflationRate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Annual Inflation Rate (%)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., 2"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="lifeExpectancy"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Life Expectancy (Age)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., 90"
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="retirementStrategy"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Retirement Strategy</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a retirement strategy" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Depletion">Depletion</SelectItem>
|
||||||
|
<SelectItem value="Maintenance">
|
||||||
|
Maintenance
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="4% Rule">4% Rule</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
Calculate
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="mb-4 grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||||
|
{result.error ? (
|
||||||
|
<Card className="col-span-full">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-destructive">{result.error}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>FIRE Number</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
Required capital at retirement using{" "}
|
||||||
|
{form.getValues().retirementStrategy}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-bold">
|
||||||
|
{formatNumber(result.fireNumber)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Retirement Age</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
Estimated age when you can retire
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-bold">
|
||||||
|
{result.retirementAge ?? "N/A"}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{result.inflationAdjustedAllowance && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Monthly Allowance</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
At retirement (inflation adjusted)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-bold">
|
||||||
|
{formatNumber(result.inflationAdjustedAllowance)}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.retirementYears && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Retirement Duration</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
Years in retirement
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-3xl font-bold">
|
||||||
|
{result.retirementYears}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result?.yearlyData && result.yearlyData.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Financial Projection</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Projected balance growth and FIRE number threshold
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer
|
||||||
|
className="aspect-auto h-80 w-full"
|
||||||
|
config={{
|
||||||
|
balance: {
|
||||||
|
label: "Balance",
|
||||||
|
color: "var(--chart-1)",
|
||||||
|
},
|
||||||
|
fireNumber: {
|
||||||
|
label: "FIRE Number",
|
||||||
|
color: "var(--chart-3)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AreaChart
|
||||||
|
data={result.yearlyData}
|
||||||
|
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="year"
|
||||||
|
label={{
|
||||||
|
value: "Year",
|
||||||
|
position: "insideBottom",
|
||||||
|
offset: -10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(value: number) => {
|
||||||
|
if (value >= 1000000) {
|
||||||
|
return `${(value / 1000000).toPrecision(3)}M`;
|
||||||
|
} else if (value >= 1000) {
|
||||||
|
return `${(value / 1000).toPrecision(3)}K`;
|
||||||
|
}
|
||||||
|
return value.toString();
|
||||||
|
}}
|
||||||
|
width={25}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload?.[0]?.payload) {
|
||||||
|
const data = payload[0]
|
||||||
|
.payload as (typeof result.yearlyData)[0];
|
||||||
|
return (
|
||||||
|
<div className="bg-background border p-2 shadow-sm">
|
||||||
|
<p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p>
|
||||||
|
<p className="text-primary">{`Balance: ${formatNumber(data.balance)}`}</p>
|
||||||
|
{result.fireNumber !== null && (
|
||||||
|
<p className="text-destructive">{`Target FIRE Number: ${formatNumber(result.fireNumber)}`}</p>
|
||||||
|
)}
|
||||||
|
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="fillBalance" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stopColor="var(--chart-1)"
|
||||||
|
stopOpacity={0.8}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor="var(--chart-1)"
|
||||||
|
stopOpacity={0.1}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="balance"
|
||||||
|
name="balance"
|
||||||
|
stroke="var(--chart-1)"
|
||||||
|
fill="url(#fillBalance)"
|
||||||
|
fillOpacity={0.4}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
{result.fireNumber && (
|
||||||
|
<ReferenceLine
|
||||||
|
y={result.fireNumber}
|
||||||
|
stroke="var(--chart-3)"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
label={{
|
||||||
|
value: "FIRE Number",
|
||||||
|
position: "insideBottomRight",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{result.retirementAge && (
|
||||||
|
<ReferenceLine
|
||||||
|
x={
|
||||||
|
currentYear +
|
||||||
|
(result.retirementAge - form.getValues().currentAge)
|
||||||
|
}
|
||||||
|
stroke="var(--chart-2)"
|
||||||
|
strokeWidth={2}
|
||||||
|
label={{
|
||||||
|
value: "Retirement",
|
||||||
|
position: "insideTopRight",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
77
src/app/components/web-vitals.tsx
Normal file
77
src/app/components/web-vitals.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePlausible } from "next-plausible";
|
||||||
|
import { useReportWebVitals } from "next/web-vitals";
|
||||||
|
interface Metric {
|
||||||
|
/**
|
||||||
|
* The name of the metric (in acronym form).
|
||||||
|
*/
|
||||||
|
name: "CLS" | "FCP" | "FID" | "INP" | "LCP" | "TTFB";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current value of the metric.
|
||||||
|
*/
|
||||||
|
value: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rating as to whether the metric value is within the "good",
|
||||||
|
* "needs improvement", or "poor" thresholds of the metric.
|
||||||
|
*/
|
||||||
|
rating: "good" | "needs-improvement" | "poor";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The delta between the current value and the last-reported value.
|
||||||
|
* On the first report, `delta` and `value` will always be the same.
|
||||||
|
*/
|
||||||
|
delta: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A unique ID representing this particular metric instance. This ID can
|
||||||
|
* be used by an analytics tool to dedupe multiple values sent for the same
|
||||||
|
* metric instance, or to group multiple deltas together and calculate a
|
||||||
|
* total. It can also be used to differentiate multiple different metric
|
||||||
|
* instances sent from the same page, which can happen if the page is
|
||||||
|
* restored from the back/forward cache (in that case new metrics object
|
||||||
|
* get created).
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Any performance entries relevant to the metric value calculation.
|
||||||
|
* The array may also be empty if the metric value was not based on any
|
||||||
|
* entries (e.g. a CLS value of 0 given no layout shifts).
|
||||||
|
*/
|
||||||
|
entries: PerformanceEntry[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of navigation.
|
||||||
|
*
|
||||||
|
* This will be the value returned by the Navigation Timing API (or
|
||||||
|
* `undefined` if the browser doesn't support that API), with the following
|
||||||
|
* exceptions:
|
||||||
|
* - 'back-forward-cache': for pages that are restored from the bfcache.
|
||||||
|
* - 'back_forward' is renamed to 'back-forward' for consistency.
|
||||||
|
* - 'prerender': for pages that were prerendered.
|
||||||
|
* - 'restore': for pages that were discarded by the browser and then
|
||||||
|
* restored by the user.
|
||||||
|
*/
|
||||||
|
navigationType:
|
||||||
|
| "navigate"
|
||||||
|
| "reload"
|
||||||
|
| "back-forward"
|
||||||
|
| "back-forward-cache"
|
||||||
|
| "prerender"
|
||||||
|
| "restore";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebVitals() {
|
||||||
|
const plausible = usePlausible();
|
||||||
|
useReportWebVitals((metric: Metric) => {
|
||||||
|
plausible("web-vitals", {
|
||||||
|
props: {
|
||||||
|
[metric.name]: metric.rating,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return <></>;
|
||||||
|
}
|
BIN
src/app/favicon.ico
(Stored with Git LFS)
Normal file
BIN
src/app/favicon.ico
(Stored with Git LFS)
Normal file
Binary file not shown.
1
src/app/icon0.svg
Normal file
1
src/app/icon0.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" width="1000" height="1000" viewBox="0 0 264.58 264.58"><defs><linearGradient id="b"><stop offset="0" stop-color="#fd8315"/><stop offset="1" stop-color="#fa6b14"/></linearGradient><linearGradient id="a"><stop offset="0" stop-color="#f24b1b"/><stop offset="1" stop-color="#dc2f12"/></linearGradient><linearGradient xlink:href="#a" id="d" x1="172.49" x2="179.1" y1="64.48" y2="197.19" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#b" id="c" x1="118.9" x2="117.99" y1="14.21" y2="194.34" gradientUnits="userSpaceOnUse"/></defs><rect width="264.58" height="264.58" fill="#fdf2e4" ry="42.08" style="-inkscape-stroke:none"/><g stroke-linecap="round" stroke-linejoin="round" stroke-width=".13"><path fill="url(#c)" stroke="#f14a1b" d="m115.13 9.96-.26.01c-.97.11-1.29 1.02-.75 2.38a45.6 45.6 0 0 1 3.02 15.68c-.09 8.46.04 12.87-7.31 23.68s-23.16 21.9-33.96 34.66c-10.8 12.76-12.28 16.6-16.1 26.2A90.42 90.42 0 0 0 53.05 146c0 41.29 27.68 76.09 65.49 86.91 35.3-25.9 55.47-125.62 55.47-125.62s-14.45-10.54-18.57-18.89c-1.26-2.56-1.97-6.15-1.97-9.58.01-2.54 1.3-8.72 1.47-9.41a42.4 42.4 0 0 0 .94-12.14c-.07-.95-.17-1.9-.3-2.84v-.01a59.45 59.45 0 0 0-7.6-19h0a60.34 60.34 0 0 0-10.24-12.13 66.97 66.97 0 0 0-21.7-13.15 2.94 2.94 0 0 0-.92-.18z"/><path fill="url(#d)" stroke="#510a0c" d="M170.01 58.08a66.66 66.66 0 0 0-10.24 15.94 66.66 66.66 0 0 0-6.3 27.82 66.8 66.8 0 0 0 3.66 20.86h-.08l7.08 105.8s37.38-25.1 45.9-61a74.13 74.13 0 0 0 2.04-25.93c-1.34-14.35-4.35-21.67-9.85-30.3-5.5-8.62-10.36-14-17.63-22.78-6.17-7.43-9.44-18.25-10.39-28.87-.28-3.13-2.13-3.91-4.19-1.54z"/></g><path fill="#510a0c" d="M93.45 115.81h77.91c9.81 0 17.71 7.9 17.71 17.7v104.53c0 9.81-7.9 17.71-17.7 17.71H93.44c-9.81 0-17.71-7.9-17.71-17.7V133.51c0-9.81 7.9-17.71 17.7-17.71z"/><path fill="#e83c1b" d="M91.95 163.12h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H91.95a6.67 6.67 0 0 1-6.68-6.68v-24.8c0-3.7 2.98-6.68 6.68-6.68zm0 45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H91.95a6.67 6.67 0 0 1-6.68-6.69v-24.8c0-3.7 2.98-6.67 6.68-6.67zm51.25-45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H143.2a6.67 6.67 0 0 1-6.68-6.68v-24.8c0-3.7 2.98-6.68 6.68-6.68zm0 45.92h29.23c3.7 0 6.68 2.98 6.68 6.68v24.8c0 3.7-2.98 6.68-6.68 6.68H143.2a6.67 6.67 0 0 1-6.68-6.69v-24.8c0-3.7 2.98-6.67 6.68-6.67z"/><g fill="#520a0c"><path d="M148.74 179.98h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84zm-51.54.04h18.29a2.41 2.41 0 1 1 0 4.83h-18.3a2.41 2.41 0 1 1 0-4.83z"/><path d="M108.76 173.3v18.28a2.41 2.41 0 1 1-4.84 0V173.3a2.41 2.41 0 1 1 4.84 0zm-10.59 59.18 12.93-12.93a2.41 2.41 0 1 1 3.42 3.42l-12.93 12.93a2.41 2.41 0 1 1-3.42-3.42z"/><path d="m101.59 219.55 12.93 12.93a2.41 2.41 0 1 1-3.42 3.42l-12.93-12.93a2.41 2.41 0 1 1 3.42-3.42zm47.15 1.49h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84zm0 10.73h18.29a2.41 2.41 0 1 1 0 4.84h-18.29a2.41 2.41 0 1 1 0-4.84z"/></g><path fill="#fcf2e4" d="M92.35 125.2h79.67a7.07 7.07 0 0 1 7.09 7.1v14.36a7.07 7.07 0 0 1-7.09 7.1H92.35a7.07 7.07 0 0 1-7.08-7.1V132.3a7.07 7.07 0 0 1 7.08-7.09z"/></svg>
|
After Width: | Height: | Size: 3.1 KiB |
BIN
src/app/icon1.png
(Stored with Git LFS)
Normal file
BIN
src/app/icon1.png
(Stored with Git LFS)
Normal file
Binary file not shown.
40
src/app/layout.tsx
Normal file
40
src/app/layout.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import "@/styles/globals.css";
|
||||||
|
import PlausibleProvider from "next-plausible";
|
||||||
|
import { type Metadata } from "next";
|
||||||
|
import { Geist } from "next/font/google";
|
||||||
|
import { WebVitals } from "./components/web-vitals";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title:
|
||||||
|
"InvestingFIRE Calculator | Plan Your Financial Independence & Early Retirement",
|
||||||
|
description:
|
||||||
|
"Achieve Financial Independence, Retire Early (FIRE) with the InvestingFIRE calculator. Get personalized projections and investing advice to plan your journey.",
|
||||||
|
icons: [{ rel: "icon", url: "/favicon.ico" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const geist = Geist({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className={geist.variable}>
|
||||||
|
<head>
|
||||||
|
<meta name="apple-mobile-web-app-title" content="FIRE" />
|
||||||
|
</head>
|
||||||
|
<PlausibleProvider
|
||||||
|
domain="investingfire.com"
|
||||||
|
customDomain="https://analytics.schulze.network"
|
||||||
|
selfHosted={true}
|
||||||
|
enabled={true}
|
||||||
|
trackOutboundLinks={true}
|
||||||
|
>
|
||||||
|
<WebVitals />
|
||||||
|
<body>{children}</body>
|
||||||
|
</PlausibleProvider>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
21
src/app/manifest.json
Normal file
21
src/app/manifest.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "InvestingFIRE",
|
||||||
|
"short_name": "FIRE",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#fdf2e4",
|
||||||
|
"background_color": "#fdf2e4",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
375
src/app/page.tsx
Normal file
375
src/app/page.tsx
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import FireCalculatorForm from "./components/FireCalculatorForm";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<main className="text-primary-foreground to-destructive from-secondary flex min-h-screen flex-col items-center bg-gradient-to-b p-4">
|
||||||
|
<div className="mx-auto flex flex-col items-center justify-center gap-4 text-center">
|
||||||
|
<div className="flex flex-row flex-wrap items-center justify-center gap-4 align-middle">
|
||||||
|
<Image
|
||||||
|
src="/investingfire_logo_no-bg.svg"
|
||||||
|
alt="InvestingFIRE Logo"
|
||||||
|
width={100}
|
||||||
|
height={100}
|
||||||
|
/>
|
||||||
|
<h1 className="from-primary via-primary-foreground to-primary bg-gradient-to-r bg-clip-text text-5xl font-extrabold tracking-tight text-transparent drop-shadow-md sm:text-[5rem]">
|
||||||
|
InvestingFIRE
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-primary-foreground/90 text-xl font-semibold md:text-2xl">
|
||||||
|
The #1 FIRE Calculator
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 w-full max-w-2xl">
|
||||||
|
<FireCalculatorForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Added SEO Content Sections */}
|
||||||
|
<div className="mx-auto max-w-2xl py-12 text-left">
|
||||||
|
<section className="mb-12">
|
||||||
|
<h2 className="mb-4 text-3xl font-bold">
|
||||||
|
What is FIRE? Understanding Financial Independence and Early
|
||||||
|
Retirement
|
||||||
|
</h2>
|
||||||
|
<p className="mb-4 text-lg leading-relaxed">
|
||||||
|
FIRE stands for "Financial Independence, Retire Early."
|
||||||
|
It's a movement focused on aggressive saving and strategic
|
||||||
|
investing to build a substantial portfolio. The goal is for
|
||||||
|
investment returns to cover living expenses indefinitely, freeing
|
||||||
|
you from traditional employment. Achieving FIRE means gaining the
|
||||||
|
freedom to pursue passions, travel, or simply enjoy life without
|
||||||
|
needing a regular paycheck. Sound investing advice is crucial for
|
||||||
|
building the wealth needed.
|
||||||
|
</p>
|
||||||
|
<p className="text-lg leading-relaxed">
|
||||||
|
The core principle involves maximizing your savings rate (often
|
||||||
|
50%+) and investing wisely, typically in low-cost, diversified
|
||||||
|
assets like index funds. Your "FIRE number" – the capital
|
||||||
|
needed – is often estimated as 25 times your desired annual
|
||||||
|
spending, derived from the 4% safe withdrawal rate guideline. This
|
||||||
|
FIRE calculator helps you personalize this estimate.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-12">
|
||||||
|
<h2 className="mb-4 text-3xl font-bold">
|
||||||
|
How This FIRE Calculator Provides Investing Insights
|
||||||
|
</h2>
|
||||||
|
<p className="mb-4 text-lg leading-relaxed">
|
||||||
|
This calculator helps visualize your path to FIRE by projecting
|
||||||
|
investment growth based on your inputs. Understanding these
|
||||||
|
projections is a key piece of investing advice for long-term
|
||||||
|
planning. Here's a breakdown of the inputs:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
|
||||||
|
<li>
|
||||||
|
<strong>Starting Capital:</strong> The total amount you currently
|
||||||
|
have invested.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Monthly Savings:</strong> The amount you consistently save
|
||||||
|
and invest each month.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Current Age:</strong> Your current age in years.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Expected Annual Growth Rate (%):</strong> The average
|
||||||
|
annual return you expect from your investments (after fees, before
|
||||||
|
inflation).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Desired Monthly Allowance (Today's Value):</strong>{" "}
|
||||||
|
How much you want to be able to spend each month in retirement, in
|
||||||
|
today's money value.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Annual Inflation Rate (%):</strong> The expected average
|
||||||
|
rate at which the cost of living will increase.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Life Expectancy (Age):</strong> The age until which you
|
||||||
|
want your funds to last.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-lg leading-relaxed">
|
||||||
|
The calculator simulates your investment growth year by year,
|
||||||
|
incorporating monthly contributions, the power of compound growth (a
|
||||||
|
core investing principle), and inflation's impact on your
|
||||||
|
target allowance. It estimates the age at which your capital could
|
||||||
|
sustain your desired, inflation-adjusted monthly spending throughout
|
||||||
|
your expected retirement until your specified life expectancy. It
|
||||||
|
calculates your potential "FIRE Number" and the age you
|
||||||
|
might reach financial independence.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-12">
|
||||||
|
<h2 className="mb-4 text-3xl font-bold">
|
||||||
|
FIRE & Investing Frequently Asked Questions (FAQ)
|
||||||
|
</h2>
|
||||||
|
<Accordion type="single" collapsible className="w-full">
|
||||||
|
<AccordionItem value="item-1">
|
||||||
|
<AccordionTrigger className="text-xl font-semibold">
|
||||||
|
What is the 4% rule?
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="text-lg leading-relaxed">
|
||||||
|
The 4% rule is a guideline suggesting that you can safely
|
||||||
|
withdraw 4% of your investment portfolio's value in your
|
||||||
|
first year of retirement, and then adjust that amount for
|
||||||
|
inflation each subsequent year, with a high probability of your
|
||||||
|
money lasting for at least 30 years. This calculator uses a more
|
||||||
|
dynamic simulation based on your life expectancy but is related
|
||||||
|
to this concept.
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="item-2">
|
||||||
|
<AccordionTrigger className="text-xl font-semibold">
|
||||||
|
Is the Expected Growth Rate realistic? Finding the right
|
||||||
|
investing advice often starts here.
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="text-lg leading-relaxed">
|
||||||
|
Historically, diversified stock market investments have returned
|
||||||
|
around 7-10% annually long-term (before inflation). A rate of 7%
|
||||||
|
(after fees) is common, but remember past performance
|
||||||
|
doesn't guarantee future results, a fundamental piece of
|
||||||
|
investing advice. Choose a rate reflecting your risk tolerance
|
||||||
|
and investment strategy.
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="item-3">
|
||||||
|
<AccordionTrigger className="text-xl font-semibold">
|
||||||
|
How does inflation impact my FIRE number?
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="text-lg leading-relaxed">
|
||||||
|
Inflation erodes the purchasing power of money over time. Your
|
||||||
|
desired monthly allowance needs to increase each year just to
|
||||||
|
maintain the same standard of living. This calculator accounts
|
||||||
|
for this by adjusting your target allowance upwards based on the
|
||||||
|
inflation rate you provide, ensuring the calculated FIRE number
|
||||||
|
supports your desired lifestyle in future dollars.
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="item-4">
|
||||||
|
<AccordionTrigger className="text-xl font-semibold">
|
||||||
|
Can I really retire early with FIRE?
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="text-lg leading-relaxed">
|
||||||
|
Retiring significantly early is achievable but demands
|
||||||
|
discipline, a high savings rate, and smart investing. Success
|
||||||
|
depends on income, expenses, savings habits, and investment
|
||||||
|
returns. Use this FIRE calculator as a planning tool,
|
||||||
|
understanding it provides estimates based on your assumptions
|
||||||
|
and chosen investing approach.
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="item-5">
|
||||||
|
<AccordionTrigger className="text-xl font-semibold">
|
||||||
|
What does FIRE stand for?
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="text-lg leading-relaxed">
|
||||||
|
FIRE stands for Financial Independence, Retire Early. It
|
||||||
|
represents a lifestyle movement aimed at maximizing your savings
|
||||||
|
rate through increased income and/or decreased expenses to
|
||||||
|
achieve financial independence and retire much earlier than
|
||||||
|
traditional retirement age.
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="item-6">
|
||||||
|
<AccordionTrigger className="text-xl font-semibold">
|
||||||
|
How much should I save each month?
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="text-lg leading-relaxed">
|
||||||
|
FIRE enthusiasts typically aim to save 50-70% of their income.
|
||||||
|
The more you can save, the faster you'll reach your FIRE
|
||||||
|
goal. However, the right amount depends on your income,
|
||||||
|
lifestyle, and target retirement age. Use the calculator to
|
||||||
|
experiment with different monthly savings amounts to see their
|
||||||
|
impact on your retirement timeline.
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Optional: Add a section for relevant resources/links here */}
|
||||||
|
<section className="mb-12">
|
||||||
|
<h2 className="mb-4 text-3xl font-bold">
|
||||||
|
FIRE Journey & Investing Resources
|
||||||
|
</h2>
|
||||||
|
<p className="mb-6 text-lg leading-relaxed">
|
||||||
|
Ready to dive deeper into FIRE and solidify your investing strategy?
|
||||||
|
Explore these valuable resources for financial independence planning
|
||||||
|
and investing advice:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-secondary/20 my-8 rounded-md p-4 text-lg">
|
||||||
|
<p className="font-semibold">Getting Started with FIRE:</p>
|
||||||
|
<ol className="ml-6 list-decimal space-y-1">
|
||||||
|
<li>
|
||||||
|
Calculate your personal numbers using this FIRE calculator and
|
||||||
|
other tools.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Seek sound investing advice and consider joining communities
|
||||||
|
like r/Fire for support.
|
||||||
|
</li>
|
||||||
|
<li>Explore books and podcasts to deepen your understanding</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-8 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-xl font-semibold">
|
||||||
|
Blogs & Investing Websites
|
||||||
|
</h3>
|
||||||
|
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://www.mrmoneymustache.com/2012/01/13/the-shockingly-simple-math-behind-early-retirement/"
|
||||||
|
target="_blank"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Mr. Money Mustache - Simple Math Behind Early Retirement &
|
||||||
|
Investing
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://www.playingwithfire.co/resources"
|
||||||
|
target="_blank"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Playing With FIRE - Comprehensive Resources
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://www.reddit.com/r/Fire/"
|
||||||
|
target="_blank"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
r/Fire Reddit Community
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-xl font-semibold">
|
||||||
|
Books & Investment Learning
|
||||||
|
</h3>
|
||||||
|
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://www.amazon.com/Your-Money-Life-Transforming-Relationship/dp/0143115766"
|
||||||
|
target="_blank"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Your Money or Your Life - Foundational FIRE & Investing
|
||||||
|
Principles
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://www.playingwithfire.co/"
|
||||||
|
target="_blank"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Playing With FIRE Documentary
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://podcasts.apple.com/us/podcast/can-you-retire-now-this-fire-calculator-will-tell-you/id1330225136?i=1000683436292"
|
||||||
|
target="_blank"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
BiggerPockets Money Podcast - FIRE Calculators & Investing
|
||||||
|
Strategies
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-xl font-semibold">
|
||||||
|
Additional FIRE & Investing Calculators
|
||||||
|
</h3>
|
||||||
|
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://walletburst.com/tools/coast-fire-calculator/"
|
||||||
|
target="_blank"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Coast FIRE Calculator - For those considering a partial
|
||||||
|
early retirement
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://www.empower.com/retirement-calculator"
|
||||||
|
target="_blank"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Empower Retirement Planner - Free portfolio analysis and net
|
||||||
|
worth tracking
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://www.investor.gov/financial-tools-calculators/calculators/compound-interest-calculator"
|
||||||
|
target="_blank"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
CAGR Compound Interest Calculator - Understand Investment
|
||||||
|
Growth
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-xl font-semibold">
|
||||||
|
Recent Investing & FIRE Articles
|
||||||
|
</h3>
|
||||||
|
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://www.businessinsider.com/retiring-tech-early-coast-fire-make-me-millionaire-2025-4"
|
||||||
|
target="_blank"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Coast FIRE: Retiring in your 30s while becoming a
|
||||||
|
millionaire by 60
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://www.businessinsider.com/financial-independence-retire-early-saving-loneliness-retreat-bali-making-friends-2025-2"
|
||||||
|
target="_blank"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
The Social Side of FIRE: Finding Community in Financial
|
||||||
|
Independence
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
69
src/components/ui/accordion.tsx
Normal file
69
src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn(
|
||||||
|
"border-primary-foreground/20 border-b last:border-b-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="text-primary-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
59
src/components/ui/button.tsx
Normal file
59
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
};
|
357
src/components/ui/chart.tsx
Normal file
357
src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as RechartsPrimitive from "recharts";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const;
|
||||||
|
|
||||||
|
export type ChartConfig = Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
label?: React.ReactNode;
|
||||||
|
icon?: React.ComponentType;
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
)
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig;
|
||||||
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>["children"];
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId();
|
||||||
|
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([, config]) => config.theme ?? config.color,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
||||||
|
itemConfig.color;
|
||||||
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean;
|
||||||
|
hideIndicator?: boolean;
|
||||||
|
indicator?: "line" | "dot" | "dashed";
|
||||||
|
nameKey?: string;
|
||||||
|
labelKey?: string;
|
||||||
|
}) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload;
|
||||||
|
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string"
|
||||||
|
? (config[label]?.label ?? label)
|
||||||
|
: itemConfig?.label;
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>
|
||||||
|
{labelFormatter(value, payload)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload.map((item, index) => {
|
||||||
|
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const indicatorColor: string | undefined =
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
|
color ?? item.payload.fill ?? item.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||||
|
indicator === "dot" && "items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label ?? item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value && (
|
||||||
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend;
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = "bottom",
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<"div"> &
|
||||||
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||||
|
hideIcon?: boolean;
|
||||||
|
nameKey?: string;
|
||||||
|
}) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload.map((item) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||||
|
const key = `${nameKey ?? item.dataKey ?? "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload &&
|
||||||
|
typeof payload.payload === "object" &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let configLabelKey: string = key;
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key as keyof typeof payload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[
|
||||||
|
key as keyof typeof payloadPayload
|
||||||
|
] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
};
|
164
src/components/ui/form.tsx
Normal file
164
src/components/ui/form.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import type * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
useFormState,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
const Form = FormProvider;
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = {
|
||||||
|
name: TName;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext);
|
||||||
|
const itemContext = React.useContext(FormItemContext);
|
||||||
|
const { getFieldState } = useFormContext();
|
||||||
|
const formState = useFormState({ name: fieldContext.name });
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState);
|
||||||
|
|
||||||
|
const { id } = itemContext;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const id = React.useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div
|
||||||
|
data-slot="form-item"
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
const { error, formItemId } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="form-label"
|
||||||
|
data-error={!!error}
|
||||||
|
className={cn("data-[error=true]:text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||||
|
useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
data-slot="form-control"
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { formDescriptionId } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-description"
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { error, formMessageId } = useFormField();
|
||||||
|
const body = error ? String(error?.message ?? "") : props.children;
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-message"
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-destructive text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
};
|
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input };
|
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label };
|
185
src/components/ui/select.tsx
Normal file
185
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
};
|
40
src/env.js
Normal file
40
src/env.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { createEnv } from "@t3-oss/env-nextjs";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const env = createEnv({
|
||||||
|
/**
|
||||||
|
* Specify your server-side environment variables schema here. This way you can ensure the app
|
||||||
|
* isn't built with invalid env vars.
|
||||||
|
*/
|
||||||
|
server: {
|
||||||
|
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specify your client-side environment variables schema here. This way you can ensure the app
|
||||||
|
* isn't built with invalid env vars. To expose them to the client, prefix them with
|
||||||
|
* `NEXT_PUBLIC_`.
|
||||||
|
*/
|
||||||
|
client: {
|
||||||
|
// NEXT_PUBLIC_CLIENTVAR: z.string(),
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
|
||||||
|
* middlewares) or client-side so we need to destruct manually.
|
||||||
|
*/
|
||||||
|
runtimeEnv: {
|
||||||
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
|
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
|
||||||
|
* useful for Docker builds.
|
||||||
|
*/
|
||||||
|
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
||||||
|
/**
|
||||||
|
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
|
||||||
|
* `SOME_VAR=''` will throw an error.
|
||||||
|
*/
|
||||||
|
emptyStringAsUndefined: true,
|
||||||
|
});
|
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
132
src/styles/globals.css
Normal file
132
src/styles/globals.css
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans:
|
||||||
|
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
|
||||||
|
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||||
|
--foreground: oklch(0.39 0.0215 96.47); /* black olive */
|
||||||
|
--card: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||||
|
--card-foreground: oklch(0.39 0.0215 96.47); /* black olive */
|
||||||
|
--popover: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||||
|
--popover-foreground: oklch(0.39 0.0215 96.47); /* black olive */
|
||||||
|
--primary: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||||
|
--primary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||||
|
--secondary: oklch(0.49 0.1326 259.29); /* denim */
|
||||||
|
--secondary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||||
|
--muted: oklch(0.67 0.0763 198.81 / 20%); /* verdigris with opacity */
|
||||||
|
--muted-foreground: oklch(
|
||||||
|
0.39 0.0215 96.47 / 80%
|
||||||
|
); /* black olive with opacity */
|
||||||
|
--accent: oklch(0.49 0.1326 259.29); /* denim */
|
||||||
|
--accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||||
|
--destructive: oklch(0.33 0.1316 336.24); /* palatinate */
|
||||||
|
--border: oklch(0.67 0.0763 198.81 / 30%); /* verdigris with opacity */
|
||||||
|
--input: oklch(0.67 0.0763 198.81 / 30%); /* verdigris with opacity */
|
||||||
|
--ring: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||||
|
--chart-1: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||||
|
--chart-2: oklch(0.49 0.1326 259.29); /* denim */
|
||||||
|
--chart-3: oklch(0.33 0.1316 336.24); /* palatinate */
|
||||||
|
--chart-4: oklch(0.39 0.0215 96.47); /* black olive */
|
||||||
|
--chart-5: oklch(0.67 0.0763 198.81 / 70%); /* verdigris with opacity */
|
||||||
|
--sidebar: oklch(0.49 0.1326 259.29 / 10%); /* denim with opacity */
|
||||||
|
--sidebar-foreground: oklch(0.39 0.0215 96.47); /* black olive */
|
||||||
|
--sidebar-primary: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||||
|
--sidebar-primary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||||
|
--sidebar-accent: oklch(0.49 0.1326 259.29); /* denim */
|
||||||
|
--sidebar-accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||||
|
--sidebar-border: oklch(
|
||||||
|
0.67 0.0763 198.81 / 20%
|
||||||
|
); /* verdigris with opacity */
|
||||||
|
--sidebar-ring: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.39 0.0215 96.47); /* black olive */
|
||||||
|
--foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||||
|
--card: oklch(0.39 0.0215 96.47 / 80%); /* black olive with opacity */
|
||||||
|
--card-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||||
|
--popover: oklch(0.39 0.0215 96.47); /* black olive */
|
||||||
|
--popover-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||||
|
--primary: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||||
|
--primary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||||
|
--secondary: oklch(0.49 0.1326 259.29); /* denim */
|
||||||
|
--secondary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||||
|
--muted: oklch(0.39 0.0215 96.47 / 70%); /* black olive with opacity */
|
||||||
|
--muted-foreground: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||||
|
--accent: oklch(0.49 0.1326 259.29); /* denim */
|
||||||
|
--accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||||
|
--destructive: oklch(0.33 0.1316 336.24); /* palatinate */
|
||||||
|
--border: oklch(0.97 0.0228 95.96 / 20%); /* cosmic latte with opacity */
|
||||||
|
--input: oklch(0.97 0.0228 95.96 / 20%); /* cosmic latte with opacity */
|
||||||
|
--ring: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||||
|
--chart-1: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||||
|
--chart-2: oklch(0.49 0.1326 259.29); /* denim */
|
||||||
|
--chart-3: oklch(0.33 0.1316 336.24); /* palatinate */
|
||||||
|
--chart-4: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||||
|
--chart-5: oklch(0.67 0.0763 198.81 / 70%); /* verdigris with opacity */
|
||||||
|
--sidebar: oklch(0.39 0.0215 96.47 / 90%); /* black olive with opacity */
|
||||||
|
--sidebar-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||||
|
--sidebar-primary: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||||
|
--sidebar-primary-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||||
|
--sidebar-accent: oklch(0.49 0.1326 259.29); /* denim */
|
||||||
|
--sidebar-accent-foreground: oklch(0.97 0.0228 95.96); /* cosmic latte */
|
||||||
|
--sidebar-border: oklch(
|
||||||
|
0.97 0.0228 95.96 / 10%
|
||||||
|
); /* cosmic latte with opacity */
|
||||||
|
--sidebar-ring: oklch(0.67 0.0763 198.81); /* verdigris */
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
42
tsconfig.json
Normal file
42
tsconfig.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
/* Base Options: */
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"target": "es2022",
|
||||||
|
"allowJs": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
|
||||||
|
/* Strictness */
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"checkJs": true,
|
||||||
|
|
||||||
|
/* Bundled projects */
|
||||||
|
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||||
|
"noEmit": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "preserve",
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"incremental": true,
|
||||||
|
|
||||||
|
/* Path Aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
"**/*.cjs",
|
||||||
|
"**/*.js",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
Reference in New Issue
Block a user