diff --git a/.planning/phases/07-setup-wizard/07-01-SUMMARY.md b/.planning/phases/07-setup-wizard/07-01-SUMMARY.md
new file mode 100644
index 0000000..c606119
--- /dev/null
+++ b/.planning/phases/07-setup-wizard/07-01-SUMMARY.md
@@ -0,0 +1,86 @@
+---
+phase: 07-setup-wizard
+plan: 01
+subsystem: frontend/setup-wizard
+tags: [wizard, ui, state-management, i18n]
+dependency_graph:
+ requires: [src/data/presets.ts, src/lib/types.ts, src/hooks/useAuth.ts]
+ provides: [src/hooks/useWizardState.ts, src/pages/SetupPage.tsx, src/components/setup/*]
+ affects: [src/i18n/en.json, src/i18n/de.json]
+tech_stack:
+ added: [shadcn/checkbox]
+ patterns: [localStorage-synced-state, multi-step-wizard, grouped-preset-checklist]
+key_files:
+ created:
+ - src/hooks/useWizardState.ts
+ - src/components/setup/WizardStepper.tsx
+ - src/components/setup/IncomeStep.tsx
+ - src/components/setup/AllocationBar.tsx
+ - src/components/setup/CategoryGroupHeader.tsx
+ - src/components/setup/PresetItemRow.tsx
+ - src/components/setup/RecurringItemsStep.tsx
+ - src/pages/SetupPage.tsx
+ - src/components/ui/checkbox.tsx
+ modified:
+ - src/i18n/en.json
+ - src/i18n/de.json
+decisions:
+ - "Profile fetched inline in SetupPage (same pattern as SettingsPage) since useAuth does not expose profile"
+ - "Used manual reduce for grouping PRESETS by type for broad browser compatibility"
+metrics:
+ duration: 178s
+ completed: 2026-04-20T19:06:36Z
+ tasks_completed: 2
+ tasks_total: 2
+ files_created: 9
+ files_modified: 2
+---
+
+# Phase 07 Plan 01: Setup Wizard UI Shell Summary
+
+LocalStorage-synced 3-step wizard with income input, grouped preset checklist (19 items), live allocation bar, and horizontal stepper navigation.
+
+## Commits
+
+| Task | Commit | Description |
+|------|--------|-------------|
+| 1 | bbcb07f | useWizardState hook, i18n keys (EN+DE), shadcn checkbox |
+| 2 | e141197 | All 7 wizard UI components and SetupPage orchestrator |
+
+## What Was Built
+
+1. **useWizardState hook** - Manages wizard step, income, and item selections in localStorage keyed by userId. Validates JSON shape on load (T-07-01 mitigation). Bills and variable_expense items pre-checked by default.
+
+2. **WizardStepper** - Horizontal 1-2-3 stepper with circles, connector lines, and step labels. Completed steps are clickable; future steps are disabled.
+
+3. **IncomeStep** - Number input pre-filled with 3000, currency suffix from profile, helper text, and inline validation error display.
+
+4. **AllocationBar** - Sticky bar showing remaining = income - sum(checked amounts). Green when positive, red (destructive) when negative. aria-live="polite" for screen readers.
+
+5. **CategoryGroupHeader** - Colored dot + label + item count for each of the 6 category types.
+
+6. **PresetItemRow** - Checkbox + preset name (i18n) + category badge with colored border + editable amount input (disabled when unchecked).
+
+7. **RecurringItemsStep** - Groups all 19 PRESETS into 6 category sections with AllocationBar at top.
+
+8. **SetupPage** - Page orchestrator: centered card layout (max-w-2xl), loads profile for currency, renders stepper + active step, handles Next/Back/Skip navigation with income validation.
+
+## i18n
+
+All wizard copy added under `setup.*` namespace in both `en.json` and `de.json` (35 keys each). German translations use proper umlauts and natural phrasing.
+
+## Deviations from Plan
+
+None - plan executed exactly as written.
+
+## Known Stubs
+
+| File | Line | Stub | Reason |
+|------|------|------|--------|
+| src/pages/SetupPage.tsx | Step 3 content | "Review step coming in next plan" placeholder | Plan 02 will implement ReviewStep and completion logic |
+| src/pages/SetupPage.tsx | Skip setup button | No-op onClick | Plan 02 will wire skip to mark setup_completed and redirect |
+| src/pages/SetupPage.tsx | Complete button | Disabled, no handler | Plan 02 will implement completion sequence |
+
+These stubs are intentional -- Plan 02 explicitly owns the ReviewStep, completion logic, routing, and redirect.
+
+## Self-Check: PASSED
diff --git a/src/components/setup/AllocationBar.tsx b/src/components/setup/AllocationBar.tsx
new file mode 100644
index 0000000..cb6b515
--- /dev/null
+++ b/src/components/setup/AllocationBar.tsx
@@ -0,0 +1,31 @@
+import { useTranslation } from "react-i18next"
+
+interface AllocationBarProps {
+ remaining: number
+ currency: string
+}
+
+export function AllocationBar({ remaining, currency }: AllocationBarProps) {
+ const { t } = useTranslation()
+
+ const formatted = new Intl.NumberFormat(undefined, {
+ style: "currency",
+ currency,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(remaining)
+
+ return (
+
+ {t("setup.step2.remaining")}
+ = 0 ? "text-on-budget" : "text-destructive"
+ }`}
+ >
+ {formatted}
+
+
+ )
+}
diff --git a/src/components/setup/CategoryGroupHeader.tsx b/src/components/setup/CategoryGroupHeader.tsx
new file mode 100644
index 0000000..18a80d8
--- /dev/null
+++ b/src/components/setup/CategoryGroupHeader.tsx
@@ -0,0 +1,36 @@
+import type { CategoryType } from "@/lib/types"
+import { Separator } from "@/components/ui/separator"
+
+interface CategoryGroupHeaderProps {
+ type: CategoryType
+ label: string
+ count: number
+}
+
+const colorVar = (type: CategoryType): string => {
+ const map: Record = {
+ income: "var(--color-income)",
+ bill: "var(--color-bill)",
+ variable_expense: "var(--color-variable-expense)",
+ debt: "var(--color-debt)",
+ saving: "var(--color-saving)",
+ investment: "var(--color-investment)",
+ }
+ return map[type]
+}
+
+export function CategoryGroupHeader({ type, label, count }: CategoryGroupHeaderProps) {
+ return (
+
+
+
+ {label}
+ ({count} items)
+
+
+
+ )
+}
diff --git a/src/components/setup/IncomeStep.tsx b/src/components/setup/IncomeStep.tsx
new file mode 100644
index 0000000..ab8fb34
--- /dev/null
+++ b/src/components/setup/IncomeStep.tsx
@@ -0,0 +1,45 @@
+import { useTranslation } from "react-i18next"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+
+interface IncomeStepProps {
+ income: number
+ onIncomeChange: (val: number) => void
+ currency: string
+ error: string | null
+}
+
+export function IncomeStep({ income, onIncomeChange, currency, error }: IncomeStepProps) {
+ const { t } = useTranslation()
+
+ const helperId = "income-helper"
+ const errorId = "income-error"
+
+ return (
+
+
+
+ onIncomeChange(Number(e.target.value))}
+ min={0}
+ aria-describedby={`${helperId}${error ? ` ${errorId}` : ""}`}
+ />
+ {currency}
+
+
+ {t("setup.step1.helper")}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ )
+}
diff --git a/src/components/setup/PresetItemRow.tsx b/src/components/setup/PresetItemRow.tsx
new file mode 100644
index 0000000..63387f5
--- /dev/null
+++ b/src/components/setup/PresetItemRow.tsx
@@ -0,0 +1,59 @@
+import { useTranslation } from "react-i18next"
+import type { CategoryType } from "@/lib/types"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import { Input } from "@/components/ui/input"
+
+interface PresetItemRowProps {
+ slug: string
+ type: CategoryType
+ checked: boolean
+ amount: number
+ onToggle: () => void
+ onAmountChange: (val: number) => void
+}
+
+const colorVar = (type: CategoryType): string => {
+ const map: Record = {
+ income: "var(--color-income)",
+ bill: "var(--color-bill)",
+ variable_expense: "var(--color-variable-expense)",
+ debt: "var(--color-debt)",
+ saving: "var(--color-saving)",
+ investment: "var(--color-investment)",
+ }
+ return map[type]
+}
+
+export function PresetItemRow({
+ slug,
+ type,
+ checked,
+ amount,
+ onToggle,
+ onAmountChange,
+}: PresetItemRowProps) {
+ const { t } = useTranslation()
+
+ return (
+
+
+ {t(`presets.${type}.${slug}`)}
+
+ {t(`categories.types.${type}`)}
+
+ onAmountChange(Number(e.target.value))}
+ disabled={!checked}
+ min={0}
+ />
+
+ )
+}
diff --git a/src/components/setup/RecurringItemsStep.tsx b/src/components/setup/RecurringItemsStep.tsx
new file mode 100644
index 0000000..33bf9ac
--- /dev/null
+++ b/src/components/setup/RecurringItemsStep.tsx
@@ -0,0 +1,81 @@
+import { useTranslation } from "react-i18next"
+import { PRESETS } from "@/data/presets"
+import type { CategoryType } from "@/lib/types"
+import { AllocationBar } from "./AllocationBar"
+import { CategoryGroupHeader } from "./CategoryGroupHeader"
+import { PresetItemRow } from "./PresetItemRow"
+
+interface RecurringItemsStepProps {
+ selectedItems: Record
+ income: number
+ currency: string
+ onToggle: (slug: string) => void
+ onAmountChange: (slug: string, amount: number) => void
+}
+
+const GROUP_ORDER: CategoryType[] = [
+ "income",
+ "bill",
+ "variable_expense",
+ "debt",
+ "saving",
+ "investment",
+]
+
+export function RecurringItemsStep({
+ selectedItems,
+ income,
+ currency,
+ onToggle,
+ onAmountChange,
+}: RecurringItemsStepProps) {
+ const { t } = useTranslation()
+
+ // Group presets by type
+ const grouped = GROUP_ORDER.reduce(
+ (acc, type) => {
+ acc[type] = PRESETS.filter((p) => p.type === type)
+ return acc
+ },
+ {} as Record
+ )
+
+ // Compute remaining
+ const totalChecked = Object.values(selectedItems)
+ .filter((i) => i.checked)
+ .reduce((sum, i) => sum + i.amount, 0)
+ const remaining = income - totalChecked
+
+ return (
+
+
+
+ {GROUP_ORDER.map((type) => {
+ const items = grouped[type]
+ if (!items || items.length === 0) return null
+ return (
+
+ )
+ })}
+
+
+ )
+}
diff --git a/src/components/setup/WizardStepper.tsx b/src/components/setup/WizardStepper.tsx
new file mode 100644
index 0000000..55bd17e
--- /dev/null
+++ b/src/components/setup/WizardStepper.tsx
@@ -0,0 +1,62 @@
+import { useTranslation } from "react-i18next"
+import { Check } from "lucide-react"
+
+interface WizardStepperProps {
+ currentStep: 1 | 2 | 3
+ onStepClick: (step: 1 | 2 | 3) => void
+}
+
+const steps = [1, 2, 3] as const
+
+export function WizardStepper({ currentStep, onStepClick }: WizardStepperProps) {
+ const { t } = useTranslation()
+
+ return (
+
+ )
+}
diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..98ac83e
--- /dev/null
+++ b/src/components/ui/checkbox.tsx
@@ -0,0 +1,30 @@
+import * as React from "react"
+import { CheckIcon } from "lucide-react"
+import { Checkbox as CheckboxPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Checkbox({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { Checkbox }
diff --git a/src/hooks/useWizardState.ts b/src/hooks/useWizardState.ts
new file mode 100644
index 0000000..141c8b5
--- /dev/null
+++ b/src/hooks/useWizardState.ts
@@ -0,0 +1,85 @@
+import { useState, useEffect } from "react"
+import { PRESETS } from "@/data/presets"
+
+export interface WizardState {
+ currentStep: 1 | 2 | 3
+ income: number
+ selectedItems: Record
+}
+
+const STORAGE_KEY = (userId: string) => `setup-wizard-${userId}`
+
+function getDefaultState(): WizardState {
+ const selectedItems: Record = {}
+ for (const preset of PRESETS) {
+ selectedItems[preset.slug] = {
+ checked: preset.type === "bill" || preset.type === "variable_expense",
+ amount: preset.defaultAmount,
+ }
+ }
+ return { currentStep: 1, income: 3000, selectedItems }
+}
+
+export function useWizardState(userId: string) {
+ const [state, setState] = useState(() => {
+ try {
+ const saved = localStorage.getItem(STORAGE_KEY(userId))
+ if (saved) {
+ const parsed = JSON.parse(saved)
+ // Validate shape (T-07-01 mitigation: validate JSON on load)
+ if (
+ parsed &&
+ typeof parsed.currentStep === "number" &&
+ parsed.currentStep >= 1 &&
+ parsed.currentStep <= 3 &&
+ typeof parsed.income === "number" &&
+ parsed.income >= 0 &&
+ parsed.selectedItems &&
+ typeof parsed.selectedItems === "object"
+ ) {
+ return parsed as WizardState
+ }
+ }
+ } catch {
+ /* ignore corrupt data */
+ }
+ return getDefaultState()
+ })
+
+ useEffect(() => {
+ localStorage.setItem(STORAGE_KEY(userId), JSON.stringify(state))
+ }, [state, userId])
+
+ const setStep = (step: 1 | 2 | 3) =>
+ setState((s) => ({ ...s, currentStep: step }))
+
+ const setIncome = (income: number) =>
+ setState((s) => ({ ...s, income }))
+
+ const toggleItem = (slug: string) =>
+ setState((s) => ({
+ ...s,
+ selectedItems: {
+ ...s.selectedItems,
+ [slug]: {
+ ...s.selectedItems[slug],
+ checked: !s.selectedItems[slug].checked,
+ },
+ },
+ }))
+
+ const setItemAmount = (slug: string, amount: number) =>
+ setState((s) => ({
+ ...s,
+ selectedItems: {
+ ...s.selectedItems,
+ [slug]: { ...s.selectedItems[slug], amount },
+ },
+ }))
+
+ const clearState = () => {
+ localStorage.removeItem(STORAGE_KEY(userId))
+ }
+
+ return { state, setStep, setIncome, toggleItem, setItemAmount, clearState }
+}
diff --git a/src/i18n/de.json b/src/i18n/de.json
index cf7e00f..3352cd1 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -121,6 +121,44 @@
"error": "Etwas ist schiefgelaufen",
"confirm": "Bestätigen"
},
+ "setup": {
+ "title": "Budget einrichten",
+ "step1": {
+ "title": "Monatliches Einkommen",
+ "description": "Wie viel verdienst du pro Monat?",
+ "incomeLabel": "Monatliches Nettoeinkommen",
+ "helper": "Gib dein monatliches Nettoeinkommen ein",
+ "validation": "Bitte gib einen positiven Betrag ein"
+ },
+ "step2": {
+ "title": "Wiederkehrende Posten",
+ "description": "Wähle deine regelmäßigen monatlichen Ausgaben",
+ "remaining": "Verbleibend zu verteilen"
+ },
+ "step3": {
+ "title": "Übersicht",
+ "description": "Bestätige deine Budgetvorlage",
+ "incomeLabel": "Monatliches Einkommen",
+ "totalLabel": "Gesamtausgaben",
+ "remainingLabel": "Verbleibend",
+ "empty": "Keine Posten ausgewählt. Du kannst später Posten zu deiner Vorlage hinzufügen."
+ },
+ "steps": {
+ "1": "Einkommen",
+ "2": "Posten",
+ "3": "Übersicht"
+ },
+ "next": "Nächster Schritt",
+ "back": "Zurück",
+ "skip": "Schritt überspringen",
+ "skipSetup": "Einrichtung überspringen",
+ "complete": "Einrichtung abschließen",
+ "toast": {
+ "success": "Vorlage erstellt! Dein erstes Budget wird automatisch erscheinen.",
+ "error": "Vorlage konnte nicht gespeichert werden. Bitte versuche es erneut.",
+ "partialError": "Einige Posten konnten nicht gespeichert werden. Prüfe deine Vorlagenseite."
+ }
+ },
"presets": {
"income": {
"salary": "Gehalt",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index a38a475..5531496 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -121,6 +121,44 @@
"error": "Something went wrong",
"confirm": "Confirm"
},
+ "setup": {
+ "title": "Set up your budget",
+ "step1": {
+ "title": "Monthly Income",
+ "description": "How much do you earn each month?",
+ "incomeLabel": "Monthly net income",
+ "helper": "Enter your total monthly take-home pay",
+ "validation": "Please enter a positive income amount"
+ },
+ "step2": {
+ "title": "Recurring Items",
+ "description": "Select your regular monthly expenses",
+ "remaining": "Remaining to allocate"
+ },
+ "step3": {
+ "title": "Review",
+ "description": "Confirm your budget template",
+ "incomeLabel": "Monthly income",
+ "totalLabel": "Total expenses",
+ "remainingLabel": "Remaining",
+ "empty": "No items selected. You can add items to your template later."
+ },
+ "steps": {
+ "1": "Income",
+ "2": "Items",
+ "3": "Review"
+ },
+ "next": "Next Step",
+ "back": "Go Back",
+ "skip": "Skip Step",
+ "skipSetup": "Skip setup",
+ "complete": "Complete Setup",
+ "toast": {
+ "success": "Template created! Your first budget will appear automatically.",
+ "error": "Could not save your template. Please try again.",
+ "partialError": "Some items could not be saved. Check your template page."
+ }
+ },
"presets": {
"income": {
"salary": "Salary",
diff --git a/src/pages/SetupPage.tsx b/src/pages/SetupPage.tsx
new file mode 100644
index 0000000..b682a55
--- /dev/null
+++ b/src/pages/SetupPage.tsx
@@ -0,0 +1,179 @@
+import { useState } from "react"
+import { useTranslation } from "react-i18next"
+import { useAuth } from "@/hooks/useAuth"
+import { useWizardState } from "@/hooks/useWizardState"
+import { supabase } from "@/lib/supabase"
+import type { Profile } from "@/lib/types"
+import { WizardStepper } from "@/components/setup/WizardStepper"
+import { IncomeStep } from "@/components/setup/IncomeStep"
+import { RecurringItemsStep } from "@/components/setup/RecurringItemsStep"
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import { Skeleton } from "@/components/ui/skeleton"
+import { useEffect } from "react"
+
+export default function SetupPage() {
+ const { t } = useTranslation()
+ const { user, loading: authLoading } = useAuth()
+ const [profile, setProfile] = useState(null)
+ const [profileLoading, setProfileLoading] = useState(true)
+
+ useEffect(() => {
+ if (!user) return
+ supabase
+ .from("profiles")
+ .select("*")
+ .eq("id", user.id)
+ .single()
+ .then(({ data }) => {
+ if (data) setProfile(data)
+ setProfileLoading(false)
+ })
+ }, [user])
+
+ const userId = user?.id ?? ""
+ const currency = profile?.currency ?? "EUR"
+ const { state, setStep, setIncome, toggleItem, setItemAmount } =
+ useWizardState(userId)
+
+ const [incomeError, setIncomeError] = useState(null)
+
+ const loading = authLoading || profileLoading
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ const stepTitles: Record<1 | 2 | 3, string> = {
+ 1: t("setup.step1.title"),
+ 2: t("setup.step2.title"),
+ 3: t("setup.step3.title"),
+ }
+
+ const stepDescriptions: Record<1 | 2 | 3, string> = {
+ 1: t("setup.step1.description"),
+ 2: t("setup.step2.description"),
+ 3: t("setup.step3.description"),
+ }
+
+ function handleNext() {
+ if (state.currentStep === 1) {
+ if (!state.income || state.income <= 0) {
+ setIncomeError(t("setup.step1.validation"))
+ return
+ }
+ setIncomeError(null)
+ setStep(2)
+ } else if (state.currentStep === 2) {
+ setStep(3)
+ }
+ }
+
+ function handleBack() {
+ if (state.currentStep === 2) setStep(1)
+ else if (state.currentStep === 3) setStep(2)
+ }
+
+ function handleSkipStep() {
+ if (state.currentStep === 1) setStep(2)
+ else if (state.currentStep === 2) setStep(3)
+ }
+
+ function handleStepClick(step: 1 | 2 | 3) {
+ if (step <= state.currentStep) {
+ setStep(step)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ {stepTitles[state.currentStep]}
+
+
+ {stepDescriptions[state.currentStep]}
+
+
+
+ {state.currentStep === 1 && (
+
+ )}
+
+ {state.currentStep === 2 && (
+
+ )}
+
+ {state.currentStep === 3 && (
+
+
Review step coming in next plan
+
+ )}
+
+ {/* Bottom navigation */}
+
+
+ {state.currentStep > 1 && (
+
+ )}
+ {state.currentStep < 3 && (
+
+ )}
+
+
+ {state.currentStep < 3 && (
+
+ )}
+ {state.currentStep === 3 && (
+
+ )}
+
+
+
+
+
+
+
+
+ )
+}