diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index dd391ef..33953e6 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,4 +1,4 @@
-import { Routes, Route, NavLink } from 'react-router-dom'
+import { Navigate, NavLink, Route, Routes, useLocation } from 'react-router-dom'
import Dashboard from './pages/Dashboard'
import Accounts from './pages/Accounts'
import Import from './pages/Import'
@@ -8,6 +8,9 @@ import InsightsPage from './pages/Insights'
import DebtPage from './pages/Debt'
import TaxesPage from './pages/Taxes'
import Settings from './pages/Settings'
+import Onboarding from './pages/Onboarding'
+import { useApi } from './hooks/useApi'
+import { api } from './api/client'
import {
IconGrid, IconWallet, IconTrendDown, IconBarChart,
IconHouse, IconReceipt, IconSpark, IconUpload, IconGear,
@@ -24,8 +27,31 @@ const NAV = [
]
export default function App() {
+ const location = useLocation()
+ const { data: onboarding, loading, refetch } = useApi<{
+ should_run_onboarding: boolean
+ }>(() => api.get('/settings/onboarding/status'), [])
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ const onboardingForced = onboarding?.should_run_onboarding ?? false
+ const isOnboardingRoute = location.pathname === '/onboarding'
+
+ if (onboardingForced && !isOnboardingRoute) {
+ return
+ }
+
return (
+ {!isOnboardingRoute && (
+ )}
+ {!isOnboardingRoute && (
+ )}
+ {!isOnboardingRoute && (
+ )}
+ } />
} />
} />
} />
@@ -110,6 +142,7 @@ export default function App() {
} />
} />
} />
+ } />
diff --git a/frontend/src/pages/Onboarding.tsx b/frontend/src/pages/Onboarding.tsx
new file mode 100644
index 0000000..18eda67
--- /dev/null
+++ b/frontend/src/pages/Onboarding.tsx
@@ -0,0 +1,211 @@
+import { useState } from 'react'
+import { api } from '../api/client'
+
+type DataMethod = 'manual' | 'csv' | 'both'
+type FireType = 'lean' | 'regular' | 'fat'
+
+const CASH_LIKE_TYPES = new Set(['checking', 'savings'])
+
+export default function Onboarding({ onComplete }: { onComplete: () => void }) {
+ const [step, setStep] = useState(1)
+ const [method, setMethod] = useState('manual')
+ const [accountName, setAccountName] = useState('')
+ const [accountType, setAccountType] = useState('checking')
+ const [openingBalance, setOpeningBalance] = useState('')
+ const [fireType, setFireType] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+
+ async function handleNext() {
+ setError(null)
+ if (step === 3) {
+ if (method !== 'csv' && !accountName.trim()) {
+ setError('Add account name or choose CSV-only method.')
+ return
+ }
+
+ if (accountName.trim()) {
+ try {
+ setBusy(true)
+ const account = await api.post<{ id: number; type: string }>('/accounts', {
+ name: accountName.trim(),
+ type: accountType,
+ })
+ if (openingBalance && CASH_LIKE_TYPES.has(account.type)) {
+ await api.post(`/accounts/${account.id}/balance`, {
+ balance: Number(openingBalance),
+ date: new Date().toISOString().slice(0, 10),
+ })
+ }
+ } catch (e: any) {
+ setError(e.message ?? 'Could not create account')
+ setBusy(false)
+ return
+ } finally {
+ setBusy(false)
+ }
+ }
+ }
+
+ if (step === 4) {
+ try {
+ setBusy(true)
+ if (fireType) {
+ await api.put('/settings/fire_type', { value: fireType })
+ }
+ await api.put('/settings/onboarding_complete', { value: true })
+ onComplete()
+ } catch (e: any) {
+ setError(e.message ?? 'Could not complete onboarding')
+ } finally {
+ setBusy(false)
+ }
+ return
+ }
+
+ setStep((s) => s + 1)
+ }
+
+ function handleBack() {
+ setError(null)
+ setStep((s) => Math.max(1, s - 1))
+ }
+
+ return (
+
+
Welcome to Libertas
+
+ Your finances, your machine, your control.
+
+
+
+
Privacy by default
+
+ Everything stays local. No cloud account required. No institution credentials stored.
+
+
+
+
+
Step {step} of 4
+
+ {step === 1 && (
+
+
Start in under 3 minutes
+
+ We will set data method, add first account, then optional FIRE target.
+
+
+ )}
+
+ {step === 2 && (
+
+
Choose data method
+
+ {[
+ ['manual', 'Manual entry', 'Fast start with account + balance.'],
+ ['csv', 'CSV import', 'Use exported statements now.'],
+ ['both', 'Both', 'Manual now, CSV later.'],
+ ].map(([value, label, desc]) => (
+
+ ))}
+
+
+ )}
+
+ {step === 3 && (
+
+
Add first account
+ {method === 'csv' && (
+
+ CSV-only selected. You can skip this and import from Import page after onboarding.
+
+ )}
+
+
+
+ setAccountName(e.target.value)}
+ placeholder="Main Checking"
+ />
+
+
+
+
+
+
+
+ setOpeningBalance(e.target.value)}
+ placeholder="5000"
+ />
+
+
+
+ )}
+
+ {step === 4 && (
+
+
Optional FIRE goal
+
+ {[
+ ['lean', 'Lean FIRE', 'Bare-minimum expenses, earliest timeline.'],
+ ['regular', 'Regular FIRE', 'Current lifestyle baseline.'],
+ ['fat', 'Fat FIRE', 'Higher lifestyle target.'],
+ ].map(([value, label, desc]) => (
+
+ ))}
+
+
+
+ )}
+
+ {error && (
+
{error}
+ )}
+
+
+ {step > 1 && (
+
+ )}
+
+
+
+
+ )
+}