+ Schritt 1 von 3 +
+diff --git a/frontend/src/lib/components/ProgressSidebar.svelte b/frontend/src/lib/components/ProgressSidebar.svelte new file mode 100644 index 0000000..102d065 --- /dev/null +++ b/frontend/src/lib/components/ProgressSidebar.svelte @@ -0,0 +1,71 @@ + + + diff --git a/frontend/src/lib/components/ProgressSidebar.test.ts b/frontend/src/lib/components/ProgressSidebar.test.ts new file mode 100644 index 0000000..a27b723 --- /dev/null +++ b/frontend/src/lib/components/ProgressSidebar.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import ProgressSidebar from './ProgressSidebar.svelte'; + +describe('ProgressSidebar', () => { + it('renders the app logo and name', () => { + render(ProgressSidebar, { props: { currentStep: 1 } }); + expect(screen.getByText('Mealplan')).toBeInTheDocument(); + }); + + it('renders all 3 step labels', () => { + render(ProgressSidebar, { props: { currentStep: 1 } }); + expect(screen.getByText('Haushalt benennen')).toBeInTheDocument(); + expect(screen.getByText('Vorräte einrichten')).toBeInTheDocument(); + expect(screen.getByText('Mitglieder einladen')).toBeInTheDocument(); + }); + + it('step 1 active: renders green circle for step 1', () => { + render(ProgressSidebar, { props: { currentStep: 1 } }); + const step1 = screen.getByTestId('step-1'); + expect(step1).toHaveAttribute('aria-current', 'step'); + }); + + it('step 1 active: steps 2 and 3 are not current', () => { + render(ProgressSidebar, { props: { currentStep: 1 } }); + expect(screen.getByTestId('step-2')).not.toHaveAttribute('aria-current'); + expect(screen.getByTestId('step-3')).not.toHaveAttribute('aria-current'); + }); + + it('step 2 active: step 1 is completed (checkmark), step 2 is current, step 3 is future', () => { + render(ProgressSidebar, { props: { currentStep: 2 } }); + expect(screen.getByTestId('step-1')).toHaveAttribute('data-state', 'completed'); + expect(screen.getByTestId('step-2')).toHaveAttribute('aria-current', 'step'); + expect(screen.getByTestId('step-3')).not.toHaveAttribute('aria-current'); + }); + + it('step 1 completed has accessible label', () => { + render(ProgressSidebar, { props: { currentStep: 2 } }); + const step1 = screen.getByTestId('step-1'); + expect(step1).toHaveAttribute('data-state', 'completed'); + expect(screen.getByLabelText(/schritt 1/i)).toBeInTheDocument(); + }); + + it('each step has an accessible aria-label', () => { + render(ProgressSidebar, { props: { currentStep: 1 } }); + expect(screen.getByLabelText(/schritt 1/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/schritt 2/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/schritt 3/i)).toBeInTheDocument(); + }); + + it('future steps do not have aria-current', () => { + render(ProgressSidebar, { props: { currentStep: 1 } }); + expect(screen.getByTestId('step-2')).not.toHaveAttribute('aria-current'); + expect(screen.getByTestId('step-3')).not.toHaveAttribute('aria-current'); + }); + + it('step 3 active: steps 1 and 2 are both completed', () => { + render(ProgressSidebar, { props: { currentStep: 3 } }); + expect(screen.getByTestId('step-1')).toHaveAttribute('data-state', 'completed'); + expect(screen.getByTestId('step-2')).toHaveAttribute('data-state', 'completed'); + expect(screen.getByTestId('step-3')).toHaveAttribute('aria-current', 'step'); + }); +}); diff --git a/frontend/src/lib/onboarding/HouseholdSetupForm.svelte b/frontend/src/lib/onboarding/HouseholdSetupForm.svelte new file mode 100644 index 0000000..e176f54 --- /dev/null +++ b/frontend/src/lib/onboarding/HouseholdSetupForm.svelte @@ -0,0 +1,80 @@ + + +
diff --git a/frontend/src/lib/onboarding/HouseholdSetupForm.test.ts b/frontend/src/lib/onboarding/HouseholdSetupForm.test.ts new file mode 100644 index 0000000..e3f7785 --- /dev/null +++ b/frontend/src/lib/onboarding/HouseholdSetupForm.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import HouseholdSetupForm from './HouseholdSetupForm.svelte'; + +vi.mock('$app/forms', () => ({ + enhance: () => ({ destroy: () => {} }) +})); + +describe('HouseholdSetupForm', () => { + it('renders household name input with label', () => { + render(HouseholdSetupForm); + expect(screen.getByLabelText('Haushaltsname')).toBeInTheDocument(); + }); + + it('renders heading', () => { + render(HouseholdSetupForm); + expect(screen.getByText('Haushalt benennen')).toBeInTheDocument(); + }); + + it('renders Continue button', () => { + render(HouseholdSetupForm); + expect(screen.getByRole('button', { name: /weiter/i })).toBeInTheDocument(); + }); + + it('Continue button is disabled when name is empty', () => { + render(HouseholdSetupForm); + const btn = screen.getByRole('button', { name: /weiter/i }); + expect(btn).toBeDisabled(); + }); + + it('Continue button is enabled when name has text', async () => { + const user = userEvent.setup(); + render(HouseholdSetupForm); + + await user.type(screen.getByLabelText('Haushaltsname'), 'Familie Müller'); + expect(screen.getByRole('button', { name: /weiter/i })).not.toBeDisabled(); + }); + + it('shows validation error when submitting with empty name', async () => { + const user = userEvent.setup(); + render(HouseholdSetupForm); + + // Type then clear: sets touched=true, which makes the $derived error visible + // as soon as the field is empty. The button is disabled so the click is a no-op, + // but the error is already shown from the touched+empty state. + const input = screen.getByLabelText('Haushaltsname'); + await user.type(input, 'a'); + await user.clear(input); + await user.click(screen.getByRole('button', { name: /weiter/i })); + + expect(screen.getByText('Haushaltsname ist erforderlich')).toBeInTheDocument(); + }); + + it('shows server-side error from form prop', () => { + render(HouseholdSetupForm, { + props: { + form: { + errors: { form: 'Haushalt konnte nicht erstellt werden.' }, + name: 'Smith family' + } + } + }); + expect(screen.getByText('Haushalt konnte nicht erstellt werden.')).toBeInTheDocument(); + }); + + it('repopulates name from form prop on server error', () => { + render(HouseholdSetupForm, { + props: { + form: { + errors: { form: 'Fehler' }, + name: 'Familie Müller' + } + } + }); + expect(screen.getByLabelText('Haushaltsname')).toHaveValue('Familie Müller'); + }); + + it('input has correct placeholder', () => { + render(HouseholdSetupForm); + expect(screen.getByPlaceholderText('z.B. Familie Müller')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/household/setup/+page.server.ts b/frontend/src/routes/household/setup/+page.server.ts new file mode 100644 index 0000000..6fe21b8 --- /dev/null +++ b/frontend/src/routes/household/setup/+page.server.ts @@ -0,0 +1,39 @@ +import { redirect, fail } from '@sveltejs/kit'; +import { apiClient } from '$lib/server/api'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + if (locals.haushalt?.id) { + throw redirect(303, '/planner'); + } + return {}; +}; + +export const actions = { + default: async ({ request, fetch }) => { + const formData = await request.formData(); + const name = (formData.get('name') ?? '').toString().trim(); + + if (!name) { + return fail(400, { errors: { name: 'Haushaltsname ist erforderlich' }, name: '' }); + } + + if (name.length > 100) { + return fail(400, { errors: { name: 'Haushaltsname darf maximal 100 Zeichen lang sein' }, name }); + } + + const api = apiClient(fetch); + const { data, error } = await api.POST('/v1/households', { + body: { name } + }); + + if (error || !data?.data) { + return fail(500, { + errors: { form: 'Haushalt konnte nicht erstellt werden. Bitte versuche es erneut.' }, + name + }); + } + + throw redirect(303, '/household/staples'); + } +} satisfies Actions; diff --git a/frontend/src/routes/household/setup/+page.svelte b/frontend/src/routes/household/setup/+page.svelte new file mode 100644 index 0000000..8048d8c --- /dev/null +++ b/frontend/src/routes/household/setup/+page.svelte @@ -0,0 +1,41 @@ + + ++ Schritt 1 von 3 +
+A3 — Vorräte einrichten (coming soon)
+