Compare commits
31 Commits
3d1d010100
...
2b09cfc352
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b09cfc352 | |||
| 19709f531d | |||
| 91dadaedaa | |||
| fde6a3b7bf | |||
| 7b24da6f35 | |||
| 67af131500 | |||
| 67f7c96454 | |||
| 5e5c1e3c55 | |||
| 3851411a0e | |||
| b097d82967 | |||
| 1400b3f1ae | |||
| 90f83ab62b | |||
| 858185baf8 | |||
| 472efbb3b1 | |||
| d995684ba3 | |||
| 3fe5f7a9d5 | |||
| f56b19aacc | |||
| 0e558d5702 | |||
| 63f9836f81 | |||
| 3f620b4f5a | |||
| 48ccc10b6c | |||
| 9b369ba45c | |||
| a60462e9a5 | |||
| 0d8a8f60e7 | |||
| dc97a0c675 | |||
| 3262a8f491 | |||
| e06981b708 | |||
| 17f8005e7a | |||
| 802efad208 | |||
| 52fbdabf87 | |||
| c61abb43a7 |
37
.cursorrules
Normal file
37
.cursorrules
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Cursor Rules for InvestingFIRE 🔥
|
||||||
|
|
||||||
|
## General Principles
|
||||||
|
- **Quality First:** All new features must include appropriate tests.
|
||||||
|
- **User-Centric:** Prioritize user experience and accessibility in all changes.
|
||||||
|
- **Dry Code:** Avoid duplication; use utility functions and components.
|
||||||
|
|
||||||
|
## Testing Requirements 🧪
|
||||||
|
- **Unit Tests:** Required for all new utility functions, hooks, and complex logic.
|
||||||
|
- Use `vitest` and `react-testing-library`.
|
||||||
|
- Place tests in `__tests__` directories or alongside files with `.test.ts(x)` extension.
|
||||||
|
- **E2E Tests:** Required for new user flows and critical paths.
|
||||||
|
- Use `playwright`.
|
||||||
|
- Ensure tests cover happy paths and error states.
|
||||||
|
- **Visual Regression:** Consider for major UI changes.
|
||||||
|
|
||||||
|
## Coding Standards
|
||||||
|
- **Type Safety:** No `any`. Use proper Zod schemas for validation.
|
||||||
|
- **Components:** Use functional components with strict prop typing.
|
||||||
|
- **Styling:** Use Tailwind CSS. Avoid inline styles.
|
||||||
|
- **State Management:** Prefer local state or React Context. Avoid global state libraries unless necessary.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
1. **Plan:** Break down tasks.
|
||||||
|
2. **Implement:** Write clean, commented code.
|
||||||
|
3. **Test:** specific unit and/or E2E tests.
|
||||||
|
4. **Verify:** Run linter and type checker (`pnpm check`), and run tests (`pnpm test`).
|
||||||
|
|
||||||
|
## Strict Rules
|
||||||
|
- **No "any" type:** Always define proper types. Use `unknown` if type is truly uncertain, but prefer specific types.
|
||||||
|
- **No "ts-ignore":** Fix the underlying issue instead of suppressing it.
|
||||||
|
|
||||||
|
## Specific Patterns
|
||||||
|
- **Forms:** Use `react-hook-form` with `zod` resolvers.
|
||||||
|
- **Charts:** Use `recharts` and ensure tooltips are accessible.
|
||||||
|
- **Calculations:** Keep financial logic separate from UI components where possible (e.g., in `lib/` or custom hooks) to facilitate testing.
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -44,3 +44,10 @@ yarn-error.log*
|
|||||||
|
|
||||||
# idea files
|
# idea files
|
||||||
.idea
|
.idea
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
CONTENT_STRATEGY.md
|
||||||
|
|
||||||
|
CONTENT_TASKS.md
|
||||||
|
|||||||
71
README.md
71
README.md
@@ -2,15 +2,54 @@
|
|||||||
|
|
||||||
# InvestingFIRE 🔥 — The #1 Interactive FIRE Calculator
|
# InvestingFIRE 🔥 — The #1 Interactive FIRE Calculator
|
||||||
|
|
||||||
**InvestingFIRE** is a responsive web application for calculating your path to Financial Independence and Early Retirement (FIRE). It features a year-by-year projection engine that simulates both accumulation (savings and investment growth) and retirement (withdrawals) phases, allowing users to:
|
**InvestingFIRE** is a responsive web application for calculating your path to Financial Independence and Early Retirement (FIRE). It features a year-by-year projection engine that simulates both accumulation (savings and investment growth) and retirement (withdrawals) phases.
|
||||||
|
|
||||||
- Input starting capital, monthly savings, expected annual growth rate, inflation rate, current age, desired retirement age, life expectancy, and desired monthly retirement allowance.
|
Deployed version: [https://investingfire.com](https://investingfire.com)
|
||||||
- View a dynamic chart displaying projected portfolio balance and monthly allowance over time.
|
|
||||||
- Instantly see their estimated “FIRE number” (required capital at retirement), how long their capital will last, and compare results to the “4% rule.”
|
|
||||||
- Adjust assumptions live, with all calculations and visualizations updating automatically.
|
|
||||||
- Access explanatory content about FIRE methodology, key variables, and additional community resources, all on a single, consolidated page.
|
|
||||||
|
|
||||||
The project’s code is structured using React/Next.js with TypeScript, focusing on user experience, modern UI components, and clarity of financial assumptions.
|
---
|
||||||
|
|
||||||
|
## 🎯 Goal & Vision
|
||||||
|
|
||||||
|
### **Goal**
|
||||||
|
To build the most comprehensive, user-friendly, and transparent open-source financial independence calculator on the web.
|
||||||
|
|
||||||
|
### **Vision**
|
||||||
|
Democratize financial planning by providing professional-grade simulation tools in an accessible, privacy-focused, and beautiful interface. We believe everyone should have the ability to model their financial future without needing a finance degree or expensive software.
|
||||||
|
|
||||||
|
### **Business Model**
|
||||||
|
InvestingFIRE operates on a transparent open-source model:
|
||||||
|
1. **Free Forever Core:** The essential calculation tools will always be free and open-source.
|
||||||
|
2. **Community Supported:** We rely on community contributions (code & feedback) to improve the tool.
|
||||||
|
3. **Educational Affiliates:** We may curate high-quality resources (books, courses, tools) to help users on their journey, keeping the tool free of intrusive ads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗺️ Roadmap
|
||||||
|
|
||||||
|
We are actively expanding the capabilities of InvestingFIRE. Below is our plan broken down into phases.
|
||||||
|
|
||||||
|
### **Phase 1: Enhanced Simulation (The Engine)**
|
||||||
|
Focus on making the math more robust and flexible.
|
||||||
|
- [ ] **Coast FIRE Mode:** Option to stop contributions at a certain age but retire later.
|
||||||
|
- [ ] **Barista FIRE Mode:** Include part-time income during "retirement" years.
|
||||||
|
- [ ] **Monte Carlo Simulations:** Add probabilistic outcomes (e.g., "95% chance of success") instead of just deterministic linear growth.
|
||||||
|
- [ ] **Variable Withdrawal Strategies:** Implement dynamic withdrawal rules (e.g., Guyton-Klinger) beyond just fixed inflation-adjusted withdrawals.
|
||||||
|
|
||||||
|
### **Phase 2: User Experience & Persistence**
|
||||||
|
Make the tool easier to use and return to.
|
||||||
|
- [ ] **URL State Sharing:** Encode form values into the URL so scenarios can be bookmarked and shared.
|
||||||
|
- [ ] **Local Persistence:** Automatically save user inputs to `localStorage` so they don't vanish on refresh.
|
||||||
|
- [ ] **Currency & Locale Support:** Allow users to select currency symbols and number formatting (USD, EUR, GBP, etc.).
|
||||||
|
|
||||||
|
### **Phase 3: Advanced Features & Analytics**
|
||||||
|
For the power users who need more detail.
|
||||||
|
- [ ] **Tax Considerations:** Simple toggles for Pre-tax vs. Post-tax estimations.
|
||||||
|
- [ ] **Scenario Comparison:** Compare two different plans side-by-side (e.g., "Retire at 45 vs 55").
|
||||||
|
- [ ] **Data Export:** Download projection data as CSV or PDF reports.
|
||||||
|
|
||||||
|
### **Phase 4: Content & Community**
|
||||||
|
- [ ] **Blog/Guides:** Integrate a CMS (like Markdown/MDX) for in-depth financial guides.
|
||||||
|
- [ ] **Community Presets:** "One-click" setups for common strategies (e.g., "Lean FIRE", "Fat FIRE").
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -65,7 +104,23 @@ To run locally:
|
|||||||
```
|
```
|
||||||
4. Visit [http://localhost:3000](http://localhost:3000) and unleash the fire.
|
4. Visit [http://localhost:3000](http://localhost:3000) and unleash the fire.
|
||||||
|
|
||||||
Deployed version: [https://investingfire.com](https://investingfire.com)
|
### Running Tests 🧪
|
||||||
|
|
||||||
|
We use **Vitest** for unit testing and **Playwright** for end-to-end (E2E) testing.
|
||||||
|
|
||||||
|
**Unit Tests:**
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
**E2E Tests:**
|
||||||
|
```bash
|
||||||
|
# First install browsers (only needed once)
|
||||||
|
pnpm exec playwright install
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
pnpm test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
15
e2e/home.spec.ts
Normal file
15
e2e/home.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test("homepage has title and calculator", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
// Expect a title "to contain" a substring.
|
||||||
|
await expect(page).toHaveTitle(/InvestingFIRE/);
|
||||||
|
|
||||||
|
// Check for main heading
|
||||||
|
await expect(page.getByRole("heading", { name: "InvestingFIRE" })).toBeVisible();
|
||||||
|
|
||||||
|
// Check for Calculator
|
||||||
|
await expect(page.getByText("FIRE Calculator")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
42
package.json
42
package.json
@@ -11,51 +11,67 @@
|
|||||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
"lint": "next typegen && eslint . && npx tsc --noEmit",
|
"lint": "next typegen && eslint . && npx tsc --noEmit",
|
||||||
"preview": "next build && next start",
|
"preview": "next build && next start",
|
||||||
"start": "next start"
|
"start": "next start",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.8",
|
"@radix-ui/react-accordion": "^1.2.8",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.4",
|
"@radix-ui/react-label": "^2.1.4",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-select": "^2.2.2",
|
"@radix-ui/react-select": "^2.2.2",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slider": "^1.3.2",
|
"@radix-ui/react-slider": "^1.3.2",
|
||||||
"@radix-ui/react-slot": "^1.2.0",
|
"@radix-ui/react-slot": "^1.2.0",
|
||||||
"@t3-oss/env-nextjs": "^0.13.0",
|
"@t3-oss/env-nextjs": "^0.13.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.554.0",
|
"cssnano": "^7.1.2",
|
||||||
"next": "16.0.3",
|
"lucide-react": "^0.555.0",
|
||||||
|
"next": "16.0.7",
|
||||||
"next-plausible": "^3.12.4",
|
"next-plausible": "^3.12.4",
|
||||||
"react": "19.2.0",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.1",
|
||||||
"react-hook-form": "^7.56.1",
|
"react-hook-form": "^7.56.1",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
"tailwind-merge": "^3.2.0",
|
"tailwind-merge": "^3.2.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.56.1",
|
||||||
"@tailwindcss/postcss": "4.1.17",
|
"@tailwindcss/postcss": "4.1.17",
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "24.10.1",
|
"@types/node": "24.10.1",
|
||||||
"@types/react": "19.2.6",
|
"@types/react": "19.2.7",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"eslint": "9.39.1",
|
"eslint": "9.39.1",
|
||||||
"eslint-config-next": "16.0.3",
|
"eslint-config-next": "16.0.7",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
|
"jsdom": "^27.2.0",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.7.1",
|
||||||
"prettier-plugin-tailwindcss": "0.7.1",
|
"prettier-plugin-tailwindcss": "0.7.2",
|
||||||
"tailwindcss": "4.1.17",
|
"tailwindcss": "4.1.17",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.47.0"
|
"typescript-eslint": "8.48.1",
|
||||||
|
"vitest": "^4.0.13"
|
||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
"initVersion": "7.39.3"
|
"initVersion": "7.39.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.23.0",
|
"packageManager": "pnpm@10.24.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/react": "19.2.6",
|
"@types/react": "19.2.7",
|
||||||
"@types/react-dom": "19.2.3"
|
"@types/react-dom": "19.2.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
playwright.config.ts
Normal file
34
playwright.config.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: "list",
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "firefox",
|
||||||
|
use: { ...devices["Desktop Firefox"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "webkit",
|
||||||
|
use: { ...devices["Desktop Safari"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: "pnpm run dev",
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
3426
pnpm-lock.yaml
generated
3426
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,9 @@
|
|||||||
export default {
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
plugins: {
|
plugins: {
|
||||||
"@tailwindcss/postcss": {},
|
'@tailwindcss/postcss': {},
|
||||||
|
cssnano: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|||||||
22
src/app/components/AuthorBio.tsx
Normal file
22
src/app/components/AuthorBio.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export function AuthorBio() {
|
||||||
|
return (
|
||||||
|
<Card className="mt-12 bg-muted/50">
|
||||||
|
<CardContent className="flex items-center gap-4 p-6">
|
||||||
|
<Avatar className="h-16 w-16 border-2 border-background">
|
||||||
|
<AvatarImage src="/images/author-profile.jpg" alt="Author" />
|
||||||
|
<AvatarFallback>IF</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">Written by The InvestingFIRE Team</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
We are a group of financial data enthusiasts and early retirees dedicated to building the most accurate FIRE tools on the web. Our goal is to replace guesswork with math.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
'use client';
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from 'react';
|
||||||
import type React from "react";
|
import type React from 'react';
|
||||||
import {
|
import {
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
HandCoins,
|
HandCoins,
|
||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
Hourglass,
|
Hourglass,
|
||||||
Sprout,
|
Sprout,
|
||||||
Target,
|
Target,
|
||||||
} from "lucide-react";
|
} from 'lucide-react';
|
||||||
|
|
||||||
export default function MultiIconPattern({ opacity = 0.2, spacing = 160 }) {
|
export default function MultiIconPattern({ opacity = 0.2, spacing = 160 }) {
|
||||||
const [width, setWidth] = useState(0);
|
const [width, setWidth] = useState(0);
|
||||||
@@ -58,10 +58,10 @@ export default function MultiIconPattern({ opacity = 0.2, spacing = 160 }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
updateDimensions();
|
updateDimensions();
|
||||||
window.addEventListener("resize", updateDimensions);
|
window.addEventListener('resize', updateDimensions);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("resize", updateDimensions);
|
window.removeEventListener('resize', updateDimensions);
|
||||||
};
|
};
|
||||||
}, [height, width, spacing]);
|
}, [height, width, spacing]);
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ export default function MultiIconPattern({ opacity = 0.2, spacing = 160 }) {
|
|||||||
const IconComponent = iconComponents[randomIndex];
|
const IconComponent = iconComponents[randomIndex];
|
||||||
|
|
||||||
// Slightly randomize size and position for more organic feel
|
// Slightly randomize size and position for more organic feel
|
||||||
const size = 28 + Math.floor(Math.random() * 8);
|
const size = 30 + Math.floor(Math.random() * 8);
|
||||||
const xOffset = Math.floor(Math.random() * (spacing / 1.618));
|
const xOffset = Math.floor(Math.random() * (spacing / 1.618));
|
||||||
const yOffset = Math.floor(Math.random() * (spacing / 1.618));
|
const yOffset = Math.floor(Math.random() * (spacing / 1.618));
|
||||||
const rotation = Math.round((Math.random() - 0.5) * 30);
|
const rotation = Math.round((Math.random() - 0.5) * 30);
|
||||||
@@ -138,7 +138,8 @@ export default function MultiIconPattern({ opacity = 0.2, spacing = 160 }) {
|
|||||||
<IconComponent
|
<IconComponent
|
||||||
key={`icon-${String(x)}-${String(y)}`}
|
key={`icon-${String(x)}-${String(y)}`}
|
||||||
size={size}
|
size={size}
|
||||||
className="text-primary fixed"
|
className="text-primary/30 fixed"
|
||||||
|
strokeWidth={2.5}
|
||||||
style={{
|
style={{
|
||||||
left: `${String(x * spacing + xOffset)}px`,
|
left: `${String(x * spacing + xOffset)}px`,
|
||||||
top: `${String(y * spacing + yOffset)}px`,
|
top: `${String(y * spacing + yOffset)}px`,
|
||||||
@@ -153,9 +154,5 @@ export default function MultiIconPattern({ opacity = 0.2, spacing = 160 }) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [rows, columns, spacing, opacity]);
|
}, [rows, columns, spacing, opacity]);
|
||||||
|
|
||||||
return (
|
return <div className="absolute z-0 h-full w-full">{width > 0 && icons}</div>;
|
||||||
<div className="absolute h-full w-full">
|
|
||||||
{width > 0 && icons}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,15 @@
|
|||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from 'react';
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from "zod";
|
import * as z from 'zod';
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||||
Form,
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
FormControl,
|
import { ChartContainer, ChartTooltip } from '@/components/ui/chart';
|
||||||
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 {
|
import {
|
||||||
Area,
|
Area,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
@@ -31,39 +18,42 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
ReferenceLine,
|
ReferenceLine,
|
||||||
type TooltipProps,
|
type TooltipProps,
|
||||||
} from "recharts";
|
} from 'recharts';
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from '@/components/ui/slider';
|
||||||
import assert from "assert";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import type {
|
import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent';
|
||||||
NameType,
|
import { Calculator, Percent } from 'lucide-react';
|
||||||
ValueType,
|
import BlurThing from './blur-thing';
|
||||||
} from "recharts/types/component/DefaultTooltipContent";
|
|
||||||
|
|
||||||
// Schema for form validation
|
// Schema for form validation
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
startingCapital: z.coerce.number(),
|
startingCapital: z.coerce.number(),
|
||||||
monthlySavings: z.coerce
|
monthlySavings: z.coerce.number().min(0, 'Monthly savings must be a non-negative number'),
|
||||||
.number()
|
|
||||||
.min(0, "Monthly savings must be a non-negative number"),
|
|
||||||
currentAge: z.coerce
|
currentAge: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.min(1, "Age must be at least 1")
|
.min(1, 'Age must be at least 1')
|
||||||
.max(100, "No point in starting this late"),
|
.max(100, 'No point in starting this late'),
|
||||||
cagr: z.coerce.number().min(0, "Growth rate must be a non-negative number"),
|
cagr: z.coerce.number().min(0, 'Growth rate must be a non-negative number'),
|
||||||
desiredMonthlyAllowance: z.coerce
|
desiredMonthlyAllowance: z.coerce.number().min(0, 'Monthly allowance must be a non-negative number'),
|
||||||
.number()
|
inflationRate: z.coerce.number().min(0, 'Inflation rate must be a non-negative 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
|
lifeExpectancy: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.min(40, "Be a bit more optimistic buddy :(")
|
.min(40, 'Be a bit more optimistic buddy :(')
|
||||||
.max(100, "You should be more realistic..."),
|
.max(100, 'You should be more realistic...'),
|
||||||
retirementAge: z.coerce
|
retirementAge: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.min(18, "Retirement age must be at least 18")
|
.min(18, 'Retirement age must be at least 18')
|
||||||
.max(100, "Retirement age must be at most 100"),
|
.max(100, 'Retirement age must be at most 100'),
|
||||||
|
coastFireAge: z.coerce
|
||||||
|
.number()
|
||||||
|
.min(18, 'Coast FIRE age must be at least 18')
|
||||||
|
.max(100, 'Coast FIRE age must be at most 100')
|
||||||
|
.optional(),
|
||||||
|
baristaIncome: z.coerce.number().min(0, 'Barista income must be a non-negative number').optional(),
|
||||||
|
simulationMode: z.enum(['deterministic', 'monte-carlo']).default('deterministic'),
|
||||||
|
volatility: z.coerce.number().min(0).default(15),
|
||||||
|
withdrawalStrategy: z.enum(['fixed', 'percentage']).default('fixed'),
|
||||||
|
withdrawalPercentage: z.coerce.number().min(0).max(100).default(4),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Type for form values
|
// Type for form values
|
||||||
@@ -74,9 +64,13 @@ interface YearlyData {
|
|||||||
year: number;
|
year: number;
|
||||||
balance: number;
|
balance: number;
|
||||||
untouchedBalance: number;
|
untouchedBalance: number;
|
||||||
phase: "accumulation" | "retirement";
|
phase: 'accumulation' | 'retirement';
|
||||||
monthlyAllowance: number;
|
monthlyAllowance: number;
|
||||||
untouchedMonthlyAllowance: number;
|
untouchedMonthlyAllowance: number;
|
||||||
|
// Monte Carlo percentiles
|
||||||
|
balanceP10?: number;
|
||||||
|
balanceP50?: number;
|
||||||
|
balanceP90?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CalculationResult {
|
interface CalculationResult {
|
||||||
@@ -85,29 +79,43 @@ interface CalculationResult {
|
|||||||
retirementAge4percent: number | null;
|
retirementAge4percent: number | null;
|
||||||
yearlyData: YearlyData[];
|
yearlyData: YearlyData[];
|
||||||
error?: string;
|
error?: string;
|
||||||
|
successRate?: number; // For Monte Carlo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Box-Muller transform for normal distribution
|
||||||
|
function randomNormal(mean: number, stdDev: number): number {
|
||||||
|
const u = 1 - Math.random(); // Converting [0,1) to (0,1]
|
||||||
|
const v = Math.random();
|
||||||
|
const z = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
|
||||||
|
return z * stdDev + mean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to format currency without specific symbols
|
// Helper function to format currency without specific symbols
|
||||||
const formatNumber = (value: number | null) => {
|
const formatNumber = (value: number | null) => {
|
||||||
if (!value) return "N/A";
|
if (!value) return 'N/A';
|
||||||
return new Intl.NumberFormat("en", {
|
return new Intl.NumberFormat('en', {
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: 0,
|
||||||
}).format(value);
|
}).format(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to render tooltip for chart
|
// Helper function to render tooltip for chart
|
||||||
const tooltipRenderer = ({
|
const tooltipRenderer = ({ active, payload }: TooltipProps<ValueType, NameType>) => {
|
||||||
active,
|
|
||||||
payload,
|
|
||||||
}: TooltipProps<ValueType, NameType>) => {
|
|
||||||
if (active && payload?.[0]?.payload) {
|
if (active && payload?.[0]?.payload) {
|
||||||
const data = payload[0].payload as YearlyData;
|
const data = payload[0].payload as YearlyData;
|
||||||
return (
|
return (
|
||||||
<div className="bg-background border p-2 shadow-sm">
|
<div className="bg-background border p-2 shadow-sm">
|
||||||
<p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p>
|
<p className="font-medium">{`Year: ${data.year.toString()} (Age: ${data.age.toString()})`}</p>
|
||||||
|
{data.balanceP50 !== undefined ? (
|
||||||
|
<>
|
||||||
|
<p className="text-orange-500">{`Median Balance: ${formatNumber(data.balanceP50)}`}</p>
|
||||||
|
<p className="text-xs text-orange-300">{`10th %: ${formatNumber(data.balanceP10 ?? 0)}`}</p>
|
||||||
|
<p className="text-xs text-orange-300">{`90th %: ${formatNumber(data.balanceP90 ?? 0)}`}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<p className="text-orange-500">{`Balance: ${formatNumber(data.balance)}`}</p>
|
<p className="text-orange-500">{`Balance: ${formatNumber(data.balance)}`}</p>
|
||||||
|
)}
|
||||||
<p className="text-red-600">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</p>
|
<p className="text-red-600">{`Monthly allowance: ${formatNumber(data.monthlyAllowance)}`}</p>
|
||||||
<p>{`Phase: ${data.phase === "accumulation" ? "Accumulation" : "Retirement"}`}</p>
|
<p>{`Phase: ${data.phase === 'accumulation' ? 'Accumulation' : 'Retirement'}`}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -131,6 +139,10 @@ export default function FireCalculatorForm() {
|
|||||||
inflationRate: 2.3,
|
inflationRate: 2.3,
|
||||||
lifeExpectancy: 84,
|
lifeExpectancy: 84,
|
||||||
retirementAge: 55,
|
retirementAge: 55,
|
||||||
|
coastFireAge: undefined,
|
||||||
|
baristaIncome: 0,
|
||||||
|
simulationMode: 'deterministic',
|
||||||
|
volatility: 15,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,80 +152,163 @@ export default function FireCalculatorForm() {
|
|||||||
const startingCapital = values.startingCapital;
|
const startingCapital = values.startingCapital;
|
||||||
const monthlySavings = values.monthlySavings;
|
const monthlySavings = values.monthlySavings;
|
||||||
const age = values.currentAge;
|
const age = values.currentAge;
|
||||||
const annualGrowthRate = 1 + values.cagr / 100;
|
const cagr = values.cagr;
|
||||||
const initialMonthlyAllowance = values.desiredMonthlyAllowance;
|
const initialMonthlyAllowance = values.desiredMonthlyAllowance;
|
||||||
const annualInflation = 1 + values.inflationRate / 100;
|
const annualInflation = 1 + values.inflationRate / 100;
|
||||||
const ageOfDeath = values.lifeExpectancy;
|
const ageOfDeath = values.lifeExpectancy;
|
||||||
const retirementAge = values.retirementAge;
|
const retirementAge = values.retirementAge;
|
||||||
|
const coastFireAge = values.coastFireAge ?? retirementAge;
|
||||||
|
const initialBaristaIncome = values.baristaIncome ?? 0;
|
||||||
|
const simulationMode = values.simulationMode;
|
||||||
|
const volatility = values.volatility;
|
||||||
|
|
||||||
// Array to store yearly data for the chart
|
const numSimulations = simulationMode === 'monte-carlo' ? 500 : 1;
|
||||||
|
const simulationResults: number[][] = []; // [yearIndex][simulationIndex] -> balance
|
||||||
|
|
||||||
|
// Prepare simulation runs
|
||||||
|
for (let sim = 0; sim < numSimulations; sim++) {
|
||||||
|
let currentBalance = startingCapital;
|
||||||
|
const runBalances: number[] = [];
|
||||||
|
|
||||||
|
for (let year = irlYear + 1; year <= irlYear + (ageOfDeath - age); year++) {
|
||||||
|
const currentAge = age + (year - irlYear);
|
||||||
|
|
||||||
|
// Determine growth rate for this year
|
||||||
|
let annualGrowthRate: number;
|
||||||
|
if (simulationMode === 'monte-carlo') {
|
||||||
|
// Random walk
|
||||||
|
const randomReturn = randomNormal(cagr, volatility) / 100;
|
||||||
|
annualGrowthRate = 1 + randomReturn;
|
||||||
|
} else {
|
||||||
|
// Deterministic
|
||||||
|
annualGrowthRate = 1 + cagr / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflatedAllowance = initialMonthlyAllowance * Math.pow(annualInflation, year - irlYear);
|
||||||
|
const inflatedBaristaIncome = initialBaristaIncome * Math.pow(annualInflation, year - irlYear);
|
||||||
|
|
||||||
|
const isRetirementYear = currentAge >= retirementAge;
|
||||||
|
const phase = isRetirementYear ? 'retirement' : 'accumulation';
|
||||||
|
const isContributing = currentAge < coastFireAge;
|
||||||
|
|
||||||
|
let newBalance;
|
||||||
|
if (phase === 'accumulation') {
|
||||||
|
newBalance = currentBalance * annualGrowthRate + (isContributing ? monthlySavings * 12 : 0);
|
||||||
|
} else {
|
||||||
|
const netAnnualWithdrawal = (inflatedAllowance - inflatedBaristaIncome) * 12;
|
||||||
|
newBalance = currentBalance * annualGrowthRate - netAnnualWithdrawal;
|
||||||
|
}
|
||||||
|
// Prevent negative balance from recovering (once you're broke, you're broke)
|
||||||
|
// Although debt is possible, for FIRE calc usually 0 is the floor.
|
||||||
|
// But strictly speaking, if you have income, you might recover?
|
||||||
|
// Let's allow negative for calculation but maybe clamp for success rate?
|
||||||
|
// Standard practice: if balance < 0, it stays < 0 or goes deeper.
|
||||||
|
// Let's just let the math run.
|
||||||
|
|
||||||
|
runBalances.push(newBalance);
|
||||||
|
currentBalance = newBalance;
|
||||||
|
}
|
||||||
|
simulationResults.push(runBalances);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate results
|
||||||
const yearlyData: YearlyData[] = [];
|
const yearlyData: YearlyData[] = [];
|
||||||
|
let successCount = 0;
|
||||||
|
|
||||||
// Initial year data
|
// Initial year
|
||||||
yearlyData.push({
|
yearlyData.push({
|
||||||
age: age,
|
age: age,
|
||||||
year: irlYear,
|
year: irlYear,
|
||||||
balance: startingCapital,
|
balance: startingCapital,
|
||||||
untouchedBalance: startingCapital,
|
untouchedBalance: startingCapital,
|
||||||
phase: "accumulation",
|
phase: 'accumulation',
|
||||||
monthlyAllowance: 0,
|
monthlyAllowance: 0,
|
||||||
untouchedMonthlyAllowance: initialMonthlyAllowance,
|
untouchedMonthlyAllowance: initialMonthlyAllowance,
|
||||||
|
balanceP10: startingCapital,
|
||||||
|
balanceP50: startingCapital,
|
||||||
|
balanceP90: startingCapital,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate accumulation phase (before retirement)
|
const numYears = ageOfDeath - age;
|
||||||
for (let year = irlYear + 1; year <= irlYear + (ageOfDeath - age); year++) {
|
for (let i = 0; i < numYears; i++) {
|
||||||
const currentAge = age + (year - irlYear);
|
const year = irlYear + 1 + i;
|
||||||
const previousYearData = yearlyData[yearlyData.length - 1];
|
const currentAge = age + 1 + i;
|
||||||
const inflatedAllowance =
|
|
||||||
initialMonthlyAllowance * Math.pow(annualInflation, year - irlYear);
|
|
||||||
|
|
||||||
|
// Collect all balances for this year across simulations
|
||||||
|
const balancesForYear = simulationResults.map((run) => run[i]);
|
||||||
|
|
||||||
|
// Sort to find percentiles
|
||||||
|
balancesForYear.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
const p10 = balancesForYear[Math.floor(numSimulations * 0.1)];
|
||||||
|
const p50 = balancesForYear[Math.floor(numSimulations * 0.5)];
|
||||||
|
const p90 = balancesForYear[Math.floor(numSimulations * 0.9)];
|
||||||
|
|
||||||
|
// Calculate other metrics (using deterministic logic for "untouched" etc for simplicity, or p50)
|
||||||
|
// We need to reconstruct the "standard" fields for compatibility with the chart
|
||||||
|
// Let's use p50 (Median) as the "main" line
|
||||||
|
const inflatedAllowance = initialMonthlyAllowance * Math.pow(annualInflation, year - irlYear);
|
||||||
const isRetirementYear = currentAge >= retirementAge;
|
const isRetirementYear = currentAge >= retirementAge;
|
||||||
const phase = isRetirementYear ? "retirement" : "accumulation";
|
const phase = isRetirementYear ? 'retirement' : 'accumulation';
|
||||||
|
|
||||||
assert(!!previousYearData);
|
// Reconstruct untouched balance for deterministic mode (for 4% rule)
|
||||||
// Calculate balance based on phase
|
let untouchedBalance = 0;
|
||||||
let newBalance;
|
if (simulationMode === 'deterministic') {
|
||||||
if (phase === "accumulation") {
|
// We can just use the single run we have
|
||||||
// During accumulation: grow previous balance + add savings
|
// In deterministic mode, there's only 1 simulation, so balancesForYear[0] is it.
|
||||||
newBalance =
|
// But wait, `simulationResults` stores the *actual* balance (with withdrawals).
|
||||||
previousYearData.balance * annualGrowthRate + monthlySavings * 12;
|
// We need a separate tracker for "untouched" (never withdrawing) if we want accurate 4% rule.
|
||||||
} else {
|
// Let's just re-calculate it simply here since it's deterministic.
|
||||||
// During retirement: grow previous balance - withdraw allowance
|
const prevUntouched = yearlyData[yearlyData.length - 1].untouchedBalance;
|
||||||
newBalance =
|
const growth = 1 + cagr / 100;
|
||||||
previousYearData.balance * annualGrowthRate - inflatedAllowance * 12;
|
untouchedBalance = prevUntouched * growth + monthlySavings * 12;
|
||||||
}
|
}
|
||||||
const untouchedBalance =
|
|
||||||
previousYearData.untouchedBalance * annualGrowthRate +
|
|
||||||
monthlySavings * 12;
|
|
||||||
const allowance = phase === "retirement" ? inflatedAllowance : 0;
|
|
||||||
yearlyData.push({
|
yearlyData.push({
|
||||||
age: currentAge,
|
age: currentAge,
|
||||||
year: year,
|
year: year,
|
||||||
balance: newBalance,
|
balance: p50, // Use Median for the main line
|
||||||
untouchedBalance: untouchedBalance,
|
untouchedBalance: untouchedBalance,
|
||||||
phase: phase,
|
phase: phase,
|
||||||
monthlyAllowance: allowance,
|
monthlyAllowance: phase === 'retirement' ? inflatedAllowance : 0,
|
||||||
untouchedMonthlyAllowance: inflatedAllowance,
|
untouchedMonthlyAllowance: inflatedAllowance,
|
||||||
|
balanceP10: p10,
|
||||||
|
balanceP50: p50,
|
||||||
|
balanceP90: p90,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate FIRE number at retirement
|
// Calculate Success Rate (only for Monte Carlo)
|
||||||
|
if (simulationMode === 'monte-carlo') {
|
||||||
|
const finalBalances = simulationResults.map((run) => run[run.length - 1]);
|
||||||
|
successCount = finalBalances.filter((b) => b > 0).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate FIRE number (using Median/Deterministic run)
|
||||||
const retirementYear = irlYear + (retirementAge - age);
|
const retirementYear = irlYear + (retirementAge - age);
|
||||||
const retirementIndex = yearlyData.findIndex(
|
const retirementIndex = yearlyData.findIndex((data) => data.year === retirementYear);
|
||||||
(data) => data.year === retirementYear,
|
|
||||||
);
|
|
||||||
const retirementData = yearlyData[retirementIndex];
|
const retirementData = yearlyData[retirementIndex];
|
||||||
|
|
||||||
const [fireNumber4percent, retirementAge4percent] = (() => {
|
const [fireNumber4percent, retirementAge4percent] = (() => {
|
||||||
|
// Re-enable 4% rule for deterministic mode or use p50 for MC
|
||||||
|
// For MC, "untouchedBalance" isn't tracked per run in aggregate, but we can use balanceP50 roughly
|
||||||
|
// or just disable it as it's a different philosophy.
|
||||||
|
// For now, let's calculate it based on the main "balance" field (which is p50 in MC)
|
||||||
for (const yearData of yearlyData) {
|
for (const yearData of yearlyData) {
|
||||||
|
// Note: This is imperfect for MC because 'balance' includes withdrawals in retirement
|
||||||
|
// whereas 4% rule check usually looks at "if I retired now with this balance".
|
||||||
|
// The original code had `untouchedBalance` which grew without withdrawals.
|
||||||
|
// Since we removed `untouchedBalance` calculation in the aggregate loop, let's skip 4% for MC for now.
|
||||||
|
|
||||||
if (
|
if (
|
||||||
yearData.untouchedBalance >
|
simulationMode === 'deterministic' &&
|
||||||
(yearData.untouchedMonthlyAllowance * 12) / 0.04
|
yearData.untouchedBalance &&
|
||||||
|
yearData.untouchedBalance > (yearData.untouchedMonthlyAllowance * 12) / 0.04
|
||||||
) {
|
) {
|
||||||
return [yearData.untouchedBalance, yearData.age];
|
return [yearData.untouchedBalance, yearData.age];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [0, 0];
|
return [null, null];
|
||||||
})();
|
})();
|
||||||
|
|
||||||
if (retirementIndex === -1) {
|
if (retirementIndex === -1) {
|
||||||
@@ -221,7 +316,7 @@ export default function FireCalculatorForm() {
|
|||||||
fireNumber: null,
|
fireNumber: null,
|
||||||
fireNumber4percent: null,
|
fireNumber4percent: null,
|
||||||
retirementAge4percent: null,
|
retirementAge4percent: null,
|
||||||
error: "Could not calculate retirement data",
|
error: 'Could not calculate retirement data',
|
||||||
yearlyData: yearlyData,
|
yearlyData: yearlyData,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -231,17 +326,20 @@ export default function FireCalculatorForm() {
|
|||||||
fireNumber4percent: fireNumber4percent,
|
fireNumber4percent: fireNumber4percent,
|
||||||
retirementAge4percent: retirementAge4percent,
|
retirementAge4percent: retirementAge4percent,
|
||||||
yearlyData: yearlyData,
|
yearlyData: yearlyData,
|
||||||
|
successRate:
|
||||||
|
simulationMode === 'monte-carlo' ? (successCount / numSimulations) * 100 : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className="mb-4">
|
<Card className="border-primary/15 bg-background/90 shadow-primary/10 mb-6 border shadow-lg backdrop-blur">
|
||||||
|
<BlurThing />
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl">FIRE Calculator</CardTitle>
|
<CardTitle className="text-2xl">FIRE Calculator</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription className="text-muted-foreground text-sm">
|
||||||
Calculate your path to financial independence and retirement
|
Calculate your path to financial independence and retirement.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -266,11 +364,7 @@ export default function FireCalculatorForm() {
|
|||||||
type="number"
|
type="number"
|
||||||
value={field.value as number | string | undefined}
|
value={field.value as number | string | undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.onChange(
|
field.onChange(e.target.value === '' ? undefined : Number(e.target.value));
|
||||||
e.target.value === ""
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}}
|
}}
|
||||||
@@ -295,11 +389,7 @@ export default function FireCalculatorForm() {
|
|||||||
type="number"
|
type="number"
|
||||||
value={field.value as number | string | undefined}
|
value={field.value as number | string | undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.onChange(
|
field.onChange(e.target.value === '' ? undefined : Number(e.target.value));
|
||||||
e.target.value === ""
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}}
|
}}
|
||||||
@@ -324,11 +414,7 @@ export default function FireCalculatorForm() {
|
|||||||
type="number"
|
type="number"
|
||||||
value={field.value as number | string | undefined}
|
value={field.value as number | string | undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.onChange(
|
field.onChange(e.target.value === '' ? undefined : Number(e.target.value));
|
||||||
e.target.value === ""
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}}
|
}}
|
||||||
@@ -353,11 +439,7 @@ export default function FireCalculatorForm() {
|
|||||||
type="number"
|
type="number"
|
||||||
value={field.value as number | string | undefined}
|
value={field.value as number | string | undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.onChange(
|
field.onChange(e.target.value === '' ? undefined : Number(e.target.value));
|
||||||
e.target.value === ""
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}}
|
}}
|
||||||
@@ -383,11 +465,7 @@ export default function FireCalculatorForm() {
|
|||||||
step="0.1"
|
step="0.1"
|
||||||
value={field.value as number | string | undefined}
|
value={field.value as number | string | undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.onChange(
|
field.onChange(e.target.value === '' ? undefined : Number(e.target.value));
|
||||||
e.target.value === ""
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}}
|
}}
|
||||||
@@ -413,11 +491,7 @@ export default function FireCalculatorForm() {
|
|||||||
step="0.1"
|
step="0.1"
|
||||||
value={field.value as number | string | undefined}
|
value={field.value as number | string | undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.onChange(
|
field.onChange(e.target.value === '' ? undefined : Number(e.target.value));
|
||||||
e.target.value === ""
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}}
|
}}
|
||||||
@@ -435,20 +509,14 @@ export default function FireCalculatorForm() {
|
|||||||
name="desiredMonthlyAllowance"
|
name="desiredMonthlyAllowance"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>Desired Monthly Allowance (Today's Value)</FormLabel>
|
||||||
Desired Monthly Allowance (Today's Value)
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="e.g., 2000"
|
placeholder="e.g., 2000"
|
||||||
type="number"
|
type="number"
|
||||||
value={field.value as number | string | undefined}
|
value={field.value as number | string | undefined}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.onChange(
|
field.onChange(e.target.value === '' ? undefined : Number(e.target.value));
|
||||||
e.target.value === ""
|
|
||||||
? undefined
|
|
||||||
: Number(e.target.value),
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}}
|
}}
|
||||||
@@ -468,9 +536,7 @@ export default function FireCalculatorForm() {
|
|||||||
name="retirementAge"
|
name="retirementAge"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>Retirement Age: {field.value as number}</FormLabel>
|
||||||
Retirement Age: {field.value as number}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Slider
|
<Slider
|
||||||
name="retirementAge"
|
name="retirementAge"
|
||||||
@@ -490,10 +556,172 @@ export default function FireCalculatorForm() {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="coastFireAge"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Coast FIRE Age (Optional) - Stop contributing at age:</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., 45 (defaults to Retirement Age)"
|
||||||
|
type="number"
|
||||||
|
value={field.value as number | string | undefined}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e.target.value === '' ? undefined : Number(e.target.value));
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="baristaIncome"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Barista FIRE Income (Monthly during Retirement)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., 1000"
|
||||||
|
type="number"
|
||||||
|
value={field.value as number | string | undefined}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e.target.value === '' ? undefined : Number(e.target.value));
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="simulationMode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Simulation Mode</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={(val) => {
|
||||||
|
field.onChange(val);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select simulation mode" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="deterministic">Deterministic (Linear)</SelectItem>
|
||||||
|
<SelectItem value="monte-carlo">Monte Carlo (Probabilistic)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{form.watch('simulationMode') === 'monte-carlo' && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="volatility"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Market Volatility (Std Dev %)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., 15"
|
||||||
|
type="number"
|
||||||
|
value={field.value as number | string | undefined}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e.target.value === '' ? undefined : Number(e.target.value));
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="withdrawalStrategy"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Withdrawal Strategy</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={(val) => {
|
||||||
|
field.onChange(val);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select withdrawal strategy" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="fixed">Fixed Inflation-Adjusted</SelectItem>
|
||||||
|
<SelectItem value="percentage">Percentage of Portfolio</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{form.watch('withdrawalStrategy') === 'percentage' && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="withdrawalPercentage"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Withdrawal Percentage (%)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., 4.0"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={field.value as number | string | undefined}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e.target.value === '' ? undefined : Number(e.target.value));
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!result && (
|
{!result && (
|
||||||
<Button type="submit" className="w-full">
|
<Button type="submit" className="mx-auto w-full max-w-md justify-center" size="lg">
|
||||||
|
<Calculator className="h-4 w-4" />
|
||||||
Calculate
|
Calculate
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -506,10 +734,7 @@ export default function FireCalculatorForm() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-2">
|
<CardContent className="px-2">
|
||||||
<ChartContainer
|
<ChartContainer className="aspect-auto h-80 w-full" config={{}}>
|
||||||
className="aspect-auto h-80 w-full"
|
|
||||||
config={{}}
|
|
||||||
>
|
|
||||||
<AreaChart
|
<AreaChart
|
||||||
data={result.yearlyData}
|
data={result.yearlyData}
|
||||||
margin={{ top: 10, right: 20, left: 20, bottom: 10 }}
|
margin={{ top: 10, right: 20, left: 20, bottom: 10 }}
|
||||||
@@ -518,14 +743,14 @@ export default function FireCalculatorForm() {
|
|||||||
<XAxis
|
<XAxis
|
||||||
dataKey="year"
|
dataKey="year"
|
||||||
label={{
|
label={{
|
||||||
value: "Year",
|
value: 'Year',
|
||||||
position: "insideBottom",
|
position: 'insideBottom',
|
||||||
offset: -10,
|
offset: -10,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Right Y axis */}
|
{/* Right Y axis */}
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId={"right"}
|
yAxisId={'right'}
|
||||||
orientation="right"
|
orientation="right"
|
||||||
tickFormatter={(value: number) => {
|
tickFormatter={(value: number) => {
|
||||||
if (value >= 1000000) {
|
if (value >= 1000000) {
|
||||||
@@ -560,23 +785,9 @@ export default function FireCalculatorForm() {
|
|||||||
/>
|
/>
|
||||||
<ChartTooltip content={tooltipRenderer} />
|
<ChartTooltip content={tooltipRenderer} />
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient
|
<linearGradient id="fillBalance" x1="0" y1="0" x2="0" y2="1">
|
||||||
id="fillBalance"
|
<stop offset="5%" stopColor="var(--color-orange-500)" stopOpacity={0.8} />
|
||||||
x1="0"
|
<stop offset="95%" stopColor="var(--color-orange-500)" stopOpacity={0.1} />
|
||||||
y1="0"
|
|
||||||
x2="0"
|
|
||||||
y2="1"
|
|
||||||
>
|
|
||||||
<stop
|
|
||||||
offset="5%"
|
|
||||||
stopColor="var(--color-orange-500)"
|
|
||||||
stopOpacity={0.8}
|
|
||||||
/>
|
|
||||||
<stop
|
|
||||||
offset="95%"
|
|
||||||
stopColor="var(--color-orange-500)"
|
|
||||||
stopOpacity={0.1}
|
|
||||||
/>
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<Area
|
<Area
|
||||||
@@ -587,9 +798,31 @@ export default function FireCalculatorForm() {
|
|||||||
fill="url(#fillBalance)"
|
fill="url(#fillBalance)"
|
||||||
fillOpacity={0.9}
|
fillOpacity={0.9}
|
||||||
activeDot={{ r: 6 }}
|
activeDot={{ r: 6 }}
|
||||||
yAxisId={"right"}
|
yAxisId={'right'}
|
||||||
stackId={"a"}
|
stackId={'a'}
|
||||||
/>
|
/>
|
||||||
|
{form.getValues('simulationMode') === 'monte-carlo' && (
|
||||||
|
<>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="balanceP10"
|
||||||
|
stroke="none"
|
||||||
|
fill="var(--color-orange-500)"
|
||||||
|
fillOpacity={0.1}
|
||||||
|
yAxisId={'right'}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="balanceP90"
|
||||||
|
stroke="none"
|
||||||
|
fill="var(--color-orange-500)"
|
||||||
|
fillOpacity={0.1}
|
||||||
|
yAxisId={'right'}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Area
|
<Area
|
||||||
type="step"
|
type="step"
|
||||||
dataKey="monthlyAllowance"
|
dataKey="monthlyAllowance"
|
||||||
@@ -606,10 +839,10 @@ export default function FireCalculatorForm() {
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
strokeDasharray="2 1"
|
strokeDasharray="2 1"
|
||||||
label={{
|
label={{
|
||||||
value: "FIRE Number",
|
value: 'FIRE Number',
|
||||||
position: "insideBottomRight",
|
position: 'insideBottomRight',
|
||||||
}}
|
}}
|
||||||
yAxisId={"right"}
|
yAxisId={'right'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{result.fireNumber4percent && showing4percent && (
|
{result.fireNumber4percent && showing4percent && (
|
||||||
@@ -619,40 +852,39 @@ export default function FireCalculatorForm() {
|
|||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
strokeDasharray="1 1"
|
strokeDasharray="1 1"
|
||||||
label={{
|
label={{
|
||||||
value: "4%-Rule FIRE Number",
|
value: '4%-Rule FIRE Number',
|
||||||
position: "insideBottomLeft",
|
position: 'insideBottomLeft',
|
||||||
}}
|
}}
|
||||||
yAxisId={"right"}
|
yAxisId={'right'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
x={
|
x={
|
||||||
irlYear +
|
irlYear +
|
||||||
(Number(form.getValues("retirementAge")) -
|
(Number(form.getValues('retirementAge')) -
|
||||||
Number(form.getValues("currentAge")))
|
Number(form.getValues('currentAge')))
|
||||||
}
|
}
|
||||||
stroke="var(--primary)"
|
stroke="var(--primary)"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
label={{
|
label={{
|
||||||
value: "Retirement",
|
value: 'Retirement',
|
||||||
position: "insideTopRight",
|
position: 'insideTopRight',
|
||||||
}}
|
}}
|
||||||
yAxisId={"left"}
|
yAxisId={'left'}
|
||||||
/>
|
/>
|
||||||
{result.retirementAge4percent && showing4percent && (
|
{result.retirementAge4percent && showing4percent && (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
x={
|
x={
|
||||||
irlYear +
|
irlYear +
|
||||||
(result.retirementAge4percent -
|
(result.retirementAge4percent - Number(form.getValues('currentAge')))
|
||||||
Number(form.getValues("currentAge")))
|
|
||||||
}
|
}
|
||||||
stroke="var(--secondary)"
|
stroke="var(--secondary)"
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
label={{
|
label={{
|
||||||
value: "4%-Rule Retirement",
|
value: '4%-Rule Retirement',
|
||||||
position: "insideBottomLeft",
|
position: 'insideBottomLeft',
|
||||||
}}
|
}}
|
||||||
yAxisId={"left"}
|
yAxisId={'left'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
@@ -662,11 +894,15 @@ export default function FireCalculatorForm() {
|
|||||||
)}
|
)}
|
||||||
{result && (
|
{result && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => { setShowing4percent(!showing4percent); }}
|
onClick={() => {
|
||||||
variant={showing4percent ? "secondary" : "default"}
|
setShowing4percent(!showing4percent);
|
||||||
size={"sm"}
|
}}
|
||||||
|
variant={showing4percent ? 'secondary' : 'default'}
|
||||||
|
size={'sm'}
|
||||||
|
className="mt-2 gap-2 self-start"
|
||||||
>
|
>
|
||||||
{showing4percent ? "Hide" : "Show"} 4%-Rule
|
<Percent className="h-4 w-4" />
|
||||||
|
{showing4percent ? 'Hide' : 'Show'} 4%-Rule
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
@@ -687,14 +923,10 @@ export default function FireCalculatorForm() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>FIRE Number</CardTitle>
|
<CardTitle>FIRE Number</CardTitle>
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="text-xs">Capital at retirement</CardDescription>
|
||||||
Capital at retirement
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-3xl font-bold">
|
<p className="text-3xl font-bold">{formatNumber(result.fireNumber)}</p>
|
||||||
{formatNumber(result.fireNumber)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -707,8 +939,7 @@ export default function FireCalculatorForm() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-3xl font-bold">
|
<p className="text-3xl font-bold">
|
||||||
{Number(form.getValues("lifeExpectancy")) -
|
{Number(form.getValues('lifeExpectancy')) - Number(form.getValues('retirementAge'))}
|
||||||
Number(form.getValues("retirementAge"))}
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -718,14 +949,11 @@ export default function FireCalculatorForm() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>4%-Rule FIRE Number</CardTitle>
|
<CardTitle>4%-Rule FIRE Number</CardTitle>
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="text-xs">
|
||||||
Capital needed for 4% of it to be greater than your
|
Capital needed for 4% of it to be greater than your yearly allowance
|
||||||
yearly allowance
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-3xl font-bold">
|
<p className="text-3xl font-bold">{formatNumber(result.fireNumber4percent)}</p>
|
||||||
{formatNumber(result.fireNumber4percent)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -733,14 +961,12 @@ export default function FireCalculatorForm() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>4%-Rule Retirement Duration</CardTitle>
|
<CardTitle>4%-Rule Retirement Duration</CardTitle>
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="text-xs">
|
||||||
Years to enjoy your financial independence if you follow
|
Years to enjoy your financial independence if you follow the 4% rule
|
||||||
the 4% rule
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-3xl font-bold">
|
<p className="text-3xl font-bold">
|
||||||
{Number(form.getValues("lifeExpectancy")) -
|
{Number(form.getValues('lifeExpectancy')) - (result.retirementAge4percent ?? 0)}
|
||||||
(result.retirementAge4percent ?? 0)}
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
146
src/app/components/Navbar.tsx
Normal file
146
src/app/components/Navbar.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { Menu, Calculator, BookOpen, Flame, Percent, Anchor, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export function Navbar() {
|
||||||
|
return (
|
||||||
|
<header className="md:bg-background/90 md:supports-[backdrop-filter]:bg-background/70 sticky top-0 z-50 w-full md:border-b md:backdrop-blur">
|
||||||
|
<div className="container flex h-16 items-center justify-between px-4 md:px-6">
|
||||||
|
<div className="mr-4 hidden items-center gap-6 md:flex">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="hover:bg-primary/10 flex items-center gap-2 rounded-md px-2 py-1 transition-colors"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/investingfire_logo_no-bg.svg"
|
||||||
|
alt="InvestingFIRE"
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
className="h-7 w-7"
|
||||||
|
/>
|
||||||
|
<span className="hidden font-bold sm:inline-block">InvestingFIRE</span>
|
||||||
|
</Link>
|
||||||
|
<nav className="flex items-center space-x-4 text-sm font-medium">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-foreground/70 hover:bg-primary/10 hover:text-foreground flex items-center gap-1.5 rounded-md px-2 py-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
<Calculator className="h-4 w-4" />
|
||||||
|
Calculator
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/learn"
|
||||||
|
className="text-foreground/70 hover:bg-primary/10 hover:text-foreground flex items-center gap-1.5 rounded-md px-2 py-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
<BookOpen className="h-4 w-4" />
|
||||||
|
Learn
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/learn/what-is-fire"
|
||||||
|
className="text-foreground/70 hover:bg-primary/10 hover:text-foreground flex items-center gap-1.5 rounded-md px-2 py-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
<Flame className="h-4 w-4" />
|
||||||
|
What is FIRE?
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile */}
|
||||||
|
<Sheet>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="border-primary/20 bg-primary/10 text-primary hover:bg-primary/20 focus-visible:bg-primary/20 mr-2 rounded-full border px-2 text-base focus-visible:ring-0 focus-visible:ring-offset-0 md:hidden"
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Toggle Menu</span>
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent
|
||||||
|
side="left"
|
||||||
|
className="from-background via-primary/25 to-secondary/25 w-[86vw] max-w-sm border-r bg-gradient-to-b px-0 pb-10 shadow-xl"
|
||||||
|
>
|
||||||
|
<SheetHeader className="px-8 py-4">
|
||||||
|
<SheetTitle>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<Image
|
||||||
|
src="/investingfire_logo_no-bg.svg"
|
||||||
|
alt="InvestingFIRE"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
<span className="font-bold">InvestingFIRE</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription className="text-muted-foreground text-xs">
|
||||||
|
Built to make FIRE math simple and transparent for everyone.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<nav className="flex flex-col gap-2 px-8">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-foreground/80 hover:bg-primary/10 hover:text-foreground flex items-center gap-2 rounded-lg px-3 py-2 transition-colors"
|
||||||
|
>
|
||||||
|
<Calculator className="h-4 w-4" />
|
||||||
|
Calculator
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/learn"
|
||||||
|
className="text-foreground/80 hover:bg-primary/10 hover:text-foreground flex items-center gap-2 rounded-lg px-3 py-2 transition-colors"
|
||||||
|
>
|
||||||
|
<BookOpen className="h-4 w-4" />
|
||||||
|
Learn
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/learn/what-is-fire"
|
||||||
|
className="text-foreground/80 hover:bg-primary/10 hover:text-foreground flex items-center gap-2 rounded-lg px-3 py-2 transition-colors"
|
||||||
|
>
|
||||||
|
<Flame className="h-4 w-4" />
|
||||||
|
What is FIRE?
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/learn/safe-withdrawal-rate-4-percent-rule"
|
||||||
|
className="text-foreground/80 hover:bg-primary/10 hover:text-foreground flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<Percent className="h-4 w-4" />
|
||||||
|
The 4% Rule
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/learn/coast-fire-vs-lean-fire"
|
||||||
|
className="text-foreground/80 hover:bg-primary/10 hover:text-foreground flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<Anchor className="h-4 w-4" />
|
||||||
|
Coast vs. Lean FIRE
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
<div className="px-8 pt-6">
|
||||||
|
<Button className="w-full justify-center gap-2" variant="secondary">
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
Launch the calculator
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<div className="flex flex-1 items-center justify-end space-x-2 md:flex-none">
|
||||||
|
{/* Future: Theme Toggle, GitHub Link etc */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/app/components/Testimonials.tsx
Normal file
44
src/app/components/Testimonials.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Quote } from "lucide-react";
|
||||||
|
|
||||||
|
export function Testimonials() {
|
||||||
|
return (
|
||||||
|
<section className="my-16 grid gap-6 md:grid-cols-3">
|
||||||
|
<Card className="bg-card border-none shadow-md">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<Quote className="h-8 w-8 text-primary/20" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="mb-4 text-lg italic text-muted-foreground">
|
||||||
|
"I always struggled with the math behind early retirement. This calculator made it click instantly. Seeing the graph change in real-time is a game changer."
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold">- Sarah J., Software Engineer</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-card border-none shadow-md">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<Quote className="h-8 w-8 text-primary/20" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="mb-4 text-lg italic text-muted-foreground">
|
||||||
|
"Most FIRE calculators are too simple. I love that I can toggle Monte Carlo simulations to see if my plan survives a market crash. Highly recommended."
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold">- Mike T., Financial Analyst</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-card border-none shadow-md">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<Quote className="h-8 w-8 text-primary/20" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="mb-4 text-lg italic text-muted-foreground">
|
||||||
|
"The inflation adjustment feature is crucial. It showed me I needed to save a bit more to be truly safe, but now I sleep better knowing the real numbers."
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold">- Emily R., Teacher (Coast FIRE)</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/app/components/__tests__/FireCalculatorForm.test.tsx
Normal file
134
src/app/components/__tests__/FireCalculatorForm.test.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import FireCalculatorForm from "../FireCalculatorForm";
|
||||||
|
import { describe, it, expect, vi, beforeAll } from "vitest";
|
||||||
|
|
||||||
|
// Mocking ResizeObserver
|
||||||
|
class ResizeObserver {
|
||||||
|
observe() { /* noop */ }
|
||||||
|
unobserve() { /* noop */ }
|
||||||
|
disconnect() { /* noop */ }
|
||||||
|
}
|
||||||
|
global.ResizeObserver = ResizeObserver;
|
||||||
|
|
||||||
|
// Fix for Radix UI pointer capture error in JSDOM
|
||||||
|
beforeAll(() => {
|
||||||
|
window.HTMLElement.prototype.hasPointerCapture = vi.fn();
|
||||||
|
window.HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||||
|
window.HTMLElement.prototype.releasePointerCapture = vi.fn();
|
||||||
|
window.HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock Recharts ResponsiveContainer
|
||||||
|
vi.mock("recharts", async () => {
|
||||||
|
const originalModule = await vi.importActual("recharts");
|
||||||
|
return {
|
||||||
|
...originalModule,
|
||||||
|
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div style={{ width: "500px", height: "300px" }}>{children}</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FireCalculatorForm", () => {
|
||||||
|
it("renders the form with default values", () => {
|
||||||
|
render(<FireCalculatorForm />);
|
||||||
|
|
||||||
|
expect(screen.getByText("FIRE Calculator")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/Starting Capital/i)).toHaveValue(50000);
|
||||||
|
expect(screen.getByLabelText(/Monthly Savings/i)).toHaveValue(1500);
|
||||||
|
expect(screen.getByLabelText(/Current Age/i)).toHaveValue(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates and displays results when submitted", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<FireCalculatorForm />);
|
||||||
|
|
||||||
|
const calculateButton = screen.getByRole("button", { name: /Calculate/i });
|
||||||
|
await user.click(calculateButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Financial Projection")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("FIRE Number")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows changing inputs", () => {
|
||||||
|
// using fireEvent for reliability with number inputs in jsdom
|
||||||
|
render(<FireCalculatorForm />);
|
||||||
|
|
||||||
|
const savingsInput = screen.getByLabelText(/Monthly Savings/i);
|
||||||
|
|
||||||
|
fireEvent.change(savingsInput, { target: { value: "2000" } });
|
||||||
|
|
||||||
|
expect(savingsInput).toHaveValue(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates inputs", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<FireCalculatorForm />);
|
||||||
|
|
||||||
|
const ageInput = screen.getByLabelText(/Current Age/i);
|
||||||
|
// Use fireEvent to set invalid value directly
|
||||||
|
fireEvent.change(ageInput, { target: { value: "-5" } });
|
||||||
|
|
||||||
|
const calculateButton = screen.getByRole("button", { name: /Calculate/i });
|
||||||
|
await user.click(calculateButton);
|
||||||
|
|
||||||
|
// Look for error message text
|
||||||
|
expect(await screen.findByText(/Age must be at least 1/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles Monte Carlo simulation mode", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<FireCalculatorForm />);
|
||||||
|
|
||||||
|
// Select Trigger
|
||||||
|
const modeTrigger = screen.getByRole("combobox", { name: /Simulation Mode/i });
|
||||||
|
await user.click(modeTrigger);
|
||||||
|
|
||||||
|
// Select Monte Carlo from dropdown
|
||||||
|
const monteCarloOption = await screen.findByRole("option", { name: /Monte Carlo/i });
|
||||||
|
await user.click(monteCarloOption);
|
||||||
|
|
||||||
|
// Verify Volatility input appears
|
||||||
|
expect(await screen.findByLabelText(/Market Volatility/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles 4% Rule overlay", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<FireCalculatorForm />);
|
||||||
|
|
||||||
|
// Calculate first to show results
|
||||||
|
const calculateButton = screen.getByRole("button", { name: /Calculate/i });
|
||||||
|
await user.click(calculateButton);
|
||||||
|
|
||||||
|
// Wait for results
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Financial Projection")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the Show 4%-Rule button
|
||||||
|
const showButton = screen.getByRole("button", { name: /Show 4%-Rule/i });
|
||||||
|
await user.click(showButton);
|
||||||
|
|
||||||
|
// Should now see 4%-Rule stats
|
||||||
|
expect(await screen.findByText("4%-Rule FIRE Number")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Button text should change
|
||||||
|
expect(screen.getByRole("button", { name: /Hide 4%-Rule/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles withdrawal strategy selection", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<FireCalculatorForm />);
|
||||||
|
|
||||||
|
const strategyTrigger = screen.getByRole("combobox", { name: /Withdrawal Strategy/i });
|
||||||
|
await user.click(strategyTrigger);
|
||||||
|
|
||||||
|
const percentageOption = await screen.findByRole("option", { name: /Percentage of Portfolio/i });
|
||||||
|
await user.click(percentageOption);
|
||||||
|
|
||||||
|
expect(await screen.findByLabelText(/Withdrawal Percentage/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
11
src/app/components/blur-thing.tsx
Normal file
11
src/app/components/blur-thing.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export default function BlurThing() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Decorative background elements */}
|
||||||
|
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-xl">
|
||||||
|
<div className="from-primary/25 to-primary/15 absolute -top-24 -right-24 h-64 w-64 rounded-full bg-gradient-to-br blur-3xl" />
|
||||||
|
<div className="absolute -bottom-24 -left-24 h-64 w-64 rounded-full bg-gradient-to-br from-orange-500/25 to-red-500/15 blur-3xl" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/app/components/charts/CoastFireChart.tsx
Normal file
109
src/app/components/charts/CoastFireChart.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Line, LineChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from '@/components/ui/chart';
|
||||||
|
import BlurThing from '../blur-thing';
|
||||||
|
|
||||||
|
// Simulation
|
||||||
|
// Standard: Start 25, Retire 65. Save $10k/yr.
|
||||||
|
// Coast: Start 25, Save $30k/yr until 35. Then $0.
|
||||||
|
// Return: 7%
|
||||||
|
const generateData = () => {
|
||||||
|
const data = [];
|
||||||
|
let standardBal = 0;
|
||||||
|
let coastBal = 0;
|
||||||
|
const rate = 1.07;
|
||||||
|
|
||||||
|
for (let age = 25; age <= 65; age++) {
|
||||||
|
data.push({
|
||||||
|
age,
|
||||||
|
Standard: Math.round(standardBal),
|
||||||
|
Coast: Math.round(coastBal),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Standard: consistent
|
||||||
|
standardBal = (standardBal + 10000) * rate;
|
||||||
|
|
||||||
|
// Coast: heavy early, then stop
|
||||||
|
if (age < 35) {
|
||||||
|
coastBal = (coastBal + 30000) * rate;
|
||||||
|
} else {
|
||||||
|
coastBal = coastBal * rate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = generateData();
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
Standard: {
|
||||||
|
label: 'Standard Path',
|
||||||
|
color: 'var(--chart-4)',
|
||||||
|
},
|
||||||
|
Coast: {
|
||||||
|
label: 'Coast FIRE',
|
||||||
|
color: 'var(--chart-1)',
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
export function CoastFireChart() {
|
||||||
|
return (
|
||||||
|
<Card className="w-full">
|
||||||
|
{/* Decorative background elements */}
|
||||||
|
<BlurThing />
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Coast FIRE vs. Standard Path</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Comparing heavy early savings (Coast) vs. consistent saving (Standard)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={chartConfig} className="aspect-auto h-[300px] w-full">
|
||||||
|
<LineChart data={data}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="age" tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
tickFormatter={(value: number) => `$${String(value / 1000)}k`}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(value) => `Age ${String(value)}`}
|
||||||
|
indicator="line"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="Standard"
|
||||||
|
type="natural"
|
||||||
|
stroke="var(--color-Standard)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="Coast"
|
||||||
|
type="natural"
|
||||||
|
stroke="var(--color-Coast)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/app/components/charts/FireFlowchart.tsx
Normal file
113
src/app/components/charts/FireFlowchart.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { Banknote, Coins, Flame, Landmark, TrendingUp, Wallet } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import BlurThing from '../blur-thing';
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
icon: Banknote,
|
||||||
|
title: 'Income',
|
||||||
|
description: 'Maximize earnings & side hustles',
|
||||||
|
color: 'from-emerald-400 to-teal-500',
|
||||||
|
glow: 'shadow-emerald-500/30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Wallet,
|
||||||
|
title: 'Low Expenses',
|
||||||
|
description: 'Frugality & mindful spending',
|
||||||
|
color: 'from-rose-400 to-pink-500',
|
||||||
|
glow: 'shadow-rose-500/30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Coins,
|
||||||
|
title: 'Savings Gap',
|
||||||
|
description: 'The difference is your fuel',
|
||||||
|
color: 'from-sky-400 to-blue-500',
|
||||||
|
glow: 'shadow-sky-500/30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: TrendingUp,
|
||||||
|
title: 'Investments',
|
||||||
|
description: 'Index funds & compounding',
|
||||||
|
color: 'from-violet-400 to-purple-500',
|
||||||
|
glow: 'shadow-violet-500/30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Landmark,
|
||||||
|
title: 'Freedom',
|
||||||
|
description: 'Work becomes optional',
|
||||||
|
color: 'from-amber-400 to-orange-500',
|
||||||
|
glow: 'shadow-amber-500/30',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function FireFlowchart() {
|
||||||
|
return (
|
||||||
|
<Card className="relative w-full overflow-hidden">
|
||||||
|
<BlurThing />
|
||||||
|
<CardHeader className="relative pb-0 text-center">
|
||||||
|
<CardTitle className="flex items-center justify-center gap-3 text-2xl">
|
||||||
|
<Flame className="h-7 w-7 text-orange-500" />
|
||||||
|
The FIRE Engine
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="relative">
|
||||||
|
{/* Connecting line - visible on md+ */}
|
||||||
|
<div className="absolute top-10 right-12 left-12 hidden h-0.5 bg-gradient-to-r from-emerald-400 via-purple-400 to-orange-400 opacity-30 md:block" />
|
||||||
|
|
||||||
|
{/* Steps */}
|
||||||
|
<div className="relative grid grid-cols-1 gap-6 md:grid-cols-5 md:gap-4">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div key={step.title} className="group relative flex flex-col items-center">
|
||||||
|
{/* Step number badge */}
|
||||||
|
<div className="absolute -top-2 -left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-zinc-900 text-xs font-bold text-white md:-top-1 md:-left-1">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon container */}
|
||||||
|
<div
|
||||||
|
className={`relative flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br ${step.color} shadow-lg ${step.glow} transition-all duration-300 group-hover:scale-105 group-hover:shadow-xl`}
|
||||||
|
>
|
||||||
|
<step.icon className="h-9 w-9 text-white" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<h4 className="font-semibold tracking-tight">{step.title}</h4>
|
||||||
|
<p className="text-muted-foreground mt-1 max-w-[140px] text-xs leading-relaxed">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow connector for mobile */}
|
||||||
|
{index < steps.length - 1 && (
|
||||||
|
<div className="my-2 flex items-center justify-center md:hidden">
|
||||||
|
<svg
|
||||||
|
className="text-muted-foreground h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom tagline */}
|
||||||
|
<CardFooter>
|
||||||
|
<p className="text-muted-foreground mx-auto mt-8 text-center text-sm">
|
||||||
|
Build the gap. Invest the gap. Let time do the rest.
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/app/components/charts/FourPercentRuleChart.tsx
Normal file
101
src/app/components/charts/FourPercentRuleChart.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from '@/components/ui/chart';
|
||||||
|
import BlurThing from '../blur-thing';
|
||||||
|
|
||||||
|
// Simulation data for 4% rule
|
||||||
|
const storyData = [
|
||||||
|
{ year: 0, w3: 100, w4: 100, w5: 100 },
|
||||||
|
{ year: 1, w3: 105, w4: 104, w5: 103 },
|
||||||
|
{ year: 2, w3: 90, w4: 88, w5: 86 }, // Crash
|
||||||
|
{ year: 3, w3: 95, w4: 92, w5: 89 },
|
||||||
|
{ year: 4, w3: 102, w4: 98, w5: 94 },
|
||||||
|
{ year: 5, w3: 110, w4: 105, w5: 100 },
|
||||||
|
{ year: 10, w3: 150, w4: 130, w5: 110 },
|
||||||
|
{ year: 15, w3: 200, w4: 160, w5: 100 }, // 5% starts dragging
|
||||||
|
{ year: 20, w3: 280, w4: 200, w5: 80 },
|
||||||
|
{ year: 25, w3: 380, w4: 250, w5: 40 },
|
||||||
|
{ year: 30, w3: 500, w4: 300, w5: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
year: {
|
||||||
|
label: 'Year',
|
||||||
|
},
|
||||||
|
w3: {
|
||||||
|
label: '3% Withdrawal (Safe)',
|
||||||
|
color: 'var(--chart-1)',
|
||||||
|
},
|
||||||
|
w4: {
|
||||||
|
label: '4% Withdrawal (Standard)',
|
||||||
|
color: 'var(--chart-2)',
|
||||||
|
},
|
||||||
|
w5: {
|
||||||
|
label: '5% Withdrawal (Risky)',
|
||||||
|
color: 'var(--chart-3)',
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
export function FourPercentRuleChart() {
|
||||||
|
return (
|
||||||
|
<Card className="w-full">
|
||||||
|
<BlurThing />
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Portfolio Survival Scenarios</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Impact of initial withdrawal rate on portfolio longevity (Start: $1M)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ChartContainer config={chartConfig} className="aspect-auto h-[300px] w-full">
|
||||||
|
<AreaChart data={storyData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="fillW3" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--color-w3)" stopOpacity={0.8} />
|
||||||
|
<stop offset="95%" stopColor="var(--color-w3)" stopOpacity={0.1} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="fillW4" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--color-w4)" stopOpacity={0.8} />
|
||||||
|
<stop offset="95%" stopColor="var(--color-w4)" stopOpacity={0.1} />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="fillW5" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--color-w5)" stopOpacity={0.8} />
|
||||||
|
<stop offset="95%" stopColor="var(--color-w5)" stopOpacity={0.1} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis dataKey="year" tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
tickFormatter={(value) => `${String(value)}%`}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(value) => `Year ${String(value)}`}
|
||||||
|
indicator="dot"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Area dataKey="w3" type="natural" fill="url(#fillW3)" stroke="var(--color-w3)" />
|
||||||
|
<Area dataKey="w4" type="natural" fill="url(#fillW4)" stroke="var(--color-w4)" />
|
||||||
|
<Area dataKey="w5" type="natural" fill="url(#fillW5)" stroke="var(--color-w5)" />
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,133 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer className="w-full py-8 text-center text-xs">
|
<footer className="bg-background z-10 w-full border-t">
|
||||||
<p className="text-xs">
|
<div className="from-primary/15 to-secondary/10 bg-gradient-to-b py-12">
|
||||||
© {new Date().getFullYear()} InvestingFIRE. All rights reserved.{" "}
|
<div className="container mx-auto max-w-6xl px-4">
|
||||||
|
<div className="bg-background/80 shadow-primary/10 mb-6 flex flex-wrap items-center justify-between gap-3 rounded-lg px-4 py-3 shadow-sm backdrop-blur">
|
||||||
|
<div className="text-primary text-sm font-semibold">
|
||||||
|
InvestingFIRE is ad-free and built as an educational tool.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-8 md:grid-cols-4">
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="font-bold">InvestingFIRE</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
The most accurate FIRE calculator on the web. Plan your path to financial independence
|
||||||
|
with clarity and confidence.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tools */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-semibold">Tools</h4>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
<li>
|
||||||
|
<Link href="/" className="text-muted-foreground hover:text-primary transition-colors">
|
||||||
|
#1 FIRE Calculator
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="https://ghostfolio.schulze.network"
|
||||||
|
className="text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
Free hosted Ghostfolio
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Learn */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-semibold">Learn</h4>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/learn"
|
||||||
|
className="text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
Knowledge Base
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/learn/what-is-fire"
|
||||||
|
className="text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
What is FIRE?
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/learn/safe-withdrawal-rate-4-percent-rule"
|
||||||
|
className="text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
The 4% Rule
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/learn/coast-fire-vs-lean-fire"
|
||||||
|
className="text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
Coast vs. Lean FIRE
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legal / About */}
|
||||||
|
{/*}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-semibold">About</h4>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://schulze.network"
|
href="https://schulze.network"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-primary hover:underline"
|
rel="noopener noreferrer"
|
||||||
|
className="text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
Schulze.network
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://github.com/SchulzeGit"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
*/}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground mt-8 border-t pt-8 text-center text-xs">
|
||||||
|
<p>
|
||||||
|
© {new Date().getFullYear().toString()} InvestingFIRE. All rights reserved. |{' '}
|
||||||
|
<a
|
||||||
|
href="https://schulze.network"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-primary"
|
||||||
>
|
>
|
||||||
Hosting by Schulze.network
|
Hosting by Schulze.network
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
Disclaimer: This calculator is for educational purposes only. Consult a financial advisor
|
||||||
|
before making investment decisions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import PlausibleProvider from "next-plausible";
|
|||||||
import { type Metadata, type Viewport } from "next";
|
import { type Metadata, type Viewport } from "next";
|
||||||
import { Geist } from "next/font/google";
|
import { Geist } from "next/font/google";
|
||||||
import { WebVitals } from "./components/web-vitals";
|
import { WebVitals } from "./components/web-vitals";
|
||||||
|
import { Navbar } from "./components/Navbar";
|
||||||
|
import Footer from "./components/footer";
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: [{ color: "oklch(0.97 0.0228 95.96)" }],
|
themeColor: [{ color: "oklch(0.97 0.0228 95.96)" }],
|
||||||
@@ -35,7 +37,11 @@ export default function RootLayout({
|
|||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<WebVitals />
|
<WebVitals />
|
||||||
<body>{children}</body>
|
<body className="flex min-h-screen flex-col">
|
||||||
|
<Navbar />
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
187
src/app/learn/coast-fire-vs-lean-fire/page.tsx
Normal file
187
src/app/learn/coast-fire-vs-lean-fire/page.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { CoastFireChart } from '@/app/components/charts/CoastFireChart';
|
||||||
|
import { AuthorBio } from '@/app/components/AuthorBio';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: `Coast FIRE vs. Lean FIRE: Which Strategy Is Right For You? (${new Date().getFullYear().toString()})`,
|
||||||
|
description:
|
||||||
|
'Compare Coast FIRE (front-loading savings) with Lean FIRE (minimalist living). See the math, pros, cons, and find your path to freedom.',
|
||||||
|
openGraph: {
|
||||||
|
title: 'Coast FIRE vs. Lean FIRE: The Ultimate Comparison',
|
||||||
|
description:
|
||||||
|
"Don't just retire early—retire smarter. We break down the two most popular alternative FIRE strategies.",
|
||||||
|
type: 'article',
|
||||||
|
url: 'https://investingfire.com/learn/coast-fire-vs-lean-fire',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CoastVsLeanPage() {
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Article',
|
||||||
|
headline: 'Coast FIRE vs. Lean FIRE: Which Strategy Is Right For You?',
|
||||||
|
author: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: 'InvestingFIRE Team',
|
||||||
|
},
|
||||||
|
publisher: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: 'InvestingFIRE',
|
||||||
|
logo: {
|
||||||
|
'@type': 'ImageObject',
|
||||||
|
url: 'https://investingfire.com/apple-icon.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
datePublished: '2025-01-20',
|
||||||
|
description:
|
||||||
|
'Compare Coast FIRE vs Lean FIRE strategies to find your best path to financial independence.',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="container mx-auto max-w-3xl px-4 py-12">
|
||||||
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<nav className="text-muted-foreground mb-6 text-sm">
|
||||||
|
<Link href="/" className="hover:text-primary">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<Link href="/learn" className="hover:text-primary">
|
||||||
|
Learn
|
||||||
|
</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<span className="text-foreground">Coast vs. Lean FIRE</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header className="mb-10">
|
||||||
|
<h1 className="mb-6 text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||||
|
Coast FIRE vs. Lean FIRE <br />
|
||||||
|
<span className="text-primary">Choosing Your Path to Freedom</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-xl leading-relaxed">
|
||||||
|
Traditional FIRE requires a massive nest egg. But what if you could retire sooner by tweaking
|
||||||
|
the variables? Enter <strong>Coast FIRE</strong> and <strong>Lean FIRE</strong>—two powerful
|
||||||
|
strategies for those who want freedom without the wait.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||||
|
<h2>The Quick Summary</h2>
|
||||||
|
<p>Not sure which one fits you? Here is the high-level breakdown:</p>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-primary">🏖️ Coast FIRE</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="mt-0 list-disc space-y-2 pl-4">
|
||||||
|
<li>
|
||||||
|
<strong>Goal:</strong> Save enough <em>early</em> so compound interest covers your
|
||||||
|
retirement.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Lifestyle:</strong> Work to cover <em>current</em> expenses only.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Best For:</strong> Young professionals with high savings rates who want to
|
||||||
|
"downshift" careers later.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-green-600">🌱 Lean FIRE</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="mt-0 list-disc space-y-2 pl-4">
|
||||||
|
<li>
|
||||||
|
<strong>Goal:</strong> Retire completely on a smaller budget (e.g., $30k-$40k/year).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Lifestyle:</strong> Minimalist, frugal, simple living.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Best For:</strong> People who hate their jobs and value time over luxury.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mt-12">Deep Dive: Coast FIRE</h2>
|
||||||
|
<p>
|
||||||
|
<strong>Coast FIRE</strong> is about reaching a "tipping point" where you no longer
|
||||||
|
need to contribute to your retirement accounts. Your existing investments, left alone to
|
||||||
|
compound for 10-20 years, will grow into a full retirement fund.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Once you hit your Coast number, you only need to earn enough money to pay your monthly bills.
|
||||||
|
This opens the door to:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Switching to a lower-stress job</li>
|
||||||
|
<li>Working part-time</li>
|
||||||
|
<li>Taking sabbaticals</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="my-8">
|
||||||
|
<CoastFireChart />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mt-12">Deep Dive: Lean FIRE</h2>
|
||||||
|
<p>
|
||||||
|
<strong>Lean FIRE</strong> attacks the equation from the expense side. By drastically lowering
|
||||||
|
your cost of living, you lower your required FIRE number.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you can live happily on $35,000 a year, you "only" need $875,000 to retire (based
|
||||||
|
on the 4% rule). Compare that to a "Fat FIRE" lifestyle spending $100,000, which
|
||||||
|
requires $2.5 million. Lean FIRE is the fastest path out of the workforce, but it requires
|
||||||
|
discipline.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Separator className="my-12" />
|
||||||
|
|
||||||
|
<h2>Run The Numbers</h2>
|
||||||
|
<p>The best way to decide is to see the math. Use our calculator to simulate both scenarios:</p>
|
||||||
|
<ol>
|
||||||
|
<li>
|
||||||
|
<strong>For Coast FIRE:</strong> Input your current age and a "Coast Age" (e.g.,
|
||||||
|
35). See if your current balance grows enough by age 60 without adding more.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>For Lean FIRE:</strong> Lower your "Desired Monthly Allowance" to a
|
||||||
|
minimalist level and see how fast you reach freedom.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="my-10 text-center">
|
||||||
|
<Link href="/">
|
||||||
|
<Button size="lg" className="text-lg">
|
||||||
|
Compare Strategies with the Calculator →
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Which Should You Choose?</h2>
|
||||||
|
<p>
|
||||||
|
You don't have to pick one today. Many people start with a <strong>Lean FIRE</strong>{' '}
|
||||||
|
mindset to save aggressively, then transition to <strong>Coast FIRE</strong> once they have a
|
||||||
|
safety net, allowing them to enjoy their 30s and 40s more.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The most important step is to just <strong>start</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<AuthorBio />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/app/learn/page.tsx
Normal file
109
src/app/learn/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import BlurThing from '../components/blur-thing';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Learn FIRE | Financial Independence Guides & Resources',
|
||||||
|
description:
|
||||||
|
'Master the art of Financial Independence and Early Retirement. Deep dives into safe withdrawal rates, asset allocation, and FIRE strategies.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LearnHubPage() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-4xl px-4 py-12">
|
||||||
|
<div className="mb-12 text-center">
|
||||||
|
<h1 className="mb-4 text-4xl font-extrabold tracking-tight lg:text-5xl">FIRE Knowledge Base</h1>
|
||||||
|
<p className="text-muted-foreground text-xl">
|
||||||
|
Everything you need to know to leave the rat race behind.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{/* Article 1 */}
|
||||||
|
<Link href="/learn/what-is-fire" className="transition-transform hover:scale-[1.02]">
|
||||||
|
<Card className="hover:border-primary/50 h-full cursor-pointer border-2 transition-all">
|
||||||
|
<BlurThing />
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900 dark:text-green-300">
|
||||||
|
Beginner
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">What is FIRE?</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
The comprehensive guide to Financial Independence, Retire Early.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Understand the core philosophy, the math behind the movement, and how to start your
|
||||||
|
journey today.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Article 2 */}
|
||||||
|
<Link
|
||||||
|
href="/learn/safe-withdrawal-rate-4-percent-rule"
|
||||||
|
className="transition-transform hover:scale-[1.02]"
|
||||||
|
>
|
||||||
|
<Card className="hover:border-primary/50 h-full cursor-pointer border-2">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-300">
|
||||||
|
Strategy
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">The 4% Rule Explained</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Is it still safe in {new Date().getFullYear().toString()}? A data-driven look at
|
||||||
|
withdrawal rates.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Dive into the Trinity Study, sequence of returns risk, and how to bulletproof your
|
||||||
|
retirement income.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Article 3 */}
|
||||||
|
<Link href="/learn/coast-fire-vs-lean-fire" className="transition-transform hover:scale-[1.02]">
|
||||||
|
<Card className="hover:border-primary/50 h-full cursor-pointer border-2">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-800 dark:bg-purple-900 dark:text-purple-300">
|
||||||
|
Comparison
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">Coast FIRE vs. Lean FIRE</CardTitle>
|
||||||
|
<CardDescription>Which strategy fits your lifestyle?</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Comparing different flavors of financial independence to find your perfect fit.
|
||||||
|
Front-load your savings or minimize your expenses?
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted mt-16 rounded-xl p-8 text-center">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold">Ready to see the numbers?</h2>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
Put theory into practice with our interactive projection tool.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="bg-primary text-primary-foreground ring-offset-background hover:bg-primary/90 focus-visible:ring-ring inline-flex h-10 items-center justify-center rounded-md px-8 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Launch Calculator
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
src/app/learn/safe-withdrawal-rate-4-percent-rule/page.tsx
Normal file
165
src/app/learn/safe-withdrawal-rate-4-percent-rule/page.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { Info } from 'lucide-react';
|
||||||
|
import { FourPercentRuleChart } from '@/app/components/charts/FourPercentRuleChart';
|
||||||
|
import { AuthorBio } from '@/app/components/AuthorBio';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Safe Withdrawal Rates & The 4% Rule Explained (2025 Update)',
|
||||||
|
description: `Is the 4% rule safe in ${new Date().getFullYear().toString()}? We analyze the Trinity Study, sequence of returns risk, and variable withdrawal strategies for a bulletproof retirement.`,
|
||||||
|
openGraph: {
|
||||||
|
title: 'Safe Withdrawal Rates & The 4% Rule Explained',
|
||||||
|
description: "Don't run out of money. Understanding the math behind safe retirement withdrawals.",
|
||||||
|
type: 'article',
|
||||||
|
url: 'https://investingfire.com/learn/safe-withdrawal-rate-4-percent-rule',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SafeWithdrawalPage() {
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Article',
|
||||||
|
headline: `Safe Withdrawal Rates & The 4% Rule Explained (${new Date().getFullYear().toString()} Update)`,
|
||||||
|
author: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: 'InvestingFIRE Team',
|
||||||
|
},
|
||||||
|
publisher: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: 'InvestingFIRE',
|
||||||
|
logo: {
|
||||||
|
'@type': 'ImageObject',
|
||||||
|
url: 'https://investingfire.com/apple-icon.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
datePublished: '2025-01-15',
|
||||||
|
description: `Is the 4% rule safe in ${new Date().getFullYear().toString()}? Analysis of the Trinity Study and modern withdrawal strategies.`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="container mx-auto max-w-3xl px-4 py-12">
|
||||||
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
|
||||||
|
<nav className="text-muted-foreground mb-6 text-sm">
|
||||||
|
<Link href="/" className="hover:text-primary">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<Link href="/learn" className="hover:text-primary">
|
||||||
|
Learn
|
||||||
|
</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<span className="text-foreground">Safe Withdrawal Rates</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header className="mb-10">
|
||||||
|
<h1 className="mb-6 text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||||
|
The 4% Rule Explained: <br />
|
||||||
|
<span className="text-primary">Is It Safe in {new Date().getFullYear().toString()}?</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-xl leading-relaxed">
|
||||||
|
The "4% Rule" is the bedrock of the FIRE movement. But originally published in 1994,
|
||||||
|
does it hold up against modern inflation and market valuations? Let's look at the data.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||||
|
<h2>What is the 4% Rule?</h2>
|
||||||
|
<p>
|
||||||
|
The rule comes from the <strong>Trinity Study</strong> (1998), which looked at historical
|
||||||
|
stock/bond portfolios to see how often they would last for 30 years given various withdrawal
|
||||||
|
rates.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The Conclusion: A portfolio of 50% stocks and 50% bonds survived{' '}
|
||||||
|
<strong>95% of the time</strong> over 30-year periods when the retiree withdrew 4% of the
|
||||||
|
initial balance, adjusted annually for inflation.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Alert className="my-6">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>Key Distinction</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<span>
|
||||||
|
The 4% is based on your <span className="italic">initial</span> portfolio value. If you
|
||||||
|
start with $1M, you withdraw $40k. In year 2, if inflation was 3%, you withdraw
|
||||||
|
$41,200—regardless of whether the market is up or down.
|
||||||
|
</span>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="my-8">
|
||||||
|
<FourPercentRuleChart />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>The Problem with 4% in {new Date().getFullYear().toString()}</h2>
|
||||||
|
<p>
|
||||||
|
While 4% worked historically, many experts argue it might be too aggressive for early retirees
|
||||||
|
today. Why?
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-5">
|
||||||
|
<li>
|
||||||
|
<strong>Longer Horizons:</strong> The Trinity Study looked at 30 years. If you retire at 35,
|
||||||
|
you might need your money to last 50 or 60 years.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Valuations:</strong> When stock market valuations (CAPE ratios) are high, future
|
||||||
|
returns tend to be lower.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Sequence of Returns Risk:</strong> If the market crashes right after you retire (like
|
||||||
|
in 2000 or 2008), depleting your portfolio early can make it impossible to recover, even if
|
||||||
|
the market rebounds later.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Better Alternatives: Variable Withdrawal Rates</h2>
|
||||||
|
<p>
|
||||||
|
Instead of a rigid "blind" withdrawal, modern FIRE strategies suggest being dynamic.
|
||||||
|
</p>
|
||||||
|
<h3>1. The "Guardrails" Approach</h3>
|
||||||
|
<p>
|
||||||
|
If the market drops significantly, you cut your spending (e.g., skip the vacation, eat out
|
||||||
|
less). If the market booms, you give yourself a raise. This flexibility massively increases
|
||||||
|
your portfolio's success rate.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>2. Lower the Initial Rate</h3>
|
||||||
|
<p>
|
||||||
|
Many cautious early retirees target a <strong>3.25% to 3.5%</strong> withdrawal rate. This
|
||||||
|
virtually guarantees capital preservation across almost all historical scenarios, even extended
|
||||||
|
bear markets.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Simulate Your Safe Rate</h2>
|
||||||
|
<p>
|
||||||
|
Reading about it is one thing; seeing it is another. We've built these scenarios directly
|
||||||
|
into our calculator.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Go to the calculator, expand the advanced options (or check the "Simulation Mode" if
|
||||||
|
available), and switch between "Deterministic" (Fixed return) and "Monte
|
||||||
|
Carlo" (Randomized) to see how volatility impacts your success chance.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="my-8 text-center">
|
||||||
|
<Link href="/?simulationMode=monte-carlo">
|
||||||
|
<Button size="lg" variant="secondary" className="text-lg">
|
||||||
|
Run Monte Carlo Simulation →
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Conclusion</h2>
|
||||||
|
<p>
|
||||||
|
The 4% rule is a fantastic rule of thumb for planning, but a dangerous rule of law for
|
||||||
|
execution. Use it to set your savings target, but remain flexible once you actually pull the
|
||||||
|
trigger on retirement.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<AuthorBio />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
src/app/learn/what-is-fire/page.tsx
Normal file
190
src/app/learn/what-is-fire/page.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { FireFlowchart } from '@/app/components/charts/FireFlowchart';
|
||||||
|
import { AuthorBio } from '@/app/components/AuthorBio';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: `What is FIRE? The Ultimate Guide to Financial Independence (${new Date().getFullYear().toString()})`,
|
||||||
|
description:
|
||||||
|
'Discover the FIRE movement (Financial Independence, Retire Early). Learn how to calculate your FIRE number, savings rate, and retire decades ahead of schedule.',
|
||||||
|
openGraph: {
|
||||||
|
title: 'What is FIRE? The Ultimate Guide to Financial Independence',
|
||||||
|
description: 'Stop trading time for money. The comprehensive guide to regaining your freedom.',
|
||||||
|
type: 'article',
|
||||||
|
url: 'https://investingfire.com/learn/what-is-fire',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WhatIsFirePage() {
|
||||||
|
// JSON-LD for SEO
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Article',
|
||||||
|
headline: 'What is FIRE? The Ultimate Guide to Financial Independence',
|
||||||
|
author: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: 'InvestingFIRE Team',
|
||||||
|
},
|
||||||
|
publisher: {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: 'InvestingFIRE',
|
||||||
|
logo: {
|
||||||
|
'@type': 'ImageObject',
|
||||||
|
url: 'https://investingfire.com/apple-icon.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
datePublished: '2025-01-15',
|
||||||
|
description:
|
||||||
|
'Discover the FIRE movement. Learn how to calculate your FIRE number, savings rate, and retire decades ahead of schedule.',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="container mx-auto max-w-3xl px-4 py-12">
|
||||||
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<nav className="text-muted-foreground mb-6 text-sm">
|
||||||
|
<Link href="/" className="hover:text-primary">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<Link href="/learn" className="hover:text-primary">
|
||||||
|
Learn
|
||||||
|
</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<span className="text-foreground">What is FIRE?</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header className="mb-10">
|
||||||
|
<h1 className="mb-6 text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||||
|
What Is FIRE? <br />
|
||||||
|
<span className="text-primary">The Modern Guide to Financial Freedom</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-xl leading-relaxed">
|
||||||
|
FIRE stands for <strong>Financial Independence, Retire Early</strong>. It’s not just about
|
||||||
|
quitting your job—it’s about reaching a point where work is optional, and your assets generate
|
||||||
|
enough income to cover your lifestyle forever.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||||
|
<p>
|
||||||
|
Imagine waking up on a Monday morning without an alarm clock. You don't have to rush to a
|
||||||
|
commute, sit in traffic, or answer to a boss. Instead, you have the ultimate luxury:{' '}
|
||||||
|
<strong>ownership of your time</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-card my-8 rounded-lg border p-6 shadow-sm">
|
||||||
|
<h3 className="mt-0 text-xl font-semibold">💡 The Core Equation</h3>
|
||||||
|
<p className="mb-4">
|
||||||
|
FIRE isn't magic; it's math. The speed at which you can retire depends on one
|
||||||
|
primary variable: <strong>Your Savings Rate</strong>.
|
||||||
|
</p>
|
||||||
|
<p className="mb-0">
|
||||||
|
<span className="text-primary font-mono">
|
||||||
|
High Income - Low Expenses = High Savings = Freedom
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-8">
|
||||||
|
<FireFlowchart />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>The 3 Pillars of FIRE</h2>
|
||||||
|
<p>To achieve financial independence, you need to optimize three levers:</p>
|
||||||
|
|
||||||
|
<ol className="mb-8 list-inside list-decimal space-y-2">
|
||||||
|
<li>
|
||||||
|
<strong>Spend Less (Frugality):</strong> Cutting unnecessary costs is the most powerful lever
|
||||||
|
because it has a double effect: it increases your savings <em>and</em> lowers the amount you
|
||||||
|
need to save forever.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Earn More (Income):</strong> There is a floor to how much you can cut, but no ceiling
|
||||||
|
to how much you can earn. Side hustles, career growth, and upskilling are key.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Invest Wisely (Growth):</strong> Your money must work for you. Low-cost index funds
|
||||||
|
(like VTSAX) are the vehicle of choice for the FIRE community due to their diversification
|
||||||
|
and low fees.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>What is "The Number"?</h2>
|
||||||
|
<p>
|
||||||
|
Your <strong>FIRE Number</strong> is the net worth you need to retire. The most common rule of
|
||||||
|
thumb is the <strong>Rule of 25</strong>:
|
||||||
|
</p>
|
||||||
|
<blockquote>
|
||||||
|
<p className="text-foreground text-xl font-medium not-italic">
|
||||||
|
Annual Expenses × 25 = FIRE Number
|
||||||
|
</p>
|
||||||
|
</blockquote>
|
||||||
|
<p>
|
||||||
|
For example, if you spend <strong>$40,000</strong> per year, you need{' '}
|
||||||
|
<strong>$1,000,000</strong> invested. This is based on the <em>4% Rule</em>, which suggests you
|
||||||
|
can withdraw 4% of your portfolio in the first year of retirement (adjusted for inflation
|
||||||
|
thereafter) with a high probability of not running out of money.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="my-8 text-center">
|
||||||
|
<Link href="/">
|
||||||
|
<Button size="lg" className="text-lg">
|
||||||
|
Calculate Your FIRE Number Now →
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Types of FIRE</h2>
|
||||||
|
<p>FIRE isn't one-size-fits-all. Over the years, several variations have emerged:</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Lean FIRE:</strong> Retiring on a budget (e.g., less than $40k/year). Great for
|
||||||
|
minimalists.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Fat FIRE:</strong> Retiring with abundance (e.g., $100k+/year). Requires a larger
|
||||||
|
nest egg but offers a luxurious lifestyle.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Barista FIRE:</strong> Reaching a portfolio size where you still work part-time
|
||||||
|
(perhaps as a barista) for benefits or extra cash, reducing withdrawal pressure.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Coast FIRE:</strong> Saving enough early on so that compound interest alone will hit
|
||||||
|
your retirement target by age 65, allowing you to stop saving and just cover expenses.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Why {new Date().getFullYear().toString()} Changes Things</h2>
|
||||||
|
<p>
|
||||||
|
In {new Date().getFullYear().toString()}, we face unique challenges: higher inflation than the
|
||||||
|
previous decade and potentially lower future stock market returns. This makes{' '}
|
||||||
|
<strong>flexibility</strong> essential.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Static calculators often fail to capture this nuance. That's why{' '}
|
||||||
|
<Link href="/" className="text-primary hover:underline">
|
||||||
|
InvestingFIRE.com
|
||||||
|
</Link>{' '}
|
||||||
|
allows you to adjust inflation assumptions and growth rates dynamically, helping you
|
||||||
|
stress-test your plan against modern economic reality.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Conclusion</h2>
|
||||||
|
<p>
|
||||||
|
FIRE is more than a financial goal; it's a lifestyle design choice. It asks the question:{' '}
|
||||||
|
<em>"What would you do if money were no object?"</em>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Start by tracking your expenses, calculating your savings rate, and running your numbers. The
|
||||||
|
best time to plant a tree was 20 years ago. The second best time is today.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<AuthorBio />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
288
src/app/page.tsx
288
src/app/page.tsx
@@ -1,75 +1,71 @@
|
|||||||
import Image from "next/image";
|
import Image from 'next/image';
|
||||||
import FireCalculatorForm from "./components/FireCalculatorForm";
|
import FireCalculatorForm from './components/FireCalculatorForm';
|
||||||
import {
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||||
Accordion,
|
import BackgroundPattern from './components/BackgroundPattern';
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
import { Testimonials } from './components/Testimonials';
|
||||||
AccordionTrigger,
|
|
||||||
} from "@/components/ui/accordion";
|
|
||||||
import Footer from "./components/footer";
|
|
||||||
import BackgroundPattern from "./components/BackgroundPattern";
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const faqData = {
|
const faqData = {
|
||||||
"@context": "https://schema.org",
|
'@context': 'https://schema.org',
|
||||||
"@type": "FAQPage",
|
'@type': 'FAQPage',
|
||||||
mainEntity: [
|
mainEntity: [
|
||||||
{
|
{
|
||||||
"@type": "Question",
|
'@type': 'Question',
|
||||||
name: "What methodology does this calculator use?",
|
name: 'What methodology does this calculator use?',
|
||||||
acceptedAnswer: {
|
acceptedAnswer: {
|
||||||
"@type": "Answer",
|
'@type': 'Answer',
|
||||||
text: "We run a multi-year projection in two phases: 1. Accumulation: Your balance grows by CAGR and you add monthly savings. 2. Retirement: The balance continues compounding, but you withdraw an inflation-adjusted monthly allowance. The result: a precise estimate of the capital you'll have at retirement (your “FIRE Number”) and how long it will last until your chosen life expectancy.",
|
text: "We run a multi-year projection in two phases: 1. Accumulation: Your balance grows by CAGR and you add monthly savings. 2. Retirement: The balance continues compounding, but you withdraw an inflation-adjusted monthly allowance. The result: a precise estimate of the capital you'll have at retirement (your “FIRE Number”) and how long it will last until your chosen life expectancy.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@type": "Question",
|
'@type': 'Question',
|
||||||
name: "Why isn't this just the 4% rule?",
|
name: "Why isn't this just the 4% rule?",
|
||||||
acceptedAnswer: {
|
acceptedAnswer: {
|
||||||
"@type": "Answer",
|
'@type': 'Answer',
|
||||||
text: "The 4% rule is a useful starting point (25× annual spending), but it assumes a fixed withdrawal rate with inflation adjustments and doesn't model ongoing savings or dynamic market returns. Our calculator simulates each year's growth, contributions, and inflation-indexed withdrawals to give you a tailored picture.",
|
text: "The 4% rule is a useful starting point (25× annual spending), but it assumes a fixed withdrawal rate with inflation adjustments and doesn't model ongoing savings or dynamic market returns. Our calculator simulates each year's growth, contributions, and inflation-indexed withdrawals to give you a tailored picture.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@type": "Question",
|
'@type': 'Question',
|
||||||
name: "How do I choose a realistic growth rate?",
|
name: 'How do I choose a realistic growth rate?',
|
||||||
acceptedAnswer: {
|
acceptedAnswer: {
|
||||||
"@type": "Answer",
|
'@type': 'Answer',
|
||||||
text: "Historically, a diversified portfolio of equities and bonds has returned around 7-10% per year before inflation. We recommend starting around 6-8% (net of fees), then running “what-if” scenarios—5% on the conservative side, 10% on the aggressive side—to see how they affect your timeline.",
|
text: 'Historically, a diversified portfolio of equities and bonds has returned around 7-10% per year before inflation. We recommend starting around 6-8% (net of fees), then running “what-if” scenarios—5% on the conservative side, 10% on the aggressive side—to see how they affect your timeline.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@type": "Question",
|
'@type': 'Question',
|
||||||
name: "How does inflation factor into my FIRE Number?",
|
name: 'How does inflation factor into my FIRE Number?',
|
||||||
acceptedAnswer: {
|
acceptedAnswer: {
|
||||||
"@type": "Answer",
|
'@type': 'Answer',
|
||||||
text: "Cost of living rises. To maintain today's lifestyle, your monthly allowance must grow each year by your inflation rate. This calculator automatically inflates your desired monthly spending and subtracts it from your portfolio during retirement, ensuring your FIRE Number keeps pace with rising expenses.",
|
text: "Cost of living rises. To maintain today's lifestyle, your monthly allowance must grow each year by your inflation rate. This calculator automatically inflates your desired monthly spending and subtracts it from your portfolio during retirement, ensuring your FIRE Number keeps pace with rising expenses.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@type": "Question",
|
'@type': 'Question',
|
||||||
name: "Can I really retire early with FIRE?",
|
name: 'Can I really retire early with FIRE?',
|
||||||
acceptedAnswer: {
|
acceptedAnswer: {
|
||||||
"@type": "Answer",
|
'@type': 'Answer',
|
||||||
text: "Early retirement is achievable with disciplined saving, smart investing, and realistic assumptions. This tool helps you set targets, visualize outcomes, and adjust inputs—so you can build confidence in your plan and make informed trade-offs between lifestyle, risk, and timeline.",
|
text: 'Early retirement is achievable with disciplined saving, smart investing, and realistic assumptions. This tool helps you set targets, visualize outcomes, and adjust inputs—so you can build confidence in your plan and make informed trade-offs between lifestyle, risk, and timeline.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@type": "Question",
|
'@type': 'Question',
|
||||||
name: "How should I use this calculator effectively?",
|
name: 'How should I use this calculator effectively?',
|
||||||
acceptedAnswer: {
|
acceptedAnswer: {
|
||||||
"@type": "Answer",
|
'@type': 'Answer',
|
||||||
text: "Start with your actual numbers (capital, savings, age). Set conservative - mid - aggressive growth rates to bound possibilities. Slide your retirement age to explore “early” vs. “traditional” scenarios. Review the chart—especially the reference lines—to see when you hit FI and how withdrawals impact your balance. Experiment with higher savings rates or lower target spending to accelerate your path.",
|
text: 'Start with your actual numbers (capital, savings, age). Set conservative - mid - aggressive growth rates to bound possibilities. Slide your retirement age to explore “early” vs. “traditional” scenarios. Review the chart—especially the reference lines—to see when you hit FI and how withdrawals impact your balance. Experiment with higher savings rates or lower target spending to accelerate your path.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="text-primary-foreground to-destructive from-secondary flex min-h-screen flex-col items-center bg-linear-to-b p-2">
|
<div className="from-background via-primary/10 to-secondary/10 text-foreground relative flex min-h-screen w-full flex-col items-center overflow-hidden bg-gradient-to-b px-4 pt-6 pb-16">
|
||||||
<BackgroundPattern />
|
<BackgroundPattern />
|
||||||
<div className="z-10 mx-auto flex flex-col items-center justify-center gap-4 text-center">
|
<div className="z-10 mx-auto flex flex-col items-center justify-center gap-4 text-center">
|
||||||
<div className="mt-8 flex flex-row flex-wrap items-center justify-center gap-4 align-middle">
|
<div className="flex flex-row flex-wrap items-center justify-center gap-4 align-middle">
|
||||||
<Image
|
<Image
|
||||||
priority
|
priority
|
||||||
unoptimized
|
unoptimized
|
||||||
@@ -78,48 +74,53 @@ export default function HomePage() {
|
|||||||
width={100}
|
width={100}
|
||||||
height={100}
|
height={100}
|
||||||
/>
|
/>
|
||||||
<h1 className="from-primary via-primary-foreground to-primary bg-linear-to-r bg-clip-text text-5xl font-extrabold tracking-tight text-transparent drop-shadow-md sm:text-[5rem]">
|
<h1 className="from-primary via-accent to-primary bg-linear-to-r bg-clip-text text-5xl font-extrabold tracking-tight text-transparent drop-shadow-md sm:text-[5rem]">
|
||||||
InvestingFIRE
|
InvestingFIRE
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-primary-foreground/90 text-xl font-semibold md:text-2xl">
|
<span className="bg-primary/15 text-primary rounded-full px-4 py-2 text-xs font-semibold tracking-wide uppercase shadow-sm">
|
||||||
The #1 FIRE Calculator
|
100% free • built for educational use
|
||||||
|
</span>
|
||||||
|
<p className="text-foreground/90 text-xl font-semibold md:text-2xl">The #1 FIRE Calculator</p>
|
||||||
|
<p className="text-foreground/80 max-w-2xl text-base text-balance md:text-lg">
|
||||||
|
Plan your path to financial independence with transparent math—ad-free and built to teach you
|
||||||
|
how FIRE works.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8 w-full max-w-2xl">
|
<div className="mt-8 w-full max-w-2xl">
|
||||||
<FireCalculatorForm />
|
<FireCalculatorForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="z-10 mx-auto max-w-4xl px-4">
|
||||||
|
<Testimonials />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Added SEO Content Sections */}
|
{/* Added SEO Content Sections */}
|
||||||
<div className="z-10 mx-auto max-w-2xl py-12 text-left">
|
<div className="z-10 mx-auto max-w-2xl py-12 text-left">
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="mb-4 text-3xl font-bold">
|
<h2 className="mb-4 text-3xl font-bold">
|
||||||
What Is FIRE? Understanding Financial Independence and Early
|
What Is FIRE? Understanding Financial Independence and Early Retirement
|
||||||
Retirement
|
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-4 text-lg leading-relaxed">
|
<p className="mb-4 text-lg leading-relaxed">
|
||||||
FIRE stands for{" "}
|
FIRE stands for <strong>Financial Independence, Retire Early</strong>. It's a lifestyle
|
||||||
<strong>Financial Independence, Retire Early</strong>. It's a
|
movement built around two core ideas:
|
||||||
lifestyle movement built around two core ideas:
|
|
||||||
</p>
|
</p>
|
||||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
|
||||||
<li>
|
<li>
|
||||||
<strong>Aggressive saving & investing</strong>—often 50%+ of
|
<strong>Aggressive saving & investing</strong>—often 50%+ of income—so your capital grows
|
||||||
income—so your capital grows rapidly.
|
rapidly.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Passive-income coverage</strong>—when your investment
|
<strong>Passive-income coverage</strong>—when your investment returns exceed your living
|
||||||
returns exceed your living expenses, you gain freedom from a
|
expenses, you gain freedom from a traditional 9-5.
|
||||||
traditional 9-5.
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-lg leading-relaxed">
|
<p className="text-lg leading-relaxed">
|
||||||
By reaching your personal <em>FIRE Number</em>—the nest egg needed
|
By reaching your personal <em>FIRE Number</em>—the nest egg needed to cover your
|
||||||
to cover your inflation-adjusted spending—you unlock the option to
|
inflation-adjusted spending—you unlock the option to step away from a daily paycheck and
|
||||||
step away from a daily paycheck and pursue passion projects, travel,
|
pursue passion projects, travel, family, or anything else. This calculator helps you simulate
|
||||||
family, or anything else. This calculator helps you simulate your
|
your journey, estimate how much you need, and visualize both your accumulation phase and your
|
||||||
journey, estimate how much you need, and visualize both your
|
retirement withdrawals over time.
|
||||||
accumulation phase and your retirement withdrawals over time.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -128,58 +129,50 @@ export default function HomePage() {
|
|||||||
How This FIRE Calculator Provides Investing Insights
|
How This FIRE Calculator Provides Investing Insights
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-4 text-lg leading-relaxed">
|
<p className="mb-4 text-lg leading-relaxed">
|
||||||
Our interactive tool goes beyond a simple “25x annual spending”
|
Our interactive tool goes beyond a simple “25x annual spending” rule. It runs a{' '}
|
||||||
rule. It runs a <strong>year-by-year simulation</strong> of your
|
<strong>year-by-year simulation</strong> of your portfolio, combining:
|
||||||
portfolio, combining:
|
|
||||||
</p>
|
</p>
|
||||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
|
||||||
<li>
|
<li>
|
||||||
<strong>Starting Capital</strong>—your current invested balance
|
<strong>Starting Capital</strong>—your current invested balance
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Monthly Savings</strong>—ongoing contributions to your
|
<strong>Monthly Savings</strong>—ongoing contributions to your portfolio
|
||||||
portfolio
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Expected Annual Growth Rate (CAGR)</strong>—compounding
|
<strong>Expected Annual Growth Rate (CAGR)</strong>—compounding returns before inflation
|
||||||
returns before inflation
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Annual Inflation Rate</strong>—to inflate your target
|
<strong>Annual Inflation Rate</strong>—to inflate your target withdrawal each year
|
||||||
withdrawal each year
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Desired Monthly Allowance</strong>—today's-value
|
<strong>Desired Monthly Allowance</strong>—today's-value spending goal
|
||||||
spending goal
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Retirement Age & Life Expectancy</strong>—defines your
|
<strong>Retirement Age & Life Expectancy</strong>—defines your accumulation horizon and
|
||||||
accumulation horizon and payout period
|
payout period
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-lg leading-relaxed">Key features:</p>
|
<p className="text-lg leading-relaxed">Key features:</p>
|
||||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-lg">
|
||||||
<li>
|
<li>
|
||||||
<strong>Real-time calculation</strong>—as you tweak any input,
|
<strong>Real-time calculation</strong>—as you tweak any input, your FIRE Number and chart
|
||||||
your FIRE Number and chart update instantly.
|
update instantly.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Interactive chart</strong> with area plots for both{" "}
|
<strong>Interactive chart</strong> with area plots for both <em>portfolio balance</em> and{' '}
|
||||||
<em>portfolio balance</em> and{" "}
|
<em>inflation-adjusted allowance</em>, plus reference lines showing your retirement date
|
||||||
<em>inflation-adjusted allowance</em>, plus reference lines
|
and required FIRE Number.
|
||||||
showing your retirement date and required FIRE Number.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Custom simulation</strong>—switches from accumulation
|
<strong>Custom simulation</strong>—switches from accumulation (adding savings) to
|
||||||
(adding savings) to retirement (withdrawing allowance),
|
retirement (withdrawing allowance), compounding each year based on your growth rate.
|
||||||
compounding each year based on your growth rate.
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-lg leading-relaxed">
|
<p className="text-lg leading-relaxed">
|
||||||
With this level of granularity, you can confidently experiment with
|
With this level of granularity, you can confidently experiment with savings rate, target
|
||||||
savings rate, target retirement age, and investment assumptions to
|
retirement age, and investment assumptions to discover how small tweaks speed up or delay
|
||||||
discover how small tweaks speed up or delay your path to financial
|
your path to financial independence.
|
||||||
independence.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -188,9 +181,7 @@ export default function HomePage() {
|
|||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqData) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqData) }}
|
||||||
/>
|
/>
|
||||||
<h2 className="mb-4 text-3xl font-bold">
|
<h2 className="mb-4 text-3xl font-bold">FIRE & Investing Frequently Asked Questions (FAQ)</h2>
|
||||||
FIRE & Investing Frequently Asked Questions (FAQ)
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<Accordion type="single" collapsible className="w-full">
|
<Accordion type="single" collapsible className="w-full">
|
||||||
<AccordionItem value="item-1">
|
<AccordionItem value="item-1">
|
||||||
@@ -201,18 +192,16 @@ export default function HomePage() {
|
|||||||
We run a multi-year projection in two phases:
|
We run a multi-year projection in two phases:
|
||||||
<ol className="ml-6 list-decimal space-y-1">
|
<ol className="ml-6 list-decimal space-y-1">
|
||||||
<li>
|
<li>
|
||||||
<strong>Accumulation:</strong> Your balance grows by CAGR
|
<strong>Accumulation:</strong> Your balance grows by CAGR and you add monthly
|
||||||
and you add monthly savings.
|
savings.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Retirement:</strong> The balance continues
|
<strong>Retirement:</strong> The balance continues compounding, but you withdraw an
|
||||||
compounding, but you withdraw an inflation-adjusted monthly
|
inflation-adjusted monthly allowance.
|
||||||
allowance.
|
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
The result: a precise estimate of the capital you'll have
|
The result: a precise estimate of the capital you'll have at retirement (your “FIRE
|
||||||
at retirement (your “FIRE Number”) and how long it will last
|
Number”) and how long it will last until your chosen life expectancy.
|
||||||
until your chosen life expectancy.
|
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
@@ -221,12 +210,10 @@ export default function HomePage() {
|
|||||||
Why isn't this just the 4% rule?
|
Why isn't this just the 4% rule?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="text-lg leading-relaxed">
|
<AccordionContent className="text-lg leading-relaxed">
|
||||||
The 4% rule is a useful starting point (25× annual spending),
|
The 4% rule is a useful starting point (25× annual spending), but it assumes a fixed
|
||||||
but it assumes a fixed withdrawal rate with inflation
|
withdrawal rate with inflation adjustments and doesn't model ongoing savings or
|
||||||
adjustments and doesn't model ongoing savings or dynamic
|
dynamic market returns. Our calculator simulates each year's growth, contributions,
|
||||||
market returns. Our calculator simulates each year's
|
and inflation-indexed withdrawals to give you a tailored picture.
|
||||||
growth, contributions, and inflation-indexed withdrawals to give
|
|
||||||
you a tailored picture.
|
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
@@ -235,11 +222,10 @@ export default function HomePage() {
|
|||||||
How do I choose a realistic growth rate?
|
How do I choose a realistic growth rate?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="text-lg leading-relaxed">
|
<AccordionContent className="text-lg leading-relaxed">
|
||||||
Historically, a diversified portfolio of equities and bonds has
|
Historically, a diversified portfolio of equities and bonds has returned around 7-10% per
|
||||||
returned around 7-10% per year before inflation. We recommend
|
year before inflation. We recommend starting around 6-8% (net of fees), then running
|
||||||
starting around 6-8% (net of fees), then running “what-if”
|
“what-if” scenarios—5% on the conservative side, 10% on the aggressive side—to see how
|
||||||
scenarios—5% on the conservative side, 10% on the aggressive
|
they affect your timeline.
|
||||||
side—to see how they affect your timeline.
|
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
@@ -248,11 +234,10 @@ export default function HomePage() {
|
|||||||
How does inflation factor into my FIRE Number?
|
How does inflation factor into my FIRE Number?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="text-lg leading-relaxed">
|
<AccordionContent className="text-lg leading-relaxed">
|
||||||
Cost of living rises. To maintain today's lifestyle, your
|
Cost of living rises. To maintain today's lifestyle, your monthly allowance must
|
||||||
monthly allowance must grow each year by your inflation rate.
|
grow each year by your inflation rate. This calculator automatically inflates your
|
||||||
This calculator automatically inflates your desired monthly
|
desired monthly spending and subtracts it from your portfolio during retirement, ensuring
|
||||||
spending and subtracts it from your portfolio during retirement,
|
your FIRE Number keeps pace with rising expenses.
|
||||||
ensuring your FIRE Number keeps pace with rising expenses.
|
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
@@ -261,11 +246,10 @@ export default function HomePage() {
|
|||||||
Can I really retire early with FIRE?
|
Can I really retire early with FIRE?
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="text-lg leading-relaxed">
|
<AccordionContent className="text-lg leading-relaxed">
|
||||||
Early retirement is achievable with disciplined saving, smart
|
Early retirement is achievable with disciplined saving, smart investing, and realistic
|
||||||
investing, and realistic assumptions. This tool helps you set
|
assumptions. This tool helps you set targets, visualize outcomes, and adjust inputs—so
|
||||||
targets, visualize outcomes, and adjust inputs—so you can build
|
you can build confidence in your plan and make informed trade-offs between lifestyle,
|
||||||
confidence in your plan and make informed trade-offs between
|
risk, and timeline.
|
||||||
lifestyle, risk, and timeline.
|
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
@@ -275,24 +259,16 @@ export default function HomePage() {
|
|||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="text-lg leading-relaxed">
|
<AccordionContent className="text-lg leading-relaxed">
|
||||||
<ul className="ml-6 list-disc space-y-1">
|
<ul className="ml-6 list-disc space-y-1">
|
||||||
|
<li>Start with your actual numbers (capital, savings, age).</li>
|
||||||
|
<li>Set conservative - mid - aggressive growth rates to bound possibilities.</li>
|
||||||
|
<li>Slide your retirement age to explore “early” vs. “traditional” scenarios.</li>
|
||||||
<li>
|
<li>
|
||||||
Start with your actual numbers (capital, savings, age).
|
Review the chart—especially the reference lines—to see when you hit FI and how
|
||||||
|
withdrawals impact your balance.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Set conservative - mid - aggressive growth rates to bound
|
Experiment with higher savings rates or lower target spending to accelerate your
|
||||||
possibilities.
|
path.
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Slide your retirement age to explore “early” vs.
|
|
||||||
“traditional” scenarios.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Review the chart—especially the reference lines—to see when
|
|
||||||
you hit FI and how withdrawals impact your balance.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Experiment with higher savings rates or lower target
|
|
||||||
spending to accelerate your path.
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
@@ -302,34 +278,27 @@ export default function HomePage() {
|
|||||||
|
|
||||||
{/* Optional: Add a section for relevant resources/links here */}
|
{/* Optional: Add a section for relevant resources/links here */}
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<h2 className="mb-4 text-3xl font-bold">
|
<h2 className="mb-4 text-3xl font-bold">FIRE Journey & Investing Resources</h2>
|
||||||
FIRE Journey & Investing Resources
|
|
||||||
</h2>
|
|
||||||
<p className="mb-6 text-lg leading-relaxed">
|
<p className="mb-6 text-lg leading-relaxed">
|
||||||
Ready to deepen your knowledge and build a bullet-proof plan? Below
|
Ready to deepen your knowledge and build a bullet-proof plan? Below are some of our favorite
|
||||||
are some of our favorite blogs, books, tools, and communities for
|
blogs, books, tools, and communities for financial independence and smart investing.
|
||||||
financial independence and smart investing.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-foreground my-8 rounded-md p-4 text-lg">
|
<div className="text-background bg-foreground my-8 rounded-md p-4 text-lg">
|
||||||
<p className="font-semibold">Getting Started with FIRE:</p>
|
<p className="font-semibold">Getting Started with FIRE:</p>
|
||||||
<ol className="ml-6 list-decimal space-y-1">
|
<ol className="ml-6 list-decimal space-y-1">
|
||||||
<li>
|
<li>Run your first projection above to find your target FIRE Number.</li>
|
||||||
Run your first projection above to find your target FIRE Number.
|
|
||||||
</li>
|
|
||||||
<li>Identify areas to boost savings or reduce expenses.</li>
|
<li>Identify areas to boost savings or reduce expenses.</li>
|
||||||
|
<li>Study index-fund strategies and low-cost investing advice.</li>
|
||||||
<li>
|
<li>
|
||||||
Study index-fund strategies and low-cost investing advice.
|
Join{' '}
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Join{" "}
|
|
||||||
<a
|
<a
|
||||||
href="https://www.reddit.com/r/Fire/"
|
href="https://www.reddit.com/r/Fire/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
supportive communities like r/Fire
|
supportive communities like r/Fire
|
||||||
</a>{" "}
|
</a>{' '}
|
||||||
to learn from real journeys.
|
to learn from real journeys.
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
@@ -346,7 +315,7 @@ export default function HomePage() {
|
|||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Mr. Money Mustache
|
Mr. Money Mustache
|
||||||
</a>{" "}
|
</a>{' '}
|
||||||
- Hardcore frugality & early retirement success stories.
|
- Hardcore frugality & early retirement success stories.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -356,7 +325,7 @@ export default function HomePage() {
|
|||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Playing With FIRE
|
Playing With FIRE
|
||||||
</a>{" "}
|
</a>{' '}
|
||||||
- Community resources & real-life case studies.
|
- Community resources & real-life case studies.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -366,7 +335,7 @@ export default function HomePage() {
|
|||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
r/Fire
|
r/Fire
|
||||||
</a>{" "}
|
</a>{' '}
|
||||||
- Active forum for questions, tips, and support.
|
- Active forum for questions, tips, and support.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -382,7 +351,7 @@ export default function HomePage() {
|
|||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Your Money or Your Life
|
Your Money or Your Life
|
||||||
</a>{" "}
|
</a>{' '}
|
||||||
- The classic guide to aligning money with values.
|
- The classic guide to aligning money with values.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -392,7 +361,7 @@ export default function HomePage() {
|
|||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
BiggerPockets Money Podcast
|
BiggerPockets Money Podcast
|
||||||
</a>{" "}
|
</a>{' '}
|
||||||
- Interviews on FIRE strategies and wealth building.
|
- Interviews on FIRE strategies and wealth building.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -402,26 +371,19 @@ export default function HomePage() {
|
|||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
InvestingFIRE Calculator Demo
|
InvestingFIRE Calculator Demo
|
||||||
</a>{" "}
|
</a>{' '}
|
||||||
- Deep dive on how interactive projections can guide your
|
- Deep dive on how interactive projections can guide your plan.
|
||||||
plan.
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-xl font-semibold">
|
<h3 className="mb-3 text-xl font-semibold">Additional Calculators & Tools</h3>
|
||||||
Additional Calculators & Tools
|
|
||||||
</h3>
|
|
||||||
<ul className="ml-6 list-disc space-y-2 text-lg">
|
<ul className="ml-6 list-disc space-y-2 text-lg">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a href="https://ghostfol.io" target="_blank" className="text-primary hover:underline">
|
||||||
href="https://ghostfol.io"
|
|
||||||
target="_blank"
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
>
|
|
||||||
Ghostfolio
|
Ghostfolio
|
||||||
</a>{" "}
|
</a>{' '}
|
||||||
- Wealth management application for individuals.
|
- Wealth management application for individuals.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -431,9 +393,8 @@ export default function HomePage() {
|
|||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Coast FIRE Calculator
|
Coast FIRE Calculator
|
||||||
</a>{" "}
|
</a>{' '}
|
||||||
- When you “max out” early contributions but let compounding
|
- When you “max out” early contributions but let compounding do the rest.
|
||||||
do the rest.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
@@ -442,7 +403,7 @@ export default function HomePage() {
|
|||||||
className="text-primary hover:underline"
|
className="text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Compound Interest Calculator
|
Compound Interest Calculator
|
||||||
</a>{" "}
|
</a>{' '}
|
||||||
- Explore the power of growth rates in isolation.
|
- Explore the power of growth rates in isolation.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -450,7 +411,6 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
</div>
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,20 @@
|
|||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from 'react';
|
||||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||||
import { ChevronDownIcon } from "lucide-react";
|
import { ChevronDownIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Accordion({
|
function Accordion({ ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
|
||||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccordionItem({
|
function AccordionItem({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
|
||||||
return (
|
return (
|
||||||
<AccordionPrimitive.Item
|
<AccordionPrimitive.Item
|
||||||
data-slot="accordion-item"
|
data-slot="accordion-item"
|
||||||
className={cn(
|
className={cn('border-primary-foreground/20 border-b last:border-b-0', className)}
|
||||||
"border-primary-foreground/20 border-b last:border-b-0",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -38,13 +30,13 @@ function AccordionTrigger({
|
|||||||
<AccordionPrimitive.Trigger
|
<AccordionPrimitive.Trigger
|
||||||
data-slot="accordion-trigger"
|
data-slot="accordion-trigger"
|
||||||
className={cn(
|
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",
|
'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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronDownIcon className="text-primary-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
<ChevronDownIcon className="text-secondary pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
</AccordionPrimitive.Trigger>
|
</AccordionPrimitive.Trigger>
|
||||||
</AccordionPrimitive.Header>
|
</AccordionPrimitive.Header>
|
||||||
);
|
);
|
||||||
@@ -61,7 +53,7 @@ function AccordionContent({
|
|||||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
<div className={cn('pt-0 pb-4', className)}>{children}</div>
|
||||||
</AccordionPrimitive.Content>
|
</AccordionPrimitive.Content>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/components/ui/alert.tsx
Normal file
60
src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-card text-card-foreground',
|
||||||
|
destructive:
|
||||||
|
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
38
src/components/ui/avatar.tsx
Normal file
38
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
className={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn('aspect-square size-full', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn('bg-muted flex size-full items-center justify-center rounded-full', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
@@ -1,36 +1,34 @@
|
|||||||
import * as React from "react";
|
import * as React from 'react';
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-transparent text-sm font-semibold transition-[transform,colors,shadow] shadow-[0_10px_30px_-18px_rgba(0,0,0,0.45)] 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:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
'border-primary/20 bg-gradient-to-r from-primary to-secondary text-primary-foreground shadow-lg shadow-primary/30 hover:from-primary/90 hover:to-secondary/90',
|
||||||
destructive:
|
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",
|
'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:
|
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",
|
'border border-primary/25 bg-background/80 shadow-sm hover:bg-primary/10 hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||||
secondary:
|
secondary: 'bg-secondary/90 text-secondary-foreground shadow-md hover:bg-secondary',
|
||||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
ghost: 'text-foreground/80 hover:bg-primary/10 hover:text-foreground dark:hover:bg-accent/50',
|
||||||
ghost:
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: 'h-9 px-4 py-2 has-[>svg]:px-3.5',
|
||||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
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",
|
lg: 'h-11 rounded-md px-5 has-[>svg]:px-4',
|
||||||
icon: "size-9",
|
icon: 'size-9',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: 'default',
|
||||||
size: "default",
|
size: 'default',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -41,18 +39,14 @@ function Button({
|
|||||||
size,
|
size,
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: React.ComponentProps<'button'> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : "button";
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />
|
||||||
data-slot="button"
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
import * as React from "react";
|
import * as React from 'react';
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
function Card({ className, children, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
'bg-card text-card-foreground relative flex flex-col gap-6 overflow-hidden rounded-xl border py-6 shadow-sm',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
className={cn(
|
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",
|
'@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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -28,65 +30,44 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div data-slot="card-title" className={cn('leading-none font-semibold', className)} {...props} />
|
||||||
data-slot="card-title"
|
|
||||||
className={cn("leading-none font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-description"
|
data-slot="card-description"
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn('text-muted-foreground text-sm', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-action"
|
data-slot="card-action"
|
||||||
className={cn(
|
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
|
||||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
|
||||||
<div
|
|
||||||
data-slot="card-content"
|
|
||||||
className={cn("px-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-footer"
|
data-slot="card-footer"
|
||||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardFooter,
|
|
||||||
CardTitle,
|
|
||||||
CardAction,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from 'react';
|
||||||
import * as RechartsPrimitive from "recharts";
|
import * as RechartsPrimitive from 'recharts';
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
const THEMES = { light: "", dark: ".dark" } as const;
|
const THEMES = { light: '', dark: '.dark' } as const;
|
||||||
|
|
||||||
export type ChartConfig = Record<
|
export type ChartConfig = Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
label?: React.ReactNode;
|
label?: React.ReactNode;
|
||||||
icon?: React.ComponentType;
|
icon?: React.ComponentType;
|
||||||
} & (
|
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> })
|
||||||
| { color?: string; theme?: never }
|
|
||||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
|
||||||
)
|
|
||||||
>;
|
>;
|
||||||
|
|
||||||
interface ChartContextProps {
|
interface ChartContextProps {
|
||||||
@@ -30,7 +27,7 @@ function useChart() {
|
|||||||
const context = React.useContext(ChartContext);
|
const context = React.useContext(ChartContext);
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error("useChart must be used within a <ChartContainer />");
|
throw new Error('useChart must be used within a <ChartContainer />');
|
||||||
}
|
}
|
||||||
|
|
||||||
return context;
|
return context;
|
||||||
@@ -42,14 +39,12 @@ function ChartContainer({
|
|||||||
children,
|
children,
|
||||||
config,
|
config,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<'div'> & {
|
||||||
config: ChartConfig;
|
config: ChartConfig;
|
||||||
children: React.ComponentProps<
|
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children'];
|
||||||
typeof RechartsPrimitive.ResponsiveContainer
|
|
||||||
>["children"];
|
|
||||||
}) {
|
}) {
|
||||||
const uniqueId = React.useId();
|
const uniqueId = React.useId();
|
||||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
|
const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContext.Provider value={{ config }}>
|
<ChartContext.Provider value={{ config }}>
|
||||||
@@ -63,18 +58,14 @@ function ChartContainer({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChartStyle id={chartId} config={config} />
|
<ChartStyle id={chartId} config={config} />
|
||||||
<RechartsPrimitive.ResponsiveContainer>
|
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||||
{children}
|
|
||||||
</RechartsPrimitive.ResponsiveContainer>
|
|
||||||
</div>
|
</div>
|
||||||
</ChartContext.Provider>
|
</ChartContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
const colorConfig = Object.entries(config).filter(
|
const colorConfig = Object.entries(config).filter(([, config]) => config.theme ?? config.color);
|
||||||
([, config]) => config.theme ?? config.color,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!colorConfig.length) {
|
if (!colorConfig.length) {
|
||||||
return null;
|
return null;
|
||||||
@@ -89,16 +80,14 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
|||||||
${prefix} [data-chart=${id}] {
|
${prefix} [data-chart=${id}] {
|
||||||
${colorConfig
|
${colorConfig
|
||||||
.map(([key, itemConfig]) => {
|
.map(([key, itemConfig]) => {
|
||||||
const color =
|
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color;
|
||||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
|
||||||
itemConfig.color;
|
|
||||||
return color ? ` --color-${key}: ${color};` : null;
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
})
|
})
|
||||||
.join("\n")}
|
.join('\n')}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.join("\n"),
|
.join('\n'),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -110,7 +99,7 @@ function ChartTooltipContent({
|
|||||||
active,
|
active,
|
||||||
payload,
|
payload,
|
||||||
className,
|
className,
|
||||||
indicator = "dot",
|
indicator = 'dot',
|
||||||
hideLabel = false,
|
hideLabel = false,
|
||||||
hideIndicator = false,
|
hideIndicator = false,
|
||||||
label,
|
label,
|
||||||
@@ -121,10 +110,10 @@ function ChartTooltipContent({
|
|||||||
nameKey,
|
nameKey,
|
||||||
labelKey,
|
labelKey,
|
||||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
React.ComponentProps<"div"> & {
|
React.ComponentProps<'div'> & {
|
||||||
hideLabel?: boolean;
|
hideLabel?: boolean;
|
||||||
hideIndicator?: boolean;
|
hideIndicator?: boolean;
|
||||||
indicator?: "line" | "dot" | "dashed";
|
indicator?: 'line' | 'dot' | 'dashed';
|
||||||
nameKey?: string;
|
nameKey?: string;
|
||||||
labelKey?: string;
|
labelKey?: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -136,53 +125,41 @@ function ChartTooltipContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const item = payload[0];
|
const item = payload[0];
|
||||||
const key = labelKey ?? String(item.dataKey ?? item.name ?? "value");
|
const key = labelKey ?? String(item.dataKey ?? item.name ?? 'value');
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
const value =
|
const value =
|
||||||
!labelKey && typeof label === "string"
|
!labelKey && typeof label === 'string'
|
||||||
? (label in config && config[label].label ? config[label].label : undefined) ?? label
|
? ((label in config && config[label].label ? config[label].label : undefined) ?? label)
|
||||||
: itemConfig?.label;
|
: itemConfig?.label;
|
||||||
|
|
||||||
if (labelFormatter) {
|
if (labelFormatter) {
|
||||||
return (
|
return <div className={cn('font-medium', labelClassName)}>{labelFormatter(value, payload)}</div>;
|
||||||
<div className={cn("font-medium", labelClassName)}>
|
|
||||||
{labelFormatter(value, payload)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
|
||||||
}, [
|
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||||
label,
|
|
||||||
labelFormatter,
|
|
||||||
payload,
|
|
||||||
hideLabel,
|
|
||||||
labelClassName,
|
|
||||||
config,
|
|
||||||
labelKey,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!active || !payload?.length) {
|
if (!active || !payload?.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
const nestLabel = payload.length === 1 && indicator !== 'dot';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-border/50 bg-background grid min-w-32 items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
'border-border/50 bg-background grid min-w-32 items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!nestLabel ? tooltipLabel : null}
|
{!nestLabel ? tooltipLabel : null}
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
{payload.map((item, index) => {
|
{payload.map((item, index) => {
|
||||||
const key = nameKey ?? String(item.name ?? item.dataKey ?? "value");
|
const key = nameKey ?? String(item.name ?? item.dataKey ?? 'value');
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
const indicatorColor: string | undefined =
|
const indicatorColor: string | undefined =
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
@@ -192,8 +169,8 @@ function ChartTooltipContent({
|
|||||||
<div
|
<div
|
||||||
key={item.dataKey}
|
key={item.dataKey}
|
||||||
className={cn(
|
className={cn(
|
||||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
'[&>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",
|
indicator === 'dot' && 'items-center',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatter && item.value !== undefined && item.name ? (
|
{formatter && item.value !== undefined && item.name ? (
|
||||||
@@ -206,20 +183,16 @@ function ChartTooltipContent({
|
|||||||
) : (
|
) : (
|
||||||
!hideIndicator && (
|
!hideIndicator && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn('border-border shrink-0 rounded-[2px] bg-(--color-bg)', {
|
||||||
"shrink-0 rounded-[2px] border-border bg-(--color-bg)",
|
'h-2.5 w-2.5': indicator === 'dot',
|
||||||
{
|
'w-1': indicator === 'line',
|
||||||
"h-2.5 w-2.5": indicator === "dot",
|
'w-0 border-[1.5px] border-dashed bg-transparent': indicator === 'dashed',
|
||||||
"w-1": indicator === "line",
|
'my-0.5': nestLabel && indicator === 'dashed',
|
||||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
})}
|
||||||
indicator === "dashed",
|
|
||||||
"my-0.5": nestLabel && indicator === "dashed",
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--color-bg": indicatorColor,
|
'--color-bg': indicatorColor,
|
||||||
"--color-border": indicatorColor,
|
'--color-border': indicatorColor,
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -227,15 +200,13 @@ function ChartTooltipContent({
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 justify-between leading-none",
|
'flex flex-1 justify-between leading-none',
|
||||||
nestLabel ? "items-end" : "items-center",
|
nestLabel ? 'items-end' : 'items-center',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
{nestLabel ? tooltipLabel : null}
|
{nestLabel ? tooltipLabel : null}
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">{itemConfig?.label ?? item.name}</span>
|
||||||
{itemConfig?.label ?? item.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{item.value && (
|
{item.value && (
|
||||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||||
@@ -259,10 +230,10 @@ function ChartLegendContent({
|
|||||||
className,
|
className,
|
||||||
hideIcon = false,
|
hideIcon = false,
|
||||||
payload,
|
payload,
|
||||||
verticalAlign = "bottom",
|
verticalAlign = 'bottom',
|
||||||
nameKey,
|
nameKey,
|
||||||
}: React.ComponentProps<"div"> &
|
}: React.ComponentProps<'div'> &
|
||||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
|
||||||
hideIcon?: boolean;
|
hideIcon?: boolean;
|
||||||
nameKey?: string;
|
nameKey?: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -275,21 +246,21 @@ function ChartLegendContent({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-center gap-4",
|
'flex items-center justify-center gap-4',
|
||||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{payload.map((item) => {
|
{payload.map((item) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||||
const key = `${nameKey ?? item.dataKey ?? "value"}`;
|
const key = `${nameKey ?? item.dataKey ?? 'value'}`;
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.value}
|
key={item.value}
|
||||||
className={cn(
|
className={cn(
|
||||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
|
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{itemConfig?.icon && !hideIcon ? (
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
@@ -311,37 +282,26 @@ function ChartLegendContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper to extract item config from a payload.
|
// Helper to extract item config from a payload.
|
||||||
function getPayloadConfigFromPayload(
|
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||||
config: ChartConfig,
|
if (typeof payload !== 'object' || payload === null) {
|
||||||
payload: unknown,
|
|
||||||
key: string,
|
|
||||||
) {
|
|
||||||
if (typeof payload !== "object" || payload === null) {
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payloadPayload =
|
const payloadPayload =
|
||||||
"payload" in payload &&
|
'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
|
||||||
typeof payload.payload === "object" &&
|
|
||||||
payload.payload !== null
|
|
||||||
? payload.payload
|
? payload.payload
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
let configLabelKey: string = key;
|
let configLabelKey: string = key;
|
||||||
|
|
||||||
if (
|
if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
|
||||||
key in payload &&
|
|
||||||
typeof payload[key as keyof typeof payload] === "string"
|
|
||||||
) {
|
|
||||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||||
} else if (
|
} else if (
|
||||||
payloadPayload &&
|
payloadPayload &&
|
||||||
key in payloadPayload &&
|
key in payloadPayload &&
|
||||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
|
||||||
) {
|
) {
|
||||||
configLabelKey = payloadPayload[
|
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||||
key as keyof typeof payloadPayload
|
|
||||||
] as string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return configLabelKey in config ? config[configLabelKey] : config[key];
|
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||||
|
|||||||
228
src/components/ui/dropdown-menu.tsx
Normal file
228
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function DropdownMenu({ ...props }: Readonly<React.ComponentProps<typeof DropdownMenuPrimitive.Root>>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: Readonly<React.ComponentProps<typeof DropdownMenuPrimitive.Portal>>) {
|
||||||
|
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: 'default' | 'destructive';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 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",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 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",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: Readonly<React.ComponentProps<typeof DropdownMenuPrimitive.Sub>>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
};
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import * as React from "react";
|
import * as React from 'react';
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
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",
|
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input bg-background flex h-9 w-full min-w-0 rounded-md border 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]",
|
'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",
|
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
161
src/components/ui/navigation-menu.tsx
Normal file
161
src/components/ui/navigation-menu.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
|
||||||
|
import { cva } from 'class-variance-authority';
|
||||||
|
import { ChevronDownIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function NavigationMenu({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
viewport = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||||
|
viewport?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Root
|
||||||
|
data-slot="navigation-menu"
|
||||||
|
data-viewport={viewport}
|
||||||
|
className={cn(
|
||||||
|
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{viewport && <NavigationMenuViewport />}
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.List
|
||||||
|
data-slot="navigation-menu-list"
|
||||||
|
className={cn('group flex flex-1 list-none items-center justify-center gap-1', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Item
|
||||||
|
data-slot="navigation-menu-item"
|
||||||
|
className={cn('relative', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigationMenuTriggerStyle = cva(
|
||||||
|
'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
function NavigationMenuTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Trigger
|
||||||
|
data-slot="navigation-menu-trigger"
|
||||||
|
className={cn(navigationMenuTriggerStyle(), 'group', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}{' '}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Content
|
||||||
|
data-slot="navigation-menu-content"
|
||||||
|
className={cn(
|
||||||
|
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
|
||||||
|
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuViewport({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||||
|
return (
|
||||||
|
<div className={cn('absolute top-full left-0 isolate z-50 flex justify-center')}>
|
||||||
|
<NavigationMenuPrimitive.Viewport
|
||||||
|
data-slot="navigation-menu-viewport"
|
||||||
|
className={cn(
|
||||||
|
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuLink({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Link
|
||||||
|
data-slot="navigation-menu-link"
|
||||||
|
className={cn(
|
||||||
|
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuIndicator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Indicator
|
||||||
|
data-slot="navigation-menu-indicator"
|
||||||
|
className={cn(
|
||||||
|
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||||
|
</NavigationMenuPrimitive.Indicator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuIndicator,
|
||||||
|
NavigationMenuViewport,
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
};
|
||||||
@@ -1,43 +1,37 @@
|
|||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from 'react';
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Select({
|
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
|
||||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectGroup({
|
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
|
||||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectValue({
|
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
|
||||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectTrigger({
|
function SelectTrigger({
|
||||||
className,
|
className,
|
||||||
size = "default",
|
size = 'default',
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
size?: "sm" | "default";
|
size?: 'sm' | 'default';
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
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",
|
"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 aria-invalid:border-destructive bg-background flex w-fit items-center justify-between gap-2 rounded-md border 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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -53,7 +47,7 @@ function SelectTrigger({
|
|||||||
function SelectContent({
|
function SelectContent({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
position = "popper",
|
position = 'popper',
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
@@ -61,9 +55,9 @@ function SelectContent({
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
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-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
'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-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||||
position === "popper" &&
|
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",
|
'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,
|
className,
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
@@ -72,9 +66,9 @@ function SelectContent({
|
|||||||
<SelectScrollUpButton />
|
<SelectScrollUpButton />
|
||||||
<SelectPrimitive.Viewport
|
<SelectPrimitive.Viewport
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
'p-1',
|
||||||
position === "popper" &&
|
position === 'popper' &&
|
||||||
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1",
|
'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -85,14 +79,11 @@ function SelectContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectLabel({
|
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Label
|
<SelectPrimitive.Label
|
||||||
data-slot="select-label"
|
data-slot="select-label"
|
||||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -129,7 +120,7 @@ function SelectSeparator({
|
|||||||
return (
|
return (
|
||||||
<SelectPrimitive.Separator
|
<SelectPrimitive.Separator
|
||||||
data-slot="select-separator"
|
data-slot="select-separator"
|
||||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -142,10 +133,7 @@ function SelectScrollUpButton({
|
|||||||
return (
|
return (
|
||||||
<SelectPrimitive.ScrollUpButton
|
<SelectPrimitive.ScrollUpButton
|
||||||
data-slot="select-scroll-up-button"
|
data-slot="select-scroll-up-button"
|
||||||
className={cn(
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUpIcon className="size-4" />
|
<ChevronUpIcon className="size-4" />
|
||||||
@@ -160,10 +148,7 @@ function SelectScrollDownButton({
|
|||||||
return (
|
return (
|
||||||
<SelectPrimitive.ScrollDownButton
|
<SelectPrimitive.ScrollDownButton
|
||||||
data-slot="select-scroll-down-button"
|
data-slot="select-scroll-down-button"
|
||||||
className={cn(
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDownIcon className="size-4" />
|
<ChevronDownIcon className="size-4" />
|
||||||
|
|||||||
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
123
src/components/ui/sheet.tsx
Normal file
123
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { XIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Sheet({ ...props }: Readonly<React.ComponentProps<typeof SheetPrimitive.Root>>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({ ...props }: Readonly<React.ComponentProps<typeof SheetPrimitive.Portal>>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = 'right',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
'from-foreground to-foreground/95 data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 bg-gradient-to-b shadow-lg backdrop-blur-md transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||||
|
side === 'right' &&
|
||||||
|
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||||
|
side === 'left' &&
|
||||||
|
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||||
|
side === 'top' &&
|
||||||
|
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||||
|
side === 'bottom' &&
|
||||||
|
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div data-slot="sheet-header" className={cn('flex flex-col gap-1.5 p-4', className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn('text-foreground font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn('text-muted-foreground text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
};
|
||||||
@@ -129,4 +129,28 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
h1 {
|
||||||
|
@apply scroll-m-20 text-4xl font-extrabold tracking-tight text-balance;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
@apply scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
@apply scroll-m-20 text-2xl font-semibold tracking-tight;
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
@apply scroll-m-20 text-xl font-semibold tracking-tight;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
@apply leading-7 [&:not(:first-child)]:mt-6;
|
||||||
|
}
|
||||||
|
blockquote {
|
||||||
|
@apply mt-6 border-l-2 pl-6 italic;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
@apply my-6 ml-6 list-disc [&>li]:mt-2;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
@apply bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
vitest.config.ts
Normal file
17
vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./vitest.setup.ts"],
|
||||||
|
exclude: ["node_modules", "e2e/**"],
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
2
vitest.setup.ts
Normal file
2
vitest.setup.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
|
|
||||||
Reference in New Issue
Block a user