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 ( +
+
+
Loading…
+
+
+ ) + } + + 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 && ( + + )} + +
+
+
+ ) +}