diff --git a/.env.example b/.env.example index 4191d20..c92f349 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,5 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_your_key_here +SUPABASE_SECRET_KEY=sb_secret_your_local_admin_key_here +DEMO_USER_PASSWORD=DemoPassword123! +NEXT_PUBLIC_ENABLE_TEST_WIZARD=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d2afa7..cb6c29b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,9 @@ jobs: - name: Lint run: npm run lint + - name: Test + run: npm run test + - name: Build run: npm run build env: diff --git a/.nvmrc b/.nvmrc index f3f52b4..5bd6811 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.9.0 +20.19.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c200e35 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,84 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**Inspannings Monitor** — a Dutch-language wellness web app for energy planning, daily check-ins, reflection, and self-evaluation. UI and all user-facing text are in Dutch (nl-NL). Release 1 targets individuals only; no sharing, AI features, or medical claims. + +## Commands + +```bash +npm run dev # Start dev server (localhost:3000) +npm run build # Production build +npm run lint # ESLint +``` + +No test framework is configured yet. + +Node version: `20.9.0` (see `.nvmrc`). + +## Environment Setup + +Copy `.env.example` to `.env.local` and fill in your Supabase project values: + +``` +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= +``` + +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. + +### Route structure + +| Route | Purpose | +|---|---| +| `/` | Public landing page | +| `/login`, `/sign-up` | Auth pages | +| `/auth/confirm` | Email confirmation callback | +| `/onboarding` | Mandatory first-time setup | +| `/dashboard` | Main protected page | +| `/settings` | User preferences | + +### 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. + +### Database + +Two tables with Row Level Security (users see only their own rows): + +- **`profiles`** — display name, locale, timezone, onboarding completion flags +- **`user_settings`** — reminder preferences, energy point visibility + +Migrations live in `supabase/migrations/`. + +### Key lib modules + +- `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 + +### 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. + +## CI/CD + +GitHub Actions (`.github/workflows/ci.yml`) runs lint + build on every PR and push to `main`. Vercel auto-deploys previews on branches and production on `main`. Production domain: `inspannings-monitor.jp-visser.nl`. + +## Planned next work + +From the backlog (tracked in Linear): +- **ST-201** — Morning check-in feature +- **ST-203** — Energy budget logic +- **ST-301** — Activities data model diff --git a/README.md b/README.md index 4868ea2..c9a4ba9 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,22 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal: - e-mail/wachtwoord-auth via Supabase - protected dashboard met server-side sessiecontrole +- ochtendcheck-in voor energiescore en slaapkwaliteit van vandaag +- eenvoudig dagbudget en energieniveau op basis van de ochtendscore +- dashboardweergave van check-instatus, energieniveau en dagbudget +- planningsfundering met activiteitenmodel, categorieën en skip-redenen in Supabase +- planningpagina voor vandaag met activiteit toevoegen en directe lijstweergave +- statusflows voor activiteiten van vandaag (`gepland`, `uitgevoerd`, `overgeslagen`, `aangepast`) +- contextuele evaluatievelden voor overgeslagen en aangepaste activiteiten +- dagoverzicht op planning met gepland versus werkelijk en statusverdeling +- autocomplete op basis van eerdere eigen activiteiten voor sneller hergebruik in planning +- energiemeter met lopend totaal ten opzichte van het dagbudget +- niet-blokkerende waarschuwing bij budgetoverschrijding in planning en dashboard +- eerste unit tests voor budget- en meterlogica via `Vitest` - korte onboardingflow voor eerste voorkeuren -- instellingen voor taal, timezone, reminders en zichtbaarheid van energiepunten +- instellingen voor profieltekst, avatar, taal, timezone, reminders en zichtbaarheid van energiepunten - `shadcn/ui` foundation voor knoppen, formulieren, kaarten en meldingen +- `Dusk`-theme met dark-mode prioriteit, semantische oppervlakken en verbeterde focus-/toegankelijkheidsstijlen ## Stack @@ -37,6 +50,7 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal: 2. Vul in: - `NEXT_PUBLIC_SUPABASE_URL` - `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` + - optioneel: `NEXT_PUBLIC_ENABLE_TEST_WIZARD=true` voor de interne wizard-testpagina 3. Installeer dependencies met `npm install` 4. Start lokaal met `npm run dev` @@ -46,6 +60,8 @@ product, niet als medisch hulpmiddel. Release 1 blijft smal: - `npm run build` - `npm run start` - `npm run lint` +- `npm run test` +- `npm run seed:demo-users` ## Supabase Auth configuratie @@ -65,34 +81,92 @@ Gebruik alleen `.env.example` als template. Lokale bestanden zoals `.env` en ## Supabase database migraties -Voor `ST-102` staat de eerste databasefundering in: +De huidige app gebruikt onder meer deze migraties: - `supabase/migrations/20260418_create_profiles_and_user_settings.sql` +- `supabase/migrations/20260418_add_onboarding_seen_to_profiles.sql` +- `supabase/migrations/20260418_create_morning_check_ins.sql` +- `supabase/migrations/20260418_add_budget_fields_to_morning_check_ins.sql` +- `supabase/migrations/20260419_create_activities_and_reference_data.sql` +- `supabase/migrations/20260419_add_profile_details_and_avatar_storage.sql` Voer deze SQL uit in de Supabase SQL Editor of via de Supabase CLI voordat je -de profile/settings-laag lokaal test. +de profile-, check-in- en budgetlagen lokaal test. + +## Demo-gebruikers seeden + +Er staat nu een seedscript klaar voor fictieve demo-gebruikers op basis van de +persona-set in [inspannings-monitor-08-gebruikerspersonas-v01.docx](/Users/janpetervisser/Development/third/docs/inspannings-monitor-08-gebruikerspersonas-v01.docx). + +Benodigd: + +- `NEXT_PUBLIC_SUPABASE_URL` +- `SUPABASE_SECRET_KEY` voorkeur +- `SUPABASE_SERVICE_ROLE_KEY` mag nog als legacy alias +- `DEMO_USER_PASSWORD` + +Uitvoeren: + +1. zorg dat de profiel- en storage-migraties al in Supabase zijn uitgevoerd +2. zet de drie env-vars lokaal +3. run `npm run seed:demo-users` + +Voor bestaande lokale setups accepteert het script tijdelijk ook: +- `NEXT_PUBLIC_SUPABASE_SERVICE_KEY` + +Maar mijn advies is om voor seedscripts alleen deze nette niet-public adminnaam te gebruiken: +- `SUPABASE_SECRET_KEY` + +De seeddata zelf staat in: +- [demo-personas.mjs](/Users/janpetervisser/Development/third/scripts/seed/demo-personas.mjs) + +Een dry run kan ook: +- `npm run seed:demo-users -- --dry-run` ## UI foundation De app gebruikt `shadcn/ui` bovenop `Tailwind CSS` als herbruikbare basis voor knoppen, formulieren, kaarten en meldingen. De theme tokens staan centraal in `app/globals.css`, zodat kleur, focus-states en componentgedrag consistenter blijven. +Voor feedback na redirects of server actions krijgt de app nu standaard de voorkeur +voor `sonner`-toasts boven losse inline statusmeldingen. + +De actuele visuele richting is `Dusk`: warme paper-achtergronden, gedempte indigo +als primaire kleur, dark mode als standaard en semantische `success`/`warning` +tokens voor rustige, niet-medische feedback. + +## Navigatie + +De app gebruikt nu een gedeelde topnavigatie: + +- links: `About`, `Dashboard`, `Planning`, `Check-in` +- rechts: `Account` en `Theme` + +`/` is de publieke `About`-pagina met informatie over de maker en de scope van +de app. In het `Account`-menu komen ingelogde gebruikers bij `Instellingen` en +`Uitloggen`; uitgelogde gebruikers zien daar `Inloggen` en `Account aanmaken`. + +## Interne wizard-test + +Er is een interne testwizard beschikbaar op `/wizard-test` om een toekomstige +generieke wizard-core te valideren. Deze route en de dashboardknop worden alleen +zichtbaar als `NEXT_PUBLIC_ENABLE_TEST_WIZARD=true` staat. ## CI/CD -- `CI`: GitHub Actions draait automatisch `lint` en `build` op pull requests en op `main` +- `CI`: GitHub Actions draait automatisch `lint`, `test` en `build` op pull requests en op `main` - `CD`: Vercel deployt automatisch previews voor branches/PR's en productie vanaf `main` - Uitwerking: [docs/inspannings-monitor-cicd-en-deploy.md](/Users/janpetervisser/Development/third/docs/inspannings-monitor-cicd-en-deploy.md) ## Documentatie - Hoofdset specificaties en plannen: [docs/README.md](/Users/janpetervisser/Development/third/docs/README.md) +- Dusk theme-specificatie: [inspannings-monitor-09-dusk-theme-specificatie-v01.md](/Users/janpetervisser/Development/third/docs/inspannings-monitor-09-dusk-theme-specificatie-v01.md) - Technische architectuur: [inspannings-monitor-05-technische-architectuur-en-implementatie-v01.docx](/Users/janpetervisser/Development/third/docs/inspannings-monitor-05-technische-architectuur-en-implementatie-v01.docx) - Implementatieplan en backlog: [inspannings-monitor-06-implementatieplan-en-backlog-v01.docx](/Users/janpetervisser/Development/third/docs/inspannings-monitor-06-implementatieplan-en-backlog-v01.docx) ## Eerstvolgende bouwstappen -1. `ST-201` Ochtendcheck-in UI bouwen -2. `ST-203` Budgetlogica implementeren -3. `ST-301` Activiteitenmodel en planning opzetten -4. `ST-105` RLS-policy tests en hardening afronden +1. `ST-105` RLS-policy tests en hardening afronden +2. logging en monitoring toevoegen +3. rate limiting op kritieke mutaties diff --git a/aanbeveling-claude.md b/aanbeveling-claude.md new file mode 100644 index 0000000..b79a5b7 --- /dev/null +++ b/aanbeveling-claude.md @@ -0,0 +1,305 @@ +# Actuele prioriteiten voor Inspannings Monitor + +Peildatum: **19 april 2026** — bijgewerkt op basis van de huidige codebase en de +recente implementaties voor check-in, planning, energiemeter, Dusk-thema en +navigatiestructuur. + +--- + +## Samenvatting + +De app heeft inmiddels een **sterk fundament**: + +- auth en protected routes werken +- onboarding en instellingen zijn aanwezig +- ochtendcheck-in en budgetlogica zijn aanwezig +- planning en energiemeter zijn aanwezig +- error/loading routes bestaan voor de belangrijkste dataroutes +- pending states en toastfeedback zijn aanwezig +- CI/CD, branch protection en Vercel-deploy staan + +De grootste winst zit nu niet meer in fundering, maar in: + +1. **de plan-do-evalueer-lus sluiten** +2. **kritieke technische gaten dichten vóór launch** +3. **test- en securitylaag versterken** + +--- + +## Wat al op orde is + +Deze punten stonden eerder als aanbeveling open, maar zijn inmiddels al +afgerond of grotendeels opgelost: + +- `error.tsx` voor de belangrijkste app-routes +- `loading.tsx` voor dashboard, check-in en planning +- pending states in onboarding, settings, check-in en planning +- centrale toastlaag voor redirect- en action-feedback +- expliciete `FormData`-validatie +- wizard-core en onboarding-refactor +- ochtendcheck-in en budget v1 +- planning, energiemeter en niet-blokkerende budgetwaarschuwing +- Dusk-themafundering en toegankelijkheidspolish +- topnavigatie met publieke About-pagina + +Deze punten hoeven dus **niet** opnieuw als directe actielijst te worden gezien. + +--- + +## Nu doen + +Dit zijn de hoogste actuele prioriteiten voor de eerstvolgende sprint. + +### 1. ST-401 t/m ST-405 — Evaluatie en dagoverzicht + +De app ondersteunt nu: + +- check-in +- plannen +- energiebudget + +Maar de daglus is nog niet af zolang de gebruiker activiteiten niet kan: + +- afronden +- overslaan +- aanpassen +- samenvatten in een dagoverzicht + +**Waarom nu:** dit is de grootste productmatige ontbrekende schakel. De app +voelt nu al nuttig, maar nog niet “rond”. + +**Concreet:** + +- activiteitstatus wijzigen naar `completed`, `skipped`, `adjusted` +- ongeplande activiteit kunnen toevoegen +- dagaggregaties berekenen +- dagoverzicht tonen met totalen en statusverdeling + +--- + +### 2. `npm test` toevoegen aan CI + +De app heeft nu unit tests voor: + +- budgetlogica +- energiemeterlogica + +Maar in CI draaien nog alleen: + +- `lint` +- `build` + +**Waarom nu:** dit is een kleine wijziging met directe kwaliteitswinst. + +**Concreet:** + +- voeg `npm run test` toe aan `.github/workflows/ci.yml` + +--- + +### 3. Tijdzonehelper dedupliceren + +`getLocalDateForTimezone()` staat nu nog dubbel in: + +- `lib/check-in/service.ts` +- `lib/planning/service.ts` + +**Waarom nu:** klein, veilig en voorkomt toekomstige divergentie. + +**Concreet:** + +- verplaats naar `lib/dates.ts` +- importeer vanuit beide services + +--- + +### 4. Onverwachte DB-fouten consistenter afvangen in server actions + +Validatiefouten worden al goed afgehandeld. Wat nog niet overal strak genoeg is: + +- onverwachte Supabase/DB-fouten +- partiële storingen in servicecalls + +Nu eindigen sommige fouten nog als generieke exception, terwijl de gebruiker +beter een nette foutcode/toast kan krijgen. + +**Waarom nu:** dit verhoogt herstelbaarheid zonder grote refactor. + +**Concreet:** + +- alle action-bestanden nalopen +- onverwachte servicefouten mappen naar gebruikersvriendelijke foutcodes +- bestaande `status/error`-toastpatronen hergebruiken + +--- + +## Daarna doen + +Deze punten zijn belangrijk, maar komen logisch ná de evaluatiefase. + +### 5. ST-105 — RLS hardening en echte policy-tests + +RLS staat aan en ziet er inhoudelijk goed uit, maar is nog niet systematisch +getest tegen misbruikscenario’s. + +**Concreet:** + +- SQL-tests of handmatige scripts schrijven +- lezen/schrijven van andermans rijen expliciet proberen +- checken dat frontend/Vercel geen admin-secret gebruikt + +**Waarom daarna:** belangrijk vóór launch, maar blokkeert de volgende productstap +niet direct. + +--- + +### 6. Testdekking uitbreiden rond pure logica + +Na de bestaande budget- en meter-tests zijn dit de beste vervolgstukken: + +- `lib/forms/parse.ts` +- toekomstige dagaggregatie voor evaluatie +- tijdzone/datumhelpers zodra die gedeeld zijn + +**Waarom daarna:** klein en waardevol, maar minder productkritisch dan ST-401. + +--- + +### 7. Transactie of RPC voor onboarding-opslag + +`completeOnboardingForCurrentUser()` doet nu twee losse updates: + +- `profiles` +- `user_settings` + +Dat werkt, maar kent een klein risico op partiële opslag als de tweede write +faalt. + +**Concreet:** + +- ofwel Supabase RPC +- ofwel server-side transactiepad waar haalbaar + +**Waarom daarna:** belangrijk voor netheid en robuustheid, maar geen acute +blokkade. + +--- + +## Vóór launch + +Deze punten hoeven niet allemaal in de volgende sprint, maar moeten wel vóór een +serieuze publieke introductie op orde zijn. + +### 8. Logging en monitoring + +Nu ontbreekt nog een echte productieloglaag zoals: + +- Sentry +- of vergelijkbare error monitoring + +**Nodig voor:** + +- incidenten terugvinden +- onverwachte action-/DB-fouten volgen +- regressies sneller herkennen + +--- + +### 9. Rate limiting + +Nu leunt auth vooral op Supabase-limieten. Voor de app zelf ontbreekt nog +bewuste begrenzing op mutaties zoals: + +- check-in opslaan +- activiteit toevoegen +- latere evaluatie-updates + +**Doel:** misbruik, spam en piekgedrag beperken. + +--- + +### 10. Secret-history cleanup + +Een oude Supabase `service_role` key heeft eerder in de git-history gestaan. + +Ook al is die key niet meer actief in de app, vóór publieke launch is het nog +steeds verstandig om: + +- die geschiedenis op te schonen +- en te bevestigen dat de sleutel niet meer bruikbaar is + +--- + +### 11. Accessibility, copy en privacy-review + +Voor launch nog nalopen: + +- toetsenbord- en screenreaderflow op kritieke routes +- wellness/self-management copy zonder medische framing +- privacy/DPIA-check passend bij jullie positionering + +--- + +## Later + +Deze punten zijn nuttig, maar nu nog niet de beste besteding van tijd. + +### 12. Paginering en schaaloptimalisaties + +Bij de huidige MVP is de activiteitslijst nog klein. Zaken als: + +- paginering +- geavanceerde caching +- query-optimisaties voor grote datasets + +zijn voorlopig geen topprioriteit. + +--- + +### 13. Supabase codegeneratie voor types + +De huidige handmatige mapping is nog beheersbaar. Codegeneratie via Supabase CLI +kan later waardevol worden, maar nu voegt het waarschijnlijk meer toolinglast +toe dan directe winst. + +--- + +### 14. Zwaardere compliance-laag + +Formele zorgcompliance, audittrail en NEN-achtige eisen zijn pas logisch als de +productpositionering echt opschuift richting zorgmarkt. Voor de huidige +wellness-first MVP is dat nog niet de eerstvolgende stap. + +--- + +## Aangescherpte prioriteitsvolgorde + +```text +Nu: ST-401–405 (evaluatie en dagoverzicht) +Nu: npm test toevoegen aan CI +Nu: getLocalDateForTimezone() dedupliceren naar lib/dates.ts +Nu: onverwachte DB-fouten in actions consistenter mappen +Daarna: ST-105 (RLS hardening en policy-tests) +Daarna: extra tests voor parse- en aggregatielogica +Daarna: onboarding-opslag transactioneler maken +Vóór launch: logging/monitoring +Vóór launch: rate limiting +Vóór launch: secret-history cleanup +Vóór launch: accessibility/copy/privacy review +Later: paginering, codegen, zwaardere compliance-laag +``` + +--- + +## Korte conclusie + +De app is niet meer in de fase van “fundering ontbreekt”. Die fase is grotendeels +voorbij. De actuele focus moet nu verschuiven naar: + +- de gebruikerslus afmaken +- reliability aanscherpen +- en launch-risico’s gecontroleerd terugdringen + +De beste volgende inhoudelijke stap is daarom nog steeds: + +**`ST-401 t/m ST-405` — evaluatie en dagoverzicht.** diff --git a/app/apple-icon.png b/app/apple-icon.png new file mode 100644 index 0000000..8da9806 Binary files /dev/null and b/app/apple-icon.png differ diff --git a/app/auth-actions.ts b/app/auth-actions.ts index 5489b41..2545a89 100644 --- a/app/auth-actions.ts +++ b/app/auth-actions.ts @@ -7,28 +7,76 @@ import { getRequestOrigin, sanitizeNextPath, } from "@/lib/auth/navigation"; +import { + assertEmail, + assertMinLength, + FormDataValidationError, + getOptionalString, + getRequiredString, +} from "@/lib/forms/parse"; import { createClient } from "@/lib/supabase/server"; import { hasSupabaseEnv } from "@/lib/supabase/config"; -function getString(formData: FormData, key: string) { - const value = formData.get(key); - return typeof value === "string" ? value.trim() : ""; +function parseSignInFormData(formData: FormData) { + const next = sanitizeNextPath(getOptionalString(formData, "next")); + const email = assertEmail( + getRequiredString(formData, "email", "missing-fields"), + "invalid-email", + ); + const password = assertMinLength( + getRequiredString(formData, "password", "missing-fields"), + 8, + "password-too-short", + ); + + return { + next, + email, + password, + }; +} + +function parseSignUpFormData(formData: FormData) { + const next = sanitizeNextPath(getOptionalString(formData, "next")); + const email = assertEmail( + getRequiredString(formData, "email", "missing-fields"), + "invalid-email", + ); + const password = assertMinLength( + getRequiredString(formData, "password", "missing-fields"), + 8, + "password-too-short", + ); + + return { + next, + email, + password, + }; } export async function signInAction(formData: FormData) { - const next = sanitizeNextPath(getString(formData, "next")); + let next = sanitizeNextPath(getOptionalString(formData, "next")); + let email = ""; + let password = ""; + + try { + const parsedFormData = parseSignInFormData(formData); + next = parsedFormData.next; + email = parsedFormData.email; + password = parsedFormData.password; + } catch (error) { + if (error instanceof FormDataValidationError) { + redirect(buildPathWithQuery("/login", { error: error.code, next })); + } + + throw error; + } if (!hasSupabaseEnv()) { redirect(buildPathWithQuery("/login", { error: "auth-not-configured", next })); } - const email = getString(formData, "email"); - const password = getString(formData, "password"); - - if (!email || !password) { - redirect(buildPathWithQuery("/login", { error: "missing-fields", next })); - } - const supabase = await createClient(); const { error } = await supabase.auth.signInWithPassword({ email, @@ -50,7 +98,22 @@ export async function signInAction(formData: FormData) { } export async function signUpAction(formData: FormData) { - const next = sanitizeNextPath(getString(formData, "next")); + let next = sanitizeNextPath(getOptionalString(formData, "next")); + let email = ""; + let password = ""; + + try { + const parsedFormData = parseSignUpFormData(formData); + next = parsedFormData.next; + email = parsedFormData.email; + password = parsedFormData.password; + } catch (error) { + if (error instanceof FormDataValidationError) { + redirect(buildPathWithQuery("/sign-up", { error: error.code, next })); + } + + throw error; + } if (!hasSupabaseEnv()) { redirect( @@ -58,13 +121,6 @@ export async function signUpAction(formData: FormData) { ); } - const email = getString(formData, "email"); - const password = getString(formData, "password"); - - if (!email || !password) { - redirect(buildPathWithQuery("/sign-up", { error: "missing-fields", next })); - } - const supabase = await createClient(); const headerStore = await headers(); const origin = getRequestOrigin(headerStore); diff --git a/app/check-in/actions.ts b/app/check-in/actions.ts new file mode 100644 index 0000000..6e21b7b --- /dev/null +++ b/app/check-in/actions.ts @@ -0,0 +1,47 @@ +"use server"; + +import { redirect } from "next/navigation"; +import { buildPathWithQuery } from "@/lib/auth/navigation"; +import { SLEEP_QUALITY_VALUES } from "@/lib/check-in/options"; +import { upsertTodayCheckInForCurrentUser } from "@/lib/check-in/service"; +import type { MorningCheckInSubmission } from "@/lib/check-in/types"; +import { + FormDataValidationError, + getEnumValue, + getIntegerValue, +} from "@/lib/forms/parse"; + +function buildMorningCheckInSubmission(formData: FormData): MorningCheckInSubmission { + return { + energyScore: getIntegerValue( + formData, + "energyScore", + { min: 1, max: 10 }, + "invalid-check-in-input", + ), + sleepQuality: getEnumValue( + formData, + "sleepQuality", + SLEEP_QUALITY_VALUES, + "invalid-check-in-input", + ), + }; +} + +export async function saveMorningCheckInAction( + _previousState: null, + formData: FormData, +): Promise { + try { + await upsertTodayCheckInForCurrentUser(buildMorningCheckInSubmission(formData)); + } catch (error) { + if (error instanceof FormDataValidationError) { + redirect(buildPathWithQuery("/check-in", { error: error.code })); + } + + throw error; + } + + redirect(buildPathWithQuery("/dashboard", { status: "check-in-saved" })); + return null; +} diff --git a/app/check-in/error.tsx b/app/check-in/error.tsx new file mode 100644 index 0000000..ec5ded7 --- /dev/null +++ b/app/check-in/error.tsx @@ -0,0 +1,27 @@ +"use client"; +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; + +export default function CheckInError({ + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
+ + Er is iets misgegaan + + De check-in pagina kon niet worden geladen. Probeer het opnieuw of + kom later terug. + + + +
+
+ ); +} diff --git a/app/check-in/loading.tsx b/app/check-in/loading.tsx new file mode 100644 index 0000000..7057309 --- /dev/null +++ b/app/check-in/loading.tsx @@ -0,0 +1,65 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; + +export default function CheckInLoading() { + return ( +
+
+
+ +
+ + +
+
+ +
+
+ + + +
+ +
+ + + + + +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ ); +} diff --git a/app/check-in/page.tsx b/app/check-in/page.tsx new file mode 100644 index 0000000..1458b9b --- /dev/null +++ b/app/check-in/page.tsx @@ -0,0 +1,120 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; +import { AppShell } from "@/components/navigation/app-shell"; +import { PageIntro } from "@/components/navigation/page-intro"; +import { CheckInForm } from "@/components/check-in/check-in-form"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { sanitizeNextPath } from "@/lib/auth/navigation"; +import { getAuthState } from "@/lib/auth/session"; +import { formatEnergyLevelLabel } from "@/lib/check-in/budget"; +import { getTodayCheckInForCurrentUser } from "@/lib/check-in/service"; +import { getCheckInStatusToast } from "@/lib/feedback/status-messages"; +import { getProfileBundleForCurrentUser } from "@/lib/profile/service"; +import { getParamValue, type PageSearchParams } from "@/lib/search-params"; + +export const dynamic = "force-dynamic"; + +type CheckInPageProps = { + searchParams: Promise; +}; + +export default async function CheckInPage({ searchParams }: CheckInPageProps) { + const authState = await getAuthState(); + const resolvedSearchParams = await searchParams; + + if (!authState.isConfigured) { + redirect("/login?error=auth-not-configured"); + } + + if (!authState.isAuthenticated) { + redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/check-in"))}`); + } + + const profileBundle = await getProfileBundleForCurrentUser(); + + if (!profileBundle) { + redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/check-in"))}`); + } + + if (!profileBundle.profile.onboardingSeen) { + redirect("/onboarding"); + } + + const checkInStatus = await getTodayCheckInForCurrentUser(); + const statusToast = getCheckInStatusToast( + getParamValue(resolvedSearchParams, "error"), + getParamValue(resolvedSearchParams, "status"), + ); + + return ( + +
+ + + + Terug naar dashboard + + } + /> + +
+ + + +
+
+
+ ); +} diff --git a/app/dashboard/error.tsx b/app/dashboard/error.tsx new file mode 100644 index 0000000..d696d6f --- /dev/null +++ b/app/dashboard/error.tsx @@ -0,0 +1,27 @@ +"use client"; +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; + +export default function DashboardError({ + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
+ + Er is iets misgegaan + + Het dashboard kon niet worden geladen. Probeer het opnieuw of kom + later terug. + + + +
+
+ ); +} diff --git a/app/dashboard/loading.tsx b/app/dashboard/loading.tsx new file mode 100644 index 0000000..6393480 --- /dev/null +++ b/app/dashboard/loading.tsx @@ -0,0 +1,41 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; + +export default function DashboardLoading() { + return ( +
+
+
+ +
+ + +
+
+ +
+
+ + + +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( + + + + + + + + + + + ))} +
+
+
+
+ ); +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 69f3a22..ced66d8 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,8 +1,11 @@ import Link from "next/link"; import { redirect } from "next/navigation"; -import { signOutAction } from "@/app/auth-actions"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Button, buttonVariants } from "@/components/ui/button"; +import { CheckInCard } from "@/components/check-in/check-in-card"; +import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; +import { AppShell } from "@/components/navigation/app-shell"; +import { PageIntro } from "@/components/navigation/page-intro"; +import { EnergyMeterCard } from "@/components/planning/energy-meter-card"; +import { ProfileAvatar } from "@/components/profile/profile-avatar"; import { Card, CardContent, @@ -12,23 +15,20 @@ import { } from "@/components/ui/card"; import { sanitizeNextPath } from "@/lib/auth/navigation"; import { getAuthState } from "@/lib/auth/session"; +import { getTodayCheckInForCurrentUser } from "@/lib/check-in/service"; +import { isTestWizardEnabled } from "@/lib/config/feature-flags"; +import { getDashboardStatusToast } from "@/lib/feedback/status-messages"; +import { getTodayActivitiesForCurrentUser } from "@/lib/planning/service"; +import { calculatePlanningMeterSnapshot } from "@/lib/planning/meter"; import { getProfileBundleForCurrentUser } from "@/lib/profile/service"; -import { cn } from "@/lib/utils"; +import { getParamValue, type PageSearchParams } from "@/lib/search-params"; export const dynamic = "force-dynamic"; type DashboardPageProps = { - searchParams: Promise>; + searchParams: Promise; }; -function getParamValue( - params: Record, - key: string, -) { - const value = params[key]; - return typeof value === "string" ? value : null; -} - function formatToggleState(value: boolean, enabledLabel = "Aan", disabledLabel = "Uit") { return value ? enabledLabel : disabledLabel; } @@ -37,18 +37,6 @@ function formatReminderTime(value: string | null) { return value ? value.slice(0, 5) : "Nog niet ingesteld"; } -function getDashboardNotice(status: string | null) { - if (status === "onboarding-completed") { - return "Je onboarding is opgeslagen. Je basisinstellingen staan nu klaar."; - } - - if (status === "onboarding-skipped") { - return "Je hebt de onboarding nu overgeslagen. Je kunt hem later alsnog afronden."; - } - - return null; -} - export default async function DashboardPage({ searchParams }: DashboardPageProps) { const authState = await getAuthState(); const resolvedSearchParams = await searchParams; @@ -68,7 +56,11 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps } const { profile, settings } = profileBundle; - const notice = getDashboardNotice(getParamValue(resolvedSearchParams, "status")); + const [checkInStatus, planningStatus] = await Promise.all([ + getTodayCheckInForCurrentUser(), + getTodayActivitiesForCurrentUser(), + ]); + const statusToast = getDashboardStatusToast(getParamValue(resolvedSearchParams, "status")); if (!profile.onboardingSeen) { redirect("/onboarding"); @@ -79,58 +71,39 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps const morningReminderState = settings.morningReminderEnabled ? `Aan om ${formatReminderTime(settings.morningReminderTime)}` : "Uit"; + const planningMeter = calculatePlanningMeterSnapshot( + planningStatus?.activities ?? [], + checkInStatus?.todayCheckIn?.dailyBudget ?? null, + ); return ( -
-
- {notice ? ( - - - {notice} - - - ) : null} + +
+ -
-
-

- Protected route -

-

- Dashboard placeholder voor release 1 -

-

- Je sessie is server-side gevalideerd en het minimale profielbundle is - nu beschikbaar. Daarmee staat de fundering voor onboarding, settings - en de eerste energieflows klaar. -

-
- -
-
+ - Instellingen + Test wizard - -
-
-
+ ) : null + } + />
- +

Auth

- Cookie-based sessie actief + Cookie-based sessie actief
@@ -139,27 +112,45 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
- +

Profiel

- {profileTitle}
- + +
+ +
+ {profileTitle} + + {profile.tagline ?? "Nog geen korte profielregel toegevoegd."} + +
+
Taal `{profile.locale}` en timezone `{profile.timezone}` staan nu per gebruiker opgeslagen. + {profile.bio ? ( + + {profile.bio} + + ) : null}
- +

Onboarding

- {onboardingState} + {onboardingState}
@@ -169,12 +160,12 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
- +

Instellingen

- + Punten {formatToggleState(settings.showEnergyPoints, "zichtbaar", "verborgen")}
@@ -185,31 +176,68 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
+ + + + + +

+ Dagplanning +

+ + {planningStatus?.activities.length + ? `${planningStatus.activities.length} activiteiten voor vandaag` + : "Nog niets toegevoegd voor vandaag"} + +
+ + + Plan kleine, concrete activiteiten voor vandaag en leg ook onverwachte activiteiten vast als je dag anders loopt dan gedacht. + +
+ + Open dagplanning + +
+
+
+ + + + {isTestWizardEnabled() ? ( + + +

+ Wizard core +

+ Interne testwizard actief +
+ + + Gebruik deze alleen in development of preview om nieuwe multi-step flows te controleren. + + +
+ ) : null}
{!profile.onboardingCompleted ? ( - +

Je onboarding is nog niet afgerond.

-

+

Je kunt de korte flow later alsnog afronden om je basisinstellingen en eerste voorkeuren vast te leggen.

- - Rond onboarding af - + + Rond onboarding af +
) : ( - +

Je instellingen kun je nu ook los beheren.

@@ -218,19 +246,13 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps timezone en zichtbaarheid van punten later zelfstandig kunt aanpassen.

- - Open instellingen - -
-
- )} + + Open instellingen + + +
+ )}
-
+ ); } diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..01beb88 Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css index d653f32..492306b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -5,42 +5,52 @@ @custom-variant dark (&:is(.dark *)); :root { - --font-display: "Iowan Old Style", "Palatino Linotype", "URW Palladio L", - Palatino, Georgia, serif; - --font-body: "Inter", "Aptos", "Segoe UI", "Helvetica Neue", Arial, - sans-serif; - --background: #f5f4ee; - --foreground: #0f172a; - --card: rgb(255 255 255 / 0.84); - --card-foreground: #0f172a; - --popover: #ffffff; - --popover-foreground: #0f172a; - --primary: #163a2b; - --primary-foreground: #effaf3; - --secondary: #e5ecde; - --secondary-foreground: #163a2b; - --muted: #eef2e6; - --muted-foreground: #51606f; - --accent: #dbe7d1; - --accent-foreground: #163a2b; - --destructive: #b91c1c; - --border: rgb(15 23 42 / 0.1); - --input: rgb(15 23 42 / 0.12); - --ring: #5d8a67; - --chart-1: #163a2b; - --chart-2: #5d8a67; - --chart-3: #90a955; - --chart-4: #b7c5a4; - --chart-5: #d7dfce; - --radius: 1rem; - --sidebar: #fbfaf5; - --sidebar-foreground: #0f172a; - --sidebar-primary: #163a2b; - --sidebar-primary-foreground: #effaf3; - --sidebar-accent: #dbe7d1; - --sidebar-accent-foreground: #163a2b; - --sidebar-border: rgb(15 23 42 / 0.08); - --sidebar-ring: #5d8a67; + --font-body: var(--font-inter-tight), ui-sans-serif, system-ui, sans-serif; + --font-display: var(--font-body); + --font-mono: var(--font-plex-mono), ui-monospace, monospace; + + --background: oklch(97% 0.008 80); + --foreground: oklch(22% 0.03 262); + --card: oklch(99% 0.004 80); + --card-foreground: oklch(22% 0.03 262); + --popover: oklch(100% 0 0); + --popover-foreground: oklch(22% 0.03 262); + + --primary: oklch(44% 0.11 262); + --primary-foreground: oklch(98% 0.01 262); + --secondary: oklch(92% 0.03 262); + --secondary-foreground: oklch(44% 0.11 262); + --muted: oklch(95% 0.012 82); + --muted-foreground: oklch(58% 0.015 262); + --accent: oklch(92% 0.03 262); + --accent-foreground: oklch(44% 0.11 262); + + --destructive: oklch(58% 0.16 25); + --success: oklch(62% 0.09 155); + --warning: oklch(72% 0.1 70); + + --border: oklch(22% 0.03 262 / 0.1); + --input: oklch(22% 0.03 262 / 0.12); + --ring: oklch(44% 0.11 262); + + --chart-1: oklch(44% 0.11 262); + --chart-2: oklch(60% 0.09 262); + --chart-3: oklch(70% 0.1 50); + --chart-4: oklch(62% 0.09 155); + --chart-5: oklch(80% 0.03 262); + + --radius: 14px; + --shadow-1: 0 1px 3px oklch(22% 0.03 262 / 0.1), 0 10px 30px oklch(22% 0.03 262 / 0.06); + --shadow-2: 0 4px 16px oklch(22% 0.03 262 / 0.14), 0 18px 45px oklch(22% 0.03 262 / 0.12); + --shadow-3: 0 24px 60px oklch(22% 0.03 262 / 0.18); + --sidebar: oklch(96.5% 0.01 80); + --sidebar-foreground: oklch(22% 0.03 262); + --sidebar-primary: oklch(44% 0.11 262); + --sidebar-primary-foreground: oklch(98% 0.01 262); + --sidebar-accent: oklch(92% 0.03 262); + --sidebar-accent-foreground: oklch(44% 0.11 262); + --sidebar-border: oklch(22% 0.03 262 / 0.08); + --sidebar-ring: oklch(44% 0.11 262); } * { @@ -79,6 +89,8 @@ a { --color-chart-3: var(--chart-3); --color-chart-2: var(--chart-2); --color-chart-1: var(--chart-1); + --color-warning: var(--warning); + --color-success: var(--success); --color-ring: var(--ring); --color-input: var(--input); --color-border: var(--border); @@ -97,47 +109,60 @@ a { --color-card: var(--card); --color-foreground: var(--foreground); --color-background: var(--background); - --radius-sm: calc(var(--radius) * 0.6); - --radius-md: calc(var(--radius) * 0.8); + --font-mono: var(--font-plex-mono), ui-monospace, monospace; + --radius-sm: 8px; + --radius-md: 11px; --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) * 1.4); - --radius-2xl: calc(var(--radius) * 1.8); - --radius-3xl: calc(var(--radius) * 2.2); - --radius-4xl: calc(var(--radius) * 2.6); + --radius-xl: 21px; + --radius-2xl: 25px; + --radius-3xl: 32px; + --radius-4xl: 40px; + --radius-full: 9999px; } .dark { - --background: #111927; - --foreground: #eef6f0; - --card: rgb(17 25 39 / 0.84); - --card-foreground: #eef6f0; - --popover: #152131; - --popover-foreground: #eef6f0; - --primary: #d9f2de; - --primary-foreground: #133225; - --secondary: #243244; - --secondary-foreground: #eef6f0; - --muted: #243244; - --muted-foreground: #b1bec8; - --accent: #31485b; - --accent-foreground: #eef6f0; - --destructive: #ef4444; - --border: rgb(255 255 255 / 0.12); - --input: rgb(255 255 255 / 0.14); - --ring: #88b593; - --chart-1: #d9f2de; - --chart-2: #88b593; - --chart-3: #90a955; - --chart-4: #51606f; - --chart-5: #243244; - --sidebar: #152131; - --sidebar-foreground: #eef6f0; - --sidebar-primary: #d9f2de; - --sidebar-primary-foreground: #133225; - --sidebar-accent: #243244; - --sidebar-accent-foreground: #eef6f0; - --sidebar-border: rgb(255 255 255 / 0.12); - --sidebar-ring: #88b593; + --background: oklch(17% 0.02 262); + --foreground: oklch(96% 0.008 80); + --card: oklch(22% 0.025 262); + --card-foreground: oklch(96% 0.008 80); + --popover: oklch(22% 0.025 262); + --popover-foreground: oklch(96% 0.008 80); + + --primary: oklch(52% 0.13 262); + --primary-foreground: oklch(98% 0.01 262); + --secondary: oklch(28% 0.03 262); + --secondary-foreground: oklch(96% 0.008 80); + --muted: oklch(26% 0.025 262); + --muted-foreground: oklch(70% 0.015 262); + --accent: oklch(30% 0.04 262); + --accent-foreground: oklch(96% 0.008 80); + + --destructive: oklch(70% 0.16 25); + --success: oklch(74% 0.09 155); + --warning: oklch(80% 0.1 70); + + --border: oklch(100% 0 0 / 0.1); + --input: oklch(100% 0 0 / 0.1); + --ring: oklch(78% 0.08 262); + + --chart-1: oklch(78% 0.08 262); + --chart-2: oklch(60% 0.09 262); + --chart-3: oklch(80% 0.1 70); + --chart-4: oklch(74% 0.09 155); + --chart-5: oklch(36% 0.03 262); + + --shadow-1: 0 1px 3px oklch(0% 0 0 / 0.24), 0 10px 30px oklch(0% 0 0 / 0.2); + --shadow-2: 0 4px 16px oklch(0% 0 0 / 0.28), 0 18px 45px oklch(0% 0 0 / 0.24); + --shadow-3: 0 24px 60px oklch(0% 0 0 / 0.32); + + --sidebar: oklch(20% 0.022 262); + --sidebar-foreground: oklch(96% 0.008 80); + --sidebar-primary: oklch(52% 0.13 262); + --sidebar-primary-foreground: oklch(98% 0.01 262); + --sidebar-accent: oklch(28% 0.03 262); + --sidebar-accent-foreground: oklch(96% 0.008 80); + --sidebar-border: oklch(100% 0 0 / 0.1); + --sidebar-ring: oklch(78% 0.08 262); } @layer base { @@ -152,12 +177,113 @@ a { @apply font-sans; background: var(--background); color: var(--foreground); + color-scheme: light; } body { @apply bg-background text-foreground; margin: 0; min-height: 100vh; font-family: var(--font-body), sans-serif; + line-height: 1.7; + font-variant-numeric: tabular-nums; -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + html.dark { + color-scheme: dark; + } + h1 { + letter-spacing: -0.03em; + line-height: 1.08; + } + h2 { + letter-spacing: -0.02em; + line-height: 1.2; + } + h3 { + line-height: 1.3; + } + code, + kbd, + samp, + pre { + font-family: var(--font-mono), monospace; + font-variant-numeric: tabular-nums; + } +} + +@layer components { + .app-page { + @apply min-h-screen px-6 py-10 text-foreground sm:px-8; + background-color: var(--background); + background-image: + radial-gradient( + circle at top, + color-mix(in oklab, var(--primary) 16%, transparent) 0%, + transparent 34% + ), + linear-gradient( + 180deg, + color-mix(in oklab, var(--background) 96%, var(--secondary) 4%) 0%, + color-mix(in oklab, var(--background) 86%, var(--secondary) 14%) 100% + ); + } + + .dark .app-page { + background-image: + radial-gradient( + circle at top, + color-mix(in oklab, var(--primary) 22%, transparent) 0%, + transparent 38% + ), + linear-gradient( + 180deg, + color-mix(in oklab, var(--background) 96%, var(--secondary) 4%) 0%, + color-mix(in oklab, var(--background) 88%, var(--secondary) 12%) 100% + ); + } + + .app-page-header { + @apply flex flex-col gap-5 rounded-[var(--radius-4xl)] p-6 sm:flex-row sm:items-start sm:justify-between sm:p-8; + border: 1px solid color-mix(in oklab, var(--border) 88%, transparent); + background: color-mix(in oklab, var(--card) 84%, transparent); + box-shadow: var(--shadow-2); + backdrop-filter: blur(18px); + } + + .app-page-breadcrumb { + @apply flex flex-wrap items-center gap-3 text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground; + } + + .app-page-link { + @apply transition hover:text-foreground; + } + + .app-page-copy { + @apply mt-4 max-w-2xl text-base leading-8 text-muted-foreground; + } + + .app-panel-primary { + @apply text-primary-foreground; + border: 1px solid color-mix(in oklab, var(--primary) 16%, transparent); + background: var(--primary); + box-shadow: var(--shadow-2); + } + + .cn-toast { + border: 1px solid color-mix(in oklab, var(--border) 92%, transparent); + box-shadow: var(--shadow-3); + backdrop-filter: blur(18px); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; } } diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000..579db7a --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,27 @@ + + + + + + + diff --git a/app/layout.tsx b/app/layout.tsx index 683c110..cfa32ae 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,22 @@ import type { Metadata } from "next"; +import { IBM_Plex_Mono, Inter_Tight } from "next/font/google"; +import { ThemeProvider } from "@/components/theme-provider"; +import { Toaster } from "@/components/ui/sonner"; import "./globals.css"; +const fontBody = Inter_Tight({ + subsets: ["latin"], + variable: "--font-inter-tight", + display: "swap", +}); + +const fontMono = IBM_Plex_Mono({ + subsets: ["latin"], + weight: ["400", "500"], + variable: "--font-plex-mono", + display: "swap", +}); + export const metadata: Metadata = { title: "Inspannings Monitor", description: @@ -13,8 +29,21 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - {children} + + + + {children} + + + ); } diff --git a/app/login/page.tsx b/app/login/page.tsx index 5f07585..9936a14 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,30 +1,23 @@ import Link from "next/link"; import { redirect } from "next/navigation"; -import { AuthNotice } from "@/components/auth/auth-notice"; +import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; import { AuthPanel } from "@/components/auth/auth-panel"; import { signInAction } from "@/app/auth-actions"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { getAuthNotice } from "@/lib/auth/messages"; import { buildPathWithQuery, sanitizeNextPath } from "@/lib/auth/navigation"; import { getAuthState } from "@/lib/auth/session"; +import { getAuthStatusToast } from "@/lib/feedback/status-messages"; +import { getParamValue, type PageSearchParams } from "@/lib/search-params"; export const dynamic = "force-dynamic"; type LoginPageProps = { - searchParams: Promise>; + searchParams: Promise; }; -function getParamValue( - params: Record, - key: string, -) { - const value = params[key]; - return typeof value === "string" ? value : null; -} - export default async function LoginPage({ searchParams }: LoginPageProps) { const authState = await getAuthState(); const resolvedSearchParams = await searchParams; @@ -34,7 +27,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) { redirect(next); } - const notice = getAuthNotice( + const statusToast = getAuthStatusToast( getParamValue(resolvedSearchParams, "error"), getParamValue(resolvedSearchParams, "status"), ); @@ -48,16 +41,16 @@ export default async function LoginPage({ searchParams }: LoginPageProps) { footer={

Nog geen account?{" "} - + Maak er een aan

} > - + {!authState.isConfigured ? ( - + Voeg eerst je Supabase-gegevens toe in `.env.local` op basis van `.env.example`. @@ -67,7 +60,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
-