From e1411976dd0a7ca93afd53d300d2212fb15fdf90 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 20 Apr 2026 21:06:29 +0200 Subject: [PATCH] feat(07-01): create wizard UI components and SetupPage - WizardStepper: horizontal 1-2-3 stepper with clickable completed steps - IncomeStep: number input with currency suffix and validation - AllocationBar: sticky remaining balance with live polite announcements - CategoryGroupHeader: colored dot section divider per category type - PresetItemRow: checkbox + name + badge + editable amount input - RecurringItemsStep: groups 19 PRESETS by type with allocation calculation - SetupPage: page orchestrator with step navigation and state persistence --- src/components/setup/AllocationBar.tsx | 31 ++++ src/components/setup/CategoryGroupHeader.tsx | 36 ++++ src/components/setup/IncomeStep.tsx | 45 +++++ src/components/setup/PresetItemRow.tsx | 59 ++++++ src/components/setup/RecurringItemsStep.tsx | 81 +++++++++ src/components/setup/WizardStepper.tsx | 62 +++++++ src/pages/SetupPage.tsx | 179 +++++++++++++++++++ 7 files changed, 493 insertions(+) create mode 100644 src/components/setup/AllocationBar.tsx create mode 100644 src/components/setup/CategoryGroupHeader.tsx create mode 100644 src/components/setup/IncomeStep.tsx create mode 100644 src/components/setup/PresetItemRow.tsx create mode 100644 src/components/setup/RecurringItemsStep.tsx create mode 100644 src/components/setup/WizardStepper.tsx create mode 100644 src/pages/SetupPage.tsx 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 ( +
+ {t(`categories.types.${type}`)} + + {items.map((preset) => ( + onToggle(preset.slug)} + onAmountChange={(val) => onAmountChange(preset.slug, val)} + /> + ))} +
+ ) + })} +
+
+ ) +} 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/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 && ( + + )} +
+
+
+
+ + +
+
+ ) +}