From 0bf6b96687fce0739f6fcab39da34e1afcaf9b11 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 19 Apr 2026 22:06:41 +0200 Subject: [PATCH] Add server-side avatar processing and responsive bottom nav Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 58 +++-- app/settings/actions.ts | 58 ++++- components/navigation/account-menu.tsx | 2 +- components/navigation/app-shell.tsx | 2 + components/navigation/bottom-nav.tsx | 62 +++++ components/navigation/navigation-config.ts | 73 ++++++ components/navigation/theme-menu.tsx | 2 +- components/navigation/top-nav.tsx | 48 +--- components/settings/settings-form.tsx | 264 ++++++++++++++++----- components/ui/dropdown-menu.tsx | 2 +- lib/feedback/status-messages.ts | 8 +- lib/profile/avatar-processing.ts | 47 ++++ lib/profile/avatar.ts | 4 +- lib/profile/service.ts | 73 ++++-- lib/profile/types.ts | 5 + lib/supabase/admin.ts | 15 ++ lib/supabase/config.ts | 19 ++ next.config.ts | 5 + package-lock.json | 7 +- package.json | 1 + 20 files changed, 608 insertions(+), 147 deletions(-) create mode 100644 components/navigation/bottom-nav.tsx create mode 100644 components/navigation/navigation-config.ts create mode 100644 lib/profile/avatar-processing.ts create mode 100644 lib/supabase/admin.ts diff --git a/CLAUDE.md b/CLAUDE.md index c200e35..2ea78af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,28 +10,28 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ```bash npm run dev # Start dev server (localhost:3000) -npm run build # Production build +npm run build # Production build --webpack (rolldown disabled) npm run lint # ESLint +npm run test # Vitest (unit tests only) ``` -No test framework is configured yet. - -Node version: `20.9.0` (see `.nvmrc`). +Node version: `>=20.19.0` (see `.nvmrc` / `engines` in package.json). ## Environment Setup -Copy `.env.example` to `.env.local` and fill in your Supabase project values: +Copy `.env.example` to `.env.local`: ``` NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= +NEXT_PUBLIC_ENABLE_TEST_WIZARD=true # optional: enables /wizard-test route ``` Supabase project must have email/password auth enabled with email confirmation. Apply migrations from `supabase/migrations/` to your local/remote DB. ## Architecture -**Stack:** Next.js (App Router) + React 19 + TypeScript + Supabase (Auth + PostgreSQL) + shadcn/ui + Tailwind CSS. Deployed on Vercel. +**Stack:** Next.js 16 (App Router) + React 19 + TypeScript + Supabase (Auth + PostgreSQL) + shadcn/ui + Tailwind CSS. Deployed on Vercel. ### Route structure @@ -42,21 +42,37 @@ Supabase project must have email/password auth enabled with email confirmation. | `/auth/confirm` | Email confirmation callback | | `/onboarding` | Mandatory first-time setup | | `/dashboard` | Main protected page | +| `/planning` | Daily activity planning | +| `/check-in` | Morning check-in flow | | `/settings` | User preferences | +| `/wizard-test` | Internal wizard UI test (feature-flagged) | ### Auth & data flow -- `lib/auth/` — `getAuthState()` validates the session server-side from SSR cookies. All protected routes call this and redirect unauthenticated users to `/login`. -- New users are redirected to `/onboarding`; dashboard redirects there if onboarding is incomplete. -- On first protected page load, `profiles` and `user_settings` rows are auto-created with defaults if missing. -- Server Actions (`app/**/actions.ts`) handle form mutations; client components call these directly. +- `lib/auth/session.ts` — `getAuthState()` validates the session server-side from SSR cookies. All protected pages call this and redirect unauthenticated users to `/login`. +- New users are redirected to `/onboarding`; the dashboard redirects there if onboarding is incomplete. +- On first protected page load, `profiles` and `user_settings` rows are auto-created with defaults if missing (`lib/profile/service.ts`). +- Server Actions (`app/**/actions.ts`) handle all form mutations; client components call these directly. +- Status feedback after redirects flows via `?status=` search params → `lib/feedback/status-messages.ts` → `StatusToastBridge` component (Sonner toasts). + +### Energy model + +The core domain logic lives in `lib/check-in/` and `lib/planning/`: + +- **Morning check-in** (`lib/check-in/`) — user scores energy 1–10 and sleep quality. `budget.ts` derives `energyLevel` and `dailyBudget` (budget = score, formula versioned via `BUDGET_FORMULA_VERSION`). +- **Activity energy points** (`lib/planning/meter.ts`) — each activity scores points from duration band (1–4) × impact adjustment. The planning meter tracks `actualPoints` vs `dailyBudget`. +- **Day overview** (`lib/planning/day-overview.ts`) — aggregates activity counts and planned vs actual points for dashboard display. +- **Activity statuses**: `planned` → `completed` / `adjusted` / `skipped`. Ad-hoc activities can be added directly. ### Database -Two tables with Row Level Security (users see only their own rows): +Tables with Row Level Security (users see only their own rows): -- **`profiles`** — display name, locale, timezone, onboarding completion flags +- **`profiles`** — display name, locale, timezone, avatar, onboarding flags - **`user_settings`** — reminder preferences, energy point visibility +- **`morning_check_ins`** — per-day energy score, sleep quality, derived budget/level +- **`activities`** — daily activity records with status, duration, impact, priority, category +- **`activity_categories`** / **`skip_reasons`** — reference data (seeded) Migrations live in `supabase/migrations/`. @@ -65,12 +81,24 @@ Migrations live in `supabase/migrations/`. - `lib/supabase/` — Supabase client setup (server-side SSR client + proxy config) - `lib/auth/` — session helpers, navigation utilities, Dutch error messages - `lib/profile/service.ts` — CRUD for profiles and user_settings -- `lib/profile/types.ts` — shared TypeScript types for profile/settings data -- `lib/onboarding/` — onboarding options and timezone lists +- `lib/profile/types.ts` — shared TypeScript types +- `lib/forms/parse.ts` — typed FormData helpers (`getRequiredString`, `getEnumValue`, `getIntegerValue`, etc.) used in all Server Actions; throws `FormDataValidationError` with an error code string +- `lib/feedback/status-messages.ts` — maps `?status=` param keys to `StatusToast` objects +- `lib/wizard/` — generic multi-step wizard primitives (`WizardStepDefinition`, `useWizardFlow` hook); used by onboarding and check-in flows +- `lib/search-params.ts` — `getParamValue()` helper for server page `searchParams` +- `lib/config/feature-flags.ts` — `isTestWizardEnabled()` reads `NEXT_PUBLIC_ENABLE_TEST_WIZARD` ### UI components -`components/ui/` contains shadcn/ui primitives (button, card, input, select, alert, etc.). Feature-level components live in `components/auth/`, `components/onboarding/`, and `components/settings/`. Path alias `@/*` resolves from the repo root. +`components/ui/` contains shadcn/ui primitives. Feature-level components live in `components/auth/`, `components/check-in/`, `components/planning/`, `components/onboarding/`, `components/settings/`, `components/wizard/`, `components/navigation/`, `components/feedback/`, and `components/profile/`. Path alias `@/*` resolves from the repo root. + +The app uses the **Dusk theme** — dark-mode-first, semantic surface tokens, enhanced focus/a11y styles (`app/globals.css`). + +### Seeding + +```bash +npm run seed:demo-users # seeds demo personas via scripts/seed-demo-users.mjs +``` ## CI/CD diff --git a/app/settings/actions.ts b/app/settings/actions.ts index d802fe2..ec2c7d7 100644 --- a/app/settings/actions.ts +++ b/app/settings/actions.ts @@ -1,5 +1,6 @@ "use server"; +import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { buildPathWithQuery } from "@/lib/auth/navigation"; import { @@ -11,12 +12,19 @@ import { getOptionalTimeValue, } from "@/lib/forms/parse"; import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options"; +import { ProfileAvatarProcessingError } from "@/lib/profile/avatar-processing"; import { isAllowedProfileAvatarMimeType, - PROFILE_AVATAR_MAX_BYTES, + PROFILE_AVATAR_UPLOAD_MAX_BYTES, } from "@/lib/profile/avatar"; -import { saveSettingsForCurrentUser } from "@/lib/profile/service"; -import type { SettingsSubmission } from "@/lib/profile/types"; +import { + saveProfileAvatarForCurrentUser, + saveSettingsForCurrentUser, +} from "@/lib/profile/service"; +import type { + AvatarUploadActionState, + SettingsSubmission, +} from "@/lib/profile/types"; const LOCALE_VALUES = ["nl-NL"] as const; const ONBOARDING_TIMEZONE_VALUES = ONBOARDING_TIMEZONE_OPTIONS.map((option) => option.value); @@ -47,7 +55,7 @@ function getOptionalAvatarFile(formData: FormData) { } if ( - value.size > PROFILE_AVATAR_MAX_BYTES || + value.size > PROFILE_AVATAR_UPLOAD_MAX_BYTES || !isAllowedProfileAvatarMimeType(value.type) ) { throw new FormDataValidationError("invalid-avatar-file"); @@ -110,6 +118,44 @@ function buildSettingsSubmission(formData: FormData): SettingsSubmission { }; } +export async function uploadAvatarAction( + _previousState: AvatarUploadActionState, + formData: FormData, +): Promise { + try { + const avatarFile = getOptionalAvatarFile(formData); + + if (!avatarFile) { + throw new FormDataValidationError("invalid-avatar-file"); + } + + await saveProfileAvatarForCurrentUser(avatarFile); + revalidatePath("/settings"); + revalidatePath("/dashboard"); + + return { + status: "success", + code: "avatar-saved", + }; + } catch (error) { + if (error instanceof FormDataValidationError) { + return { + status: "error", + code: error.code, + }; + } + + if (error instanceof ProfileAvatarProcessingError) { + return { + status: "error", + code: "invalid-avatar-file", + }; + } + + throw error; + } +} + export async function saveSettingsAction( _previousState: null, formData: FormData, @@ -121,6 +167,10 @@ export async function saveSettingsAction( redirect(buildPathWithQuery("/settings", { error: error.code })); } + if (error instanceof ProfileAvatarProcessingError) { + redirect(buildPathWithQuery("/settings", { error: "invalid-avatar-file" })); + } + throw error; } diff --git a/components/navigation/account-menu.tsx b/components/navigation/account-menu.tsx index 7909a95..40f6e06 100644 --- a/components/navigation/account-menu.tsx +++ b/components/navigation/account-menu.tsx @@ -28,7 +28,7 @@ export function AccountMenu({ authState }: AccountMenuProps) { - Account + Account {authState.isConfigured ? ( diff --git a/components/navigation/app-shell.tsx b/components/navigation/app-shell.tsx index 131d8d2..8e6b2cb 100644 --- a/components/navigation/app-shell.tsx +++ b/components/navigation/app-shell.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import { getAuthState } from "@/lib/auth/session"; +import { BottomNav } from "@/components/navigation/bottom-nav"; import { TopNav } from "@/components/navigation/top-nav"; import { cn } from "@/lib/utils"; @@ -19,6 +20,7 @@ export async function AppShell({
{children}
+
); diff --git a/components/navigation/bottom-nav.tsx b/components/navigation/bottom-nav.tsx new file mode 100644 index 0000000..2b61c0b --- /dev/null +++ b/components/navigation/bottom-nav.tsx @@ -0,0 +1,62 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { bottomNavItems, isActivePath, shouldUseBottomNav } from "@/components/navigation/navigation-config"; +import { cn } from "@/lib/utils"; + +export function BottomNav() { + const pathname = usePathname(); + + if (!shouldUseBottomNav(pathname)) { + return null; + } + + return ( + <> +