Compare commits
15 Commits
main
...
5e0ff2891a
Author | SHA1 | Date | |
---|---|---|---|
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"
|
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"],
|
||||
};
|
727
src/app/components/FireCalculatorForm.tsx
Normal file
727
src/app/components/FireCalculatorForm.tsx
Normal file
@@ -0,0 +1,727 @@
|
||||
"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;
|
||||
let simulationAllowance = finalInflationAdjustedAllowance!;
|
||||
|
||||
// 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
|
||||
let yearStartCapital = currentCapital;
|
||||
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) {
|
||||
let yearlyStartCapital = testCapital;
|
||||
let yearlyGrowth = 0;
|
||||
let yearlyWithdrawal = 0;
|
||||
|
||||
for (let month = 0; month < 12; month++) {
|
||||
let withdrawal = testAllowance;
|
||||
yearlyWithdrawal += withdrawal;
|
||||
testCapital -= withdrawal;
|
||||
let growth = testCapital * monthlyGrowthRate;
|
||||
yearlyGrowth += growth;
|
||||
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 (
|
||||
<div className="w-full max-w-3xl">
|
||||
<Card className="mb-8">
|
||||
<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 && (
|
||||
<>
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<CardTitle>Results</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{result.error ? (
|
||||
<p className="text-destructive">{result.error}</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>
|
||||
FIRE Number (Required Capital at Retirement - Strategy:{" "}
|
||||
{form.getValues().retirementStrategy})
|
||||
</Label>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatNumber(result.fireNumber)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Estimated Retirement Age</Label>
|
||||
<p className="text-2xl font-bold">
|
||||
{result.retirementAge ?? "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
{result.inflationAdjustedAllowance && (
|
||||
<div>
|
||||
<Label>
|
||||
Monthly Allowance at Retirement (Inflation Adjusted)
|
||||
</Label>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatNumber(result.inflationAdjustedAllowance)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{result.retirementYears && (
|
||||
<div>
|
||||
<Label>Retirement Duration (Years)</Label>
|
||||
<p className="text-2xl font-bold">
|
||||
{result.retirementYears}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{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="h-80"
|
||||
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).toFixed(1)}M`;
|
||||
} else if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(0)}K`;
|
||||
}
|
||||
return value.toString();
|
||||
}}
|
||||
width={80}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
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 <></>;
|
||||
}
|
37
src/app/layout.tsx
Normal file
37
src/app/layout.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
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}>
|
||||
<PlausibleProvider
|
||||
domain="investingfire.com"
|
||||
customDomain="https://analytics.schulze.network"
|
||||
selfHosted={true}
|
||||
enabled={true}
|
||||
trackOutboundLinks={true}
|
||||
>
|
||||
<WebVitals />
|
||||
<body>{children}</body>
|
||||
</PlausibleProvider>
|
||||
</html>
|
||||
);
|
||||
}
|
366
src/app/page.tsx
Normal file
366
src/app/page.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
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="container mx-auto flex flex-col items-center justify-center gap-4 px-4 py-16 text-center">
|
||||
<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>
|
||||
<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="container mx-auto max-w-4xl px-4 py-8 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-muted-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,
|
||||
};
|
353
src/components/ui/chart.tsx
Normal file
353
src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
"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 = {
|
||||
[k in 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 as keyof typeof config]?.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 = 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 ? (
|
||||
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) => {
|
||||
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 as keyof typeof config];
|
||||
}
|
||||
|
||||
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