diff --git a/.env.example b/.env.example index 4191d20..b89112c 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_your_key_here +NEXT_PUBLIC_ENABLE_TEST_WIZARD=false diff --git a/README.md b/README.md index 4868ea2..3f99b90 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,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` @@ -77,6 +78,14 @@ de profile/settings-laag lokaal test. 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. + +## 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 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/dashboard/page.tsx b/app/dashboard/page.tsx index 69f3a22..dd53b1f 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { signOutAction } from "@/app/auth-actions"; -import { Alert, AlertDescription } from "@/components/ui/alert"; +import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; import { Button, buttonVariants } from "@/components/ui/button"; import { Card, @@ -12,23 +12,18 @@ import { } from "@/components/ui/card"; import { sanitizeNextPath } from "@/lib/auth/navigation"; import { getAuthState } from "@/lib/auth/session"; +import { isTestWizardEnabled } from "@/lib/config/feature-flags"; +import { getDashboardStatusToast } from "@/lib/feedback/status-messages"; import { getProfileBundleForCurrentUser } from "@/lib/profile/service"; +import { getParamValue, type PageSearchParams } from "@/lib/search-params"; import { cn } from "@/lib/utils"; 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 +32,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 +51,7 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps } const { profile, settings } = profileBundle; - const notice = getDashboardNotice(getParamValue(resolvedSearchParams, "status")); + const statusToast = getDashboardStatusToast(getParamValue(resolvedSearchParams, "status")); if (!profile.onboardingSeen) { redirect("/onboarding"); @@ -83,13 +66,7 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps return (
- {notice ? ( - - - {notice} - - - ) : null} +
@@ -117,6 +94,17 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps > Instellingen + {isTestWizardEnabled() ? ( + + Test wizard + + ) : null} @@ -185,6 +173,22 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps + + {isTestWizardEnabled() ? ( + + +

+ Wizard core +

+ Interne testwizard actief +
+ + + Gebruik deze alleen in development of preview om nieuwe multi-step flows te controleren. + + +
+ ) : null} {!profile.onboardingCompleted ? ( diff --git a/app/layout.tsx b/app/layout.tsx index 683c110..d3ff4a6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { Toaster } from "@/components/ui/sonner"; import "./globals.css"; export const metadata: Metadata = { @@ -14,7 +15,10 @@ export default function RootLayout({ }>) { return ( - {children} + + {children} + + ); } diff --git a/app/login/page.tsx b/app/login/page.tsx index 5f07585..1cdf584 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"), ); @@ -54,7 +47,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {

} > - + {!authState.isConfigured ? ( diff --git a/app/onboarding/actions.ts b/app/onboarding/actions.ts index 389795f..4b8e1e2 100644 --- a/app/onboarding/actions.ts +++ b/app/onboarding/actions.ts @@ -2,38 +2,80 @@ import { redirect } from "next/navigation"; import { buildPathWithQuery } from "@/lib/auth/navigation"; +import { + FormDataValidationError, + getBooleanValue, + getEnumValue, + getOptionalString, + getOptionalTimeValue, +} from "@/lib/forms/parse"; +import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options"; import { completeOnboardingForCurrentUser, markOnboardingSeenForCurrentUser, } from "@/lib/profile/service"; import type { OnboardingSubmission } from "@/lib/profile/types"; -function getString(formData: FormData, key: string) { - const value = formData.get(key); - return typeof value === "string" ? value.trim() : ""; -} - -function getBoolean(formData: FormData, key: string) { - return formData.get(key) === "true"; -} +const ONBOARDING_TIMEZONE_VALUES = ONBOARDING_TIMEZONE_OPTIONS.map((option) => option.value); function buildOnboardingSubmission(formData: FormData): OnboardingSubmission { + const morningReminderEnabled = getBooleanValue( + formData, + "morningReminderEnabled", + "invalid-onboarding-input", + ); + const reminderTime = getOptionalTimeValue( + formData, + "morningReminderTime", + "invalid-onboarding-input", + ); + return { - displayName: getString(formData, "displayName") || null, - timezone: getString(formData, "timezone"), - morningReminderEnabled: getBoolean(formData, "morningReminderEnabled"), - morningReminderTime: getString(formData, "morningReminderTime") || null, - reflectionReminderEnabled: getBoolean(formData, "reflectionReminderEnabled"), - showEnergyPoints: getBoolean(formData, "showEnergyPoints"), + displayName: getOptionalString(formData, "displayName") || null, + timezone: getEnumValue( + formData, + "timezone", + ONBOARDING_TIMEZONE_VALUES, + "invalid-onboarding-input", + ), + morningReminderEnabled, + morningReminderTime: morningReminderEnabled ? reminderTime : null, + reflectionReminderEnabled: getBooleanValue( + formData, + "reflectionReminderEnabled", + "invalid-onboarding-input", + ), + showEnergyPoints: getBooleanValue( + formData, + "showEnergyPoints", + "invalid-onboarding-input", + ), }; } -export async function completeOnboardingAction(formData: FormData) { - await completeOnboardingForCurrentUser(buildOnboardingSubmission(formData)); +export async function completeOnboardingAction( + _previousState: null, + formData: FormData, +): Promise { + try { + await completeOnboardingForCurrentUser(buildOnboardingSubmission(formData)); + } catch (error) { + if (error instanceof FormDataValidationError) { + redirect(buildPathWithQuery("/onboarding", { error: error.code })); + } + + throw error; + } + redirect(buildPathWithQuery("/dashboard", { status: "onboarding-completed" })); + return null; } -export async function skipOnboardingAction() { +export async function skipOnboardingAction( + _previousState: null, + _formData: FormData, +): Promise { await markOnboardingSeenForCurrentUser(); redirect(buildPathWithQuery("/dashboard", { status: "onboarding-skipped" })); + return null; } diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx index 2e48884..187572f 100644 --- a/app/onboarding/page.tsx +++ b/app/onboarding/page.tsx @@ -1,13 +1,21 @@ import { redirect } from "next/navigation"; +import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; import { OnboardingFlow } from "@/components/onboarding/onboarding-flow"; import { sanitizeNextPath } from "@/lib/auth/navigation"; import { getAuthState } from "@/lib/auth/session"; +import { getOnboardingStatusToast } from "@/lib/feedback/status-messages"; import { getProfileBundleForCurrentUser } from "@/lib/profile/service"; +import { getParamValue, type PageSearchParams } from "@/lib/search-params"; export const dynamic = "force-dynamic"; -export default async function OnboardingPage() { +type OnboardingPageProps = { + searchParams: Promise; +}; + +export default async function OnboardingPage({ searchParams }: OnboardingPageProps) { const authState = await getAuthState(); + const resolvedSearchParams = await searchParams; if (!authState.isConfigured) { redirect("/login?error=auth-not-configured"); @@ -27,9 +35,15 @@ export default async function OnboardingPage() { redirect("/dashboard"); } + const statusToast = getOnboardingStatusToast( + getParamValue(resolvedSearchParams, "error"), + getParamValue(resolvedSearchParams, "status"), + ); + return (
+
diff --git a/app/page.tsx b/app/page.tsx index de51a24..7ac5a2c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { signOutAction } from "@/app/auth-actions"; -import { AuthNotice } from "@/components/auth/auth-notice"; +import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; import { Button, buttonVariants } from "@/components/ui/button"; import { Card, @@ -9,8 +9,9 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { getAuthNotice } from "@/lib/auth/messages"; import { getAuthState } from "@/lib/auth/session"; +import { getAuthStatusToast } from "@/lib/feedback/status-messages"; +import { getParamValue, type PageSearchParams } from "@/lib/search-params"; import { cn } from "@/lib/utils"; export const dynamic = "force-dynamic"; @@ -38,21 +39,13 @@ const releaseFocus = [ ]; type HomePageProps = { - searchParams: Promise>; + searchParams: Promise; }; -function getParamValue( - params: Record, - key: string, -) { - const value = params[key]; - return typeof value === "string" ? value : null; -} - export default async function Home({ searchParams }: HomePageProps) { const authState = await getAuthState(); const resolvedSearchParams = await searchParams; - const notice = getAuthNotice( + const statusToast = getAuthStatusToast( getParamValue(resolvedSearchParams, "error"), getParamValue(resolvedSearchParams, "status"), ); @@ -118,7 +111,7 @@ export default async function Home({ searchParams }: HomePageProps) {
- +
diff --git a/app/settings/actions.ts b/app/settings/actions.ts index e68902f..4a661f4 100644 --- a/app/settings/actions.ts +++ b/app/settings/actions.ts @@ -2,30 +2,68 @@ import { redirect } from "next/navigation"; import { buildPathWithQuery } from "@/lib/auth/navigation"; +import { + FormDataValidationError, + getBooleanValue, + getEnumValue, + getOptionalTimeValue, +} from "@/lib/forms/parse"; +import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options"; import { saveSettingsForCurrentUser } from "@/lib/profile/service"; import type { SettingsSubmission } from "@/lib/profile/types"; -function getString(formData: FormData, key: string) { - const value = formData.get(key); - return typeof value === "string" ? value.trim() : ""; -} - -function getBoolean(formData: FormData, key: string) { - return formData.get(key) === "true"; -} +const LOCALE_VALUES = ["nl-NL"] as const; +const ONBOARDING_TIMEZONE_VALUES = ONBOARDING_TIMEZONE_OPTIONS.map((option) => option.value); function buildSettingsSubmission(formData: FormData): SettingsSubmission { + const morningReminderEnabled = getBooleanValue( + formData, + "morningReminderEnabled", + "invalid-settings-input", + ); + const reminderTime = getOptionalTimeValue( + formData, + "morningReminderTime", + "invalid-settings-input", + ); + return { - locale: getString(formData, "locale"), - timezone: getString(formData, "timezone"), - morningReminderEnabled: getBoolean(formData, "morningReminderEnabled"), - morningReminderTime: getString(formData, "morningReminderTime") || null, - reflectionReminderEnabled: getBoolean(formData, "reflectionReminderEnabled"), - showEnergyPoints: getBoolean(formData, "showEnergyPoints"), + locale: getEnumValue(formData, "locale", LOCALE_VALUES, "invalid-settings-input"), + timezone: getEnumValue( + formData, + "timezone", + ONBOARDING_TIMEZONE_VALUES, + "invalid-settings-input", + ), + morningReminderEnabled, + morningReminderTime: morningReminderEnabled ? reminderTime : null, + reflectionReminderEnabled: getBooleanValue( + formData, + "reflectionReminderEnabled", + "invalid-settings-input", + ), + showEnergyPoints: getBooleanValue( + formData, + "showEnergyPoints", + "invalid-settings-input", + ), }; } -export async function saveSettingsAction(formData: FormData) { - await saveSettingsForCurrentUser(buildSettingsSubmission(formData)); +export async function saveSettingsAction( + _previousState: null, + formData: FormData, +): Promise { + try { + await saveSettingsForCurrentUser(buildSettingsSubmission(formData)); + } catch (error) { + if (error instanceof FormDataValidationError) { + redirect(buildPathWithQuery("/settings", { error: error.code })); + } + + throw error; + } + redirect(buildPathWithQuery("/settings", { status: "saved" })); + return null; } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index e251070..12598aa 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -1,8 +1,8 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { signOutAction } from "@/app/auth-actions"; +import { StatusToastBridge } from "@/components/feedback/status-toast-bridge"; import { SettingsForm } from "@/components/settings/settings-form"; -import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button, buttonVariants } from "@/components/ui/button"; import { Card, @@ -13,31 +13,17 @@ import { } from "@/components/ui/card"; import { sanitizeNextPath } from "@/lib/auth/navigation"; import { getAuthState } from "@/lib/auth/session"; +import { getSettingsStatusToast } from "@/lib/feedback/status-messages"; import { getProfileBundleForCurrentUser } from "@/lib/profile/service"; +import { getParamValue, type PageSearchParams } from "@/lib/search-params"; import { cn } from "@/lib/utils"; export const dynamic = "force-dynamic"; type SettingsPageProps = { - searchParams: Promise>; + searchParams: Promise; }; -function getParamValue( - params: Record, - key: string, -) { - const value = params[key]; - return typeof value === "string" ? value : null; -} - -function getSettingsNotice(status: string | null) { - if (status === "saved") { - return "Je instellingen zijn opgeslagen."; - } - - return null; -} - export default async function SettingsPage({ searchParams }: SettingsPageProps) { const authState = await getAuthState(); const resolvedSearchParams = await searchParams; @@ -60,7 +46,10 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps) redirect("/onboarding"); } - const notice = getSettingsNotice(getParamValue(resolvedSearchParams, "status")); + const statusToast = getSettingsStatusToast( + getParamValue(resolvedSearchParams, "error"), + getParamValue(resolvedSearchParams, "status"), + ); const profileTitle = profileBundle.profile.displayName ?? profileBundle.profile.email ?? @@ -70,6 +59,8 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps) return (
+ +
@@ -106,14 +97,6 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps)
- {notice ? ( - - - {notice} - - - ) : null} -
diff --git a/app/sign-up/page.tsx b/app/sign-up/page.tsx index 8f101bb..6cb17a0 100644 --- a/app/sign-up/page.tsx +++ b/app/sign-up/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 { signUpAction } 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 SignUpPageProps = { - searchParams: Promise>; + searchParams: Promise; }; -function getParamValue( - params: Record, - key: string, -) { - const value = params[key]; - return typeof value === "string" ? value : null; -} - export default async function SignUpPage({ searchParams }: SignUpPageProps) { const authState = await getAuthState(); const resolvedSearchParams = await searchParams; @@ -34,7 +27,7 @@ export default async function SignUpPage({ searchParams }: SignUpPageProps) { redirect(next); } - const notice = getAuthNotice( + const statusToast = getAuthStatusToast( getParamValue(resolvedSearchParams, "error"), getParamValue(resolvedSearchParams, "status"), ); @@ -54,7 +47,7 @@ export default async function SignUpPage({ searchParams }: SignUpPageProps) {

} > - + {!authState.isConfigured ? ( diff --git a/app/wizard-test/page.tsx b/app/wizard-test/page.tsx new file mode 100644 index 0000000..b5a9794 --- /dev/null +++ b/app/wizard-test/page.tsx @@ -0,0 +1,31 @@ +import { redirect } from "next/navigation"; +import { TestWizardFlow } from "@/components/wizard/test-wizard-flow"; +import { sanitizeNextPath } from "@/lib/auth/navigation"; +import { getAuthState } from "@/lib/auth/session"; +import { isTestWizardEnabled } from "@/lib/config/feature-flags"; + +export const dynamic = "force-dynamic"; + +export default async function WizardTestPage() { + const authState = await getAuthState(); + + if (!isTestWizardEnabled()) { + redirect("/dashboard"); + } + + if (!authState.isConfigured) { + redirect("/login?error=auth-not-configured"); + } + + if (!authState.isAuthenticated) { + redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/wizard-test"))}`); + } + + return ( +
+
+ +
+
+ ); +} diff --git a/components/feedback/status-toast-bridge.tsx b/components/feedback/status-toast-bridge.tsx new file mode 100644 index 0000000..4934870 --- /dev/null +++ b/components/feedback/status-toast-bridge.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import type { StatusToast } from "@/lib/feedback/status-messages"; +import { showStatusToast } from "@/lib/feedback/toast"; + +type StatusToastBridgeProps = { + toast: StatusToast | null; + paramKeys?: string[]; +}; + +export function StatusToastBridge({ + toast, + paramKeys = ["status"], +}: StatusToastBridgeProps) { + const hasShownRef = useRef(false); + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + + useEffect(() => { + if (!toast || hasShownRef.current) { + return; + } + + hasShownRef.current = true; + showStatusToast(toast); + + if (!pathname) { + return; + } + + const nextParams = new URLSearchParams(searchParams.toString()); + let changed = false; + + for (const key of paramKeys) { + if (nextParams.has(key)) { + nextParams.delete(key); + changed = true; + } + } + + if (!changed) { + return; + } + + const nextUrl = nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname; + router.replace(nextUrl, { scroll: false }); + }, [paramKeys, pathname, router, searchParams, toast]); + + return null; +} diff --git a/components/onboarding/onboarding-flow.tsx b/components/onboarding/onboarding-flow.tsx index 466d5de..76f6289 100644 --- a/components/onboarding/onboarding-flow.tsx +++ b/components/onboarding/onboarding-flow.tsx @@ -1,49 +1,41 @@ "use client"; -import type { MouseEvent } from "react"; -import { useState } from "react"; +import { useActionState } from "react"; import { completeOnboardingAction, skipOnboardingAction } from "@/app/onboarding/actions"; +import { OnboardingStepIntro } from "@/components/onboarding/onboarding-step-intro"; +import { OnboardingStepPreferences } from "@/components/onboarding/onboarding-step-preferences"; +import { OnboardingStepProfile } from "@/components/onboarding/onboarding-step-profile"; +import { PreferenceHiddenFields } from "@/components/preferences/preference-hidden-fields"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Separator } from "@/components/ui/separator"; -import { Switch } from "@/components/ui/switch"; -import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options"; +import { WizardShell } from "@/components/wizard/wizard-shell"; +import { useOnboardingDraft, type OnboardingDraft } from "@/lib/onboarding/use-onboarding-draft"; import type { ProfileBundle } from "@/lib/profile/types"; +import { useWizardFlow } from "@/lib/wizard/use-wizard-flow"; +import type { WizardStepDefinition } from "@/lib/wizard/types"; type OnboardingFlowProps = { profileBundle: ProfileBundle; }; -const steps = [ +const steps: WizardStepDefinition[] = [ { + id: "intro", eyebrow: "Stap 1", title: "Zo gebruiken we Inspannings Monitor", description: "De app helpt je om je dag rustiger te plannen en terug te kijken zonder medische claims of zorgverlenerfuncties.", }, { + id: "profile", eyebrow: "Stap 2", title: "Basisprofiel", description: "Kies hoe de app je mag aanspreken en welke timezone het best bij je dagindeling past.", + canContinue: (draft) => draft.timezone.length > 0, }, { + id: "preferences", eyebrow: "Stap 3", title: "Startvoorkeuren", description: @@ -51,292 +43,133 @@ const steps = [ }, ] as const; +function renderCurrentStep( + stepId: string, + draft: OnboardingDraft, + updateDraft: (patch: Partial) => void, + disabled: boolean, +) { + switch (stepId) { + case "intro": + return ; + case "profile": + return ( + + ); + case "preferences": + return ( + + ); + default: + return null; + } +} + export function OnboardingFlow({ profileBundle }: OnboardingFlowProps) { - const [currentStep, setCurrentStep] = useState(0); - const [displayName, setDisplayName] = useState(profileBundle.profile.displayName ?? ""); - const [timezone, setTimezone] = useState(profileBundle.profile.timezone); - const [showEnergyPoints, setShowEnergyPoints] = useState( - profileBundle.settings.showEnergyPoints, - ); - const [morningReminderEnabled, setMorningReminderEnabled] = useState( - profileBundle.settings.morningReminderEnabled, - ); - const [morningReminderTime, setMorningReminderTime] = useState( - profileBundle.settings.morningReminderTime ?? "08:30", - ); - const [reflectionReminderEnabled, setReflectionReminderEnabled] = useState( - profileBundle.settings.reflectionReminderEnabled, + const [, completeFormAction, isCompleting] = useActionState(completeOnboardingAction, null); + const [, skipFormAction, isSkipping] = useActionState(skipOnboardingAction, null); + const { draft, updateDraft } = useOnboardingDraft(profileBundle); + const wizard = useWizardFlow({ + steps, + draft, + }); + const isPending = isCompleting || isSkipping; + + const aside = ( + + + Release 1 blijft bewust wellness-first. + + Alleen voor individuele gebruikers, zonder delen of zorgverlenerstoegang. + + + De app geeft geen diagnose, behandeling of medisch advies. + + + Bij acute of snel verslechterende klachten hoort directe hulp via arts, huisartsenpost of 112 buiten deze app. + + + ); - const step = steps[currentStep]; - const isFirstStep = currentStep === 0; - const isLastStep = currentStep === steps.length - 1; + const topAction = ( + <> +

+ Korte onboarding +

+
+ +
+ + ); - function goToPreviousStep() { - setCurrentStep((stepIndex) => Math.max(0, stepIndex - 1)); - } + const backAction = ( + + ); - function goToNextStep(event: MouseEvent) { - event.preventDefault(); - setCurrentStep((stepIndex) => Math.min(steps.length - 1, stepIndex + 1)); - } + const nextAction = wizard.isLastStep ? ( + + ) : ( + + ); return ( -
-
-

- {step.eyebrow} -

-

- {step.title} -

-

- {step.description} -

+ +
+ + - - - Release 1 blijft bewust wellness-first. - - Alleen voor individuele gebruikers, zonder delen of zorgverlenerstoegang. - - - De app geeft geen diagnose, behandeling of medisch advies. - - - Bij acute of snel verslechterende klachten hoort directe hulp via arts, huisartsenpost of 112 buiten deze app. - - - - -
    - {steps.map((item, index) => ( -
  1. - ))} -
-
- -
-
-

- Korte onboarding -

- - - -
- -
- - - - - - - - {currentStep === 0 ? ( -
- - - - Wat je hier wél krijgt - - - - - Een rustige plan-doe-evalueer flow met energiebudgetten, zonder - druk, score-oordeel of medische terminologie. - - - - - - - - Wat deze app niet doet - - - - - Geen diagnose, geen behandeling, geen medische triage en geen - automatisch delen met derden. - - - -
- ) : null} - - {currentStep === 1 ? ( -
-
- - setDisplayName(event.target.value)} - placeholder="Optioneel, bijvoorbeeld Jan" - maxLength={40} - /> -
- - - - Voertaal voor release 1 staat vast op Nederlands. - - - -
- - -
-
- ) : null} - - {currentStep === 2 ? ( -
- - -
- -

- Laat geplande en resterende punten zichtbaar zien in de interface. -

-
- -
-
- - - -
-
- -

- Handig als je later een korte check-in wilt doen zonder extra druk. -

-
- -
- - {morningReminderEnabled ? ( - <> - -
- - setMorningReminderTime(event.target.value)} - /> -
- - ) : null} -
-
- - - -
- -

- Optionele terugblikprompts kunnen later helpen om rustiger patronen te zien. -

-
- -
-
-
- ) : null} - - - -
- - - {isLastStep ? ( - - ) : ( - - )} -
- -
-
+ {renderCurrentStep(wizard.currentStep.id, draft, updateDraft, isPending)} + + ); } diff --git a/components/onboarding/onboarding-step-intro.tsx b/components/onboarding/onboarding-step-intro.tsx new file mode 100644 index 0000000..897de83 --- /dev/null +++ b/components/onboarding/onboarding-step-intro.tsx @@ -0,0 +1,41 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export function OnboardingStepIntro() { + return ( +
+ + + + Wat je hier wél krijgt + + + + + Een rustige plan-doe-evalueer flow met energiebudgetten, zonder druk, + score-oordeel of medische terminologie. + + + + + + + + Wat deze app niet doet + + + + + Geen diagnose, geen behandeling, geen medische triage en geen automatisch + delen met derden. + + + +
+ ); +} diff --git a/components/onboarding/onboarding-step-preferences.tsx b/components/onboarding/onboarding-step-preferences.tsx new file mode 100644 index 0000000..a529f30 --- /dev/null +++ b/components/onboarding/onboarding-step-preferences.tsx @@ -0,0 +1,106 @@ +import { + Card, + CardContent, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import type { OnboardingDraft } from "@/lib/onboarding/use-onboarding-draft"; + +type OnboardingStepPreferencesProps = { + draft: OnboardingDraft; + updateDraft: (patch: Partial) => void; + disabled?: boolean; +}; + +export function OnboardingStepPreferences({ + draft, + updateDraft, + disabled = false, +}: OnboardingStepPreferencesProps) { + return ( +
+ + +
+ +

+ Laat geplande en resterende punten zichtbaar zien in de interface. +

+
+ updateDraft({ showEnergyPoints: checked })} + /> +
+
+ + + +
+
+ +

+ Handig als je later een korte check-in wilt doen zonder extra druk. +

+
+ + updateDraft({ morningReminderEnabled: checked }) + } + /> +
+ + {draft.morningReminderEnabled ? ( + <> + +
+ + + updateDraft({ morningReminderTime: event.target.value }) + } + /> +
+ + ) : null} +
+
+ + + +
+ +

+ Optionele terugblikprompts kunnen later helpen om rustiger patronen te zien. +

+
+ + updateDraft({ reflectionReminderEnabled: checked }) + } + /> +
+
+
+ ); +} diff --git a/components/onboarding/onboarding-step-profile.tsx b/components/onboarding/onboarding-step-profile.tsx new file mode 100644 index 0000000..c40e796 --- /dev/null +++ b/components/onboarding/onboarding-step-profile.tsx @@ -0,0 +1,74 @@ +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options"; +import type { OnboardingDraft } from "@/lib/onboarding/use-onboarding-draft"; + +type OnboardingStepProfileProps = { + draft: OnboardingDraft; + updateDraft: (patch: Partial) => void; + disabled?: boolean; +}; + +export function OnboardingStepProfile({ + draft, + updateDraft, + disabled = false, +}: OnboardingStepProfileProps) { + return ( +
+
+ + updateDraft({ displayName: event.target.value })} + placeholder="Optioneel, bijvoorbeeld Jan" + maxLength={40} + /> +
+ + + + Voertaal voor release 1 staat vast op Nederlands. + + + +
+ + +
+
+ ); +} diff --git a/components/preferences/preference-hidden-fields.tsx b/components/preferences/preference-hidden-fields.tsx new file mode 100644 index 0000000..90aaeaa --- /dev/null +++ b/components/preferences/preference-hidden-fields.tsx @@ -0,0 +1,31 @@ +import type { PreferenceDraft } from "@/lib/preferences/use-preferences-draft"; + +type PreferenceHiddenFieldsProps = { + draft: PreferenceDraft; +}; + +export function PreferenceHiddenFields({ + draft, +}: PreferenceHiddenFieldsProps) { + return ( + <> + + + + + + + ); +} diff --git a/components/settings/settings-form.tsx b/components/settings/settings-form.tsx index d035f73..ac099b9 100644 --- a/components/settings/settings-form.tsx +++ b/components/settings/settings-form.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState } from "react"; +import { useActionState, useState } from "react"; import { saveSettingsAction } from "@/app/settings/actions"; +import { PreferenceHiddenFields } from "@/components/preferences/preference-hidden-fields"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { @@ -23,6 +24,7 @@ import { import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options"; +import { usePreferenceDraft } from "@/lib/preferences/use-preferences-draft"; import type { ProfileBundle } from "@/lib/profile/types"; type SettingsFormProps = { @@ -37,41 +39,14 @@ const LOCALE_OPTIONS = [ ] as const; export function SettingsForm({ profileBundle }: SettingsFormProps) { + const [, formAction, isPending] = useActionState(saveSettingsAction, null); const [locale, setLocale] = useState(profileBundle.profile.locale); - const [timezone, setTimezone] = useState(profileBundle.profile.timezone); - const [showEnergyPoints, setShowEnergyPoints] = useState( - profileBundle.settings.showEnergyPoints, - ); - const [morningReminderEnabled, setMorningReminderEnabled] = useState( - profileBundle.settings.morningReminderEnabled, - ); - const [morningReminderTime, setMorningReminderTime] = useState( - profileBundle.settings.morningReminderTime ?? "08:30", - ); - const [reflectionReminderEnabled, setReflectionReminderEnabled] = useState( - profileBundle.settings.reflectionReminderEnabled, - ); + const { draft, updateDraft } = usePreferenceDraft(profileBundle); return ( -
+ - - - - - + @@ -107,6 +82,7 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
- setTimezone(value ?? profileBundle.profile.timezone) + updateDraft({ + timezone: value ?? profileBundle.profile.timezone, + }) } > @@ -165,8 +144,11 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
+ updateDraft({ showEnergyPoints }) + } />
@@ -193,14 +175,17 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) { Zet een lichte reminder aan voor een rustige start van je check-in.

- + + updateDraft({ morningReminderEnabled }) + } + />
- {morningReminderEnabled ? ( + {draft.morningReminderEnabled ? ( <>
@@ -210,9 +195,12 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) { setMorningReminderTime(event.target.value)} + value={draft.morningReminderTime} + onChange={(event) => + updateDraft({ morningReminderTime: event.target.value }) + } />
@@ -232,8 +220,11 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) { + updateDraft({ reflectionReminderEnabled }) + } /> @@ -258,11 +249,13 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {

- Wijzigingen zijn direct van toepassing op jouw account en volgende sessies. + {isPending + ? "Instellingen worden opgeslagen..." + : "Wijzigingen zijn direct van toepassing op jouw account en volgende sessies."}

-
diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx new file mode 100644 index 0000000..0f01208 --- /dev/null +++ b/components/ui/sonner.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { + CircleCheckIcon, + InfoIcon, + Loader2Icon, + OctagonXIcon, + TriangleAlertIcon, +} from "lucide-react"; +import { Toaster as Sonner, type ToasterProps } from "sonner"; + +const Toaster = ({ ...props }: ToasterProps) => { + return ( + , + info: , + warning: , + error: , + loading: , + }} + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + } as React.CSSProperties + } + toastOptions={{ + classNames: { + toast: "cn-toast", + }, + }} + {...props} + /> + ); +}; + +export { Toaster }; diff --git a/components/wizard/test-wizard-flow.tsx b/components/wizard/test-wizard-flow.tsx new file mode 100644 index 0000000..5a5ad39 --- /dev/null +++ b/components/wizard/test-wizard-flow.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { WizardShell } from "@/components/wizard/wizard-shell"; +import { useWizardFlow } from "@/lib/wizard/use-wizard-flow"; +import type { WizardStepDefinition } from "@/lib/wizard/types"; + +type TestWizardDraft = Record; + +const steps: WizardStepDefinition[] = [ + { + id: "step-1", + eyebrow: "Stap 1", + title: "Stap 1", + description: "Eerste simpele teststap om de wizard-shell en navigatie te valideren.", + }, + { + id: "step-2", + eyebrow: "Stap 2", + title: "Stap 2", + description: "Tweede stap voor controle van voortgang, vorige/volgende en layoutconsistentie.", + }, + { + id: "step-3", + eyebrow: "Stap 3", + title: "Stap 3", + description: "Derde stap om te bevestigen dat multi-step flows generiek inzetbaar blijven.", + }, + { + id: "step-4", + eyebrow: "Stap 4", + title: "Stap 4", + description: "Vierde stap als tussenpunt vlak voor afronding van de testflow.", + }, + { + id: "step-5", + eyebrow: "Stap 5", + title: "Stap 5", + description: "Laatste stap voor afronding en redirect terug naar het dashboard.", + }, +]; + +const testStepDescriptions: Record = { + "step-1": "Deze stap bewijst dat de flow op de eerste positie correct opstart.", + "step-2": "Deze stap bewijst dat vooruit navigeren geen state of layout breekt.", + "step-3": "Deze stap is het midden van de flow en is handig voor regressietests.", + "step-4": "Deze stap bevestigt dat de shell netjes blijft werken richting het einde.", + "step-5": "Deze stap bevestigt dat afronden als aparte actie werkt op de laatste stap.", +}; + +export function TestWizardFlow() { + const router = useRouter(); + const wizard = useWizardFlow({ + steps, + draft: {}, + }); + + function finishWizard() { + router.push("/dashboard?status=test-wizard-completed"); + } + + const aside = ( + + + Interne testwizard + + Alleen bedoeld om de generieke wizard-core te controleren voor toekomstige flows. + + + + ); + + const topAction = ( + <> +

+ Test wizard +

+ + + ); + + const backAction = ( + + ); + + const nextAction = wizard.isLastStep ? ( + + ) : ( + + ); + + return ( + + + + + {wizard.currentStep.title} + + + + + {testStepDescriptions[wizard.currentStep.id]} + + + + + ); +} diff --git a/components/wizard/wizard-progress.tsx b/components/wizard/wizard-progress.tsx new file mode 100644 index 0000000..6536376 --- /dev/null +++ b/components/wizard/wizard-progress.tsx @@ -0,0 +1,22 @@ +import { cn } from "@/lib/utils"; + +type WizardProgressProps = { + current: number; + total: number; +}; + +export function WizardProgress({ current, total }: WizardProgressProps) { + return ( +
    + {Array.from({ length: total }, (_, index) => ( +
  1. + ))} +
+ ); +} diff --git a/components/wizard/wizard-shell.tsx b/components/wizard/wizard-shell.tsx new file mode 100644 index 0000000..18ac220 --- /dev/null +++ b/components/wizard/wizard-shell.tsx @@ -0,0 +1,63 @@ +import type { ReactNode } from "react"; +import { WizardProgress } from "@/components/wizard/wizard-progress"; + +type WizardShellProps = { + eyebrow?: string; + title: string; + description?: string; + progressCurrent: number; + progressTotal: number; + topAction?: ReactNode; + aside?: ReactNode; + children: ReactNode; + backAction?: ReactNode; + nextAction?: ReactNode; +}; + +export function WizardShell({ + eyebrow, + title, + description, + progressCurrent, + progressTotal, + topAction, + aside, + children, + backAction, + nextAction, +}: WizardShellProps) { + return ( +
+
+ {eyebrow ? ( +

+ {eyebrow} +

+ ) : null} +

+ {title} +

+ {description ? ( +

+ {description} +

+ ) : null} + {aside ?
{aside}
: null} + +
+ +
+ {topAction ? ( +
{topAction}
+ ) : null} +
{children}
+ {backAction || nextAction ? ( +
+
{backAction}
+
{nextAction}
+
+ ) : null} +
+
+ ); +} diff --git a/docs/README.md b/docs/README.md index 04cd15b..4121f1a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,6 +44,12 @@ Deze map bevat de vernieuwde documentatie voor de gekozen `wellness/self-managem - [inspannings-monitor-cicd-en-deploy.md](./inspannings-monitor-cicd-en-deploy.md) Beschrijft de gekozen CI/CD-opzet met GitHub Actions voor verificatie en Vercel voor automatische preview- en production-deploys. +- [inspannings-monitor-dagelijkse-deploy-checklist.md](./inspannings-monitor-dagelijkse-deploy-checklist.md) + Korte operationele checklist voor de normale flow van feature branch naar productie. +- [inspannings-monitor-ops-security-notitie.md](./inspannings-monitor-ops-security-notitie.md) + Legt de actuele operationele en security-keuzes vast rond repositorybescherming, Vercel-deploys en secretbeheer. +- [gpt-instructies.md](./gpt-instructies.md) + Bundelt de inhoudelijke instructies en expliciete keuzes die in deze context zijn gegeven als compacte bron voor vervolgwerk. ## Backlog en Linear diff --git a/docs/generate_inspannings_monitor_docs.py b/docs/generate_inspannings_monitor_docs.py index 81fae0d..c4a3741 100644 --- a/docs/generate_inspannings_monitor_docs.py +++ b/docs/generate_inspannings_monitor_docs.py @@ -1193,6 +1193,398 @@ def build_implementatieplan_backlog() -> None: doc.save(BASE_DIR / "inspannings-monitor-06-implementatieplan-en-backlog-v01.docx") +def build_testplan() -> None: + doc = init_doc( + f"{PRODUCT_NAME} Testplan v0.1", + f"Teststrategie, tooling en acceptatiecriteria voor de wellness-first MVP\n{DATE_TEXT}", + ) + + p(doc, "1. Documentdoel", "Heading 1") + p( + doc, + f"Dit document beschrijft hoe {PRODUCT_NAME} getest wordt: welke lagen worden afgedekt, welke frameworks worden ingezet, " + "hoe tests georganiseerd zijn en wat de Definition of Done is per testlaag. " + "Het is bedoeld als praktische leidraad voor engineers die nieuwe features bouwen en als reviewdocument voor kwaliteitsborging vóór launch. " + "De strategie gaat uit van de huidige technische keuzes: Next.js App Router, Supabase PostgreSQL met RLS, TypeScript en Vercel.", + ) + + p(doc, "2. Testpiramide en scope", "Heading 1") + p( + doc, + "De teststrategie volgt een klassieke piramide met vier lagen. Elke laag heeft een eigen doel, tooling en uitvoerfrequentie. " + "De nadruk ligt op de onderste twee lagen, omdat de domeinlogica van dit product (budgetberekening, insightregels, RLS-afdwinging) " + "het meest waardevol is om snel en automatisch te verifiëren.", + ) + table( + doc, + ["Laag", "Wat wordt getest", "Framework", "Wanneer"], + [ + ["Unit", "Pure functies, berekeningslogica, Zod-schema's, hulpfuncties", "Vitest", "Bij elke commit"], + ["Integratie", "Servicelaag, server actions, Supabase-queries, RLS via pgTAP", "Vitest + pgTAP", "Bij elke commit"], + ["End-to-end", "Volledige gebruikersflows in echte browser", "Playwright", "Bij PR naar main"], + ["Handmatig / QA", "Toegankelijkheid, lage interactielast, foutmeldingen, regressie", "Checklist", "Vóór elke release"], + ], + ) + + p(doc, "3. Tooling en frameworks", "Heading 1") + p(doc, "3.1 Vitest — unit en integratietests", "Heading 2") + p( + doc, + "Vitest is de aanbevolen testruner voor Next.js-projecten in 2026. Het start sneller dan Jest, heeft native ESM-ondersteuning " + "en werkt goed samen met TypeScript zonder extra transpilatiestap. " + "Vitest ondersteunt momenteel geen asynchrone Server Components (React 19); daarvoor wordt Playwright ingezet. " + "Synchrone server-side logica in lib/ en app/**/actions.ts kan wel met Vitest worden getest.", + ) + table( + doc, + ["Pakket", "Doel"], + [ + ["vitest", "Testruner en assertion library"], + ["@vitejs/plugin-react", "React JSX-ondersteuning in Vitest"], + ["jsdom", "Browser-omgeving voor component-snapshot tests"], + ["@testing-library/react", "Renderen en interacteren met React-componenten"], + ["@testing-library/dom", "DOM-queries die dicht bij gebruikersgedrag liggen"], + ["vite-tsconfig-paths", "Ondersteuning voor @ padalias uit tsconfig.json"], + ], + ) + p(doc, "Installatie:", "Normal") + p(doc, "npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom vite-tsconfig-paths", "Normal") + p(doc, "Testcommando's:", "Normal") + p(doc, "npx vitest — alle unit- en integratietests uitvoeren", "Normal") + p(doc, "npx vitest run --reporter=verbose — eenmalig uitvoeren met gedetailleerde output", "Normal") + p(doc, "npx vitest run src/lib/checkin/budget.test.ts — één testbestand uitvoeren", "Normal") + + p(doc, "3.2 Playwright — end-to-end tests", "Heading 2") + p( + doc, + "Playwright test de volledige applicatie in een echte browser. Het is de enige manier om asynchrone Server Components, " + "Next.js-redirects en Supabase-authenticatie samen als één systeem te verifiëren. " + "Voor Supabase-authenticatie wordt programmatische login via de REST API aanbevolen in plaats van UI-gebaseerde login per test, " + "zodat tests sneller zijn en minder foutgevoelig.", + ) + table( + doc, + ["Pakket", "Doel"], + [ + ["@playwright/test", "Testruner, assertions en browser-automatisering"], + ["playwright", "Browseromgevingen (Chromium, Firefox, WebKit)"], + ], + ) + p(doc, "Installatie:", "Normal") + p(doc, "npm install -D @playwright/test && npx playwright install", "Normal") + p(doc, "Testcommando's:", "Normal") + p(doc, "npx playwright test — alle E2E-tests uitvoeren", "Normal") + p(doc, "npx playwright test auth/ — één map uitvoeren", "Normal") + p(doc, "npx playwright test --ui — interactieve testrunner met tijdlijn", "Normal") + p(doc, "npx playwright test --workers=1 — bij Supabase connection-limiet in CI", "Normal") + + p(doc, "3.3 Zod — runtime-validatie en testschema's", "Heading 2") + p( + doc, + "Zod wordt ingezet als de enige bron van waarheid voor invoervalidatie. " + "Zod-schema's worden gedefinieerd in lib/*/schemas.ts en hergebruikt in server actions (server-side validatie) " + "en client-componenten (real-time feedback). " + "Dit elimineert dubbele validatielogica en maakt het makkelijker om testdata te genereren.", + ) + table( + doc, + ["Pakket", "Doel"], + [ + ["zod", "Runtime-validatie met TypeScript-type-inferentie"], + ["zod-fixture", "Automatisch genereren van testfixtures vanuit een Zod-schema"], + ], + ) + p(doc, "Installatie:", "Normal") + p(doc, "npm install zod && npm install -D zod-fixture", "Normal") + p(doc, "Voorbeeldschema (lib/checkin/schemas.ts):", "Normal") + p(doc, 'import { z } from "zod"', "Normal") + p(doc, "export const MorningCheckInSchema = z.object({", "Normal") + p(doc, " energyScore: z.number().int().min(1).max(10),", "Normal") + p(doc, ' sleepQuality: z.enum(["good", "fair", "poor"]),', "Normal") + p(doc, " note: z.string().max(500).optional(),", "Normal") + p(doc, "})", "Normal") + p(doc, "export type MorningCheckIn = z.infer", "Normal") + + p(doc, "3.4 pgTAP — database- en RLS-tests", "Heading 2") + p( + doc, + "pgTAP is een unit-testframework voor PostgreSQL dat direct in de database draait. " + "Het is de meest betrouwbare manier om RLS-beleid te testen, omdat het dezelfde uitvoeringslaag gebruikt als de echte applicatie. " + "Tests worden uitgevoerd met de Supabase lokale ontwikkelomgeving of een dedicated testdatabase.", + ) + p(doc, "Supabase biedt ingebouwde ondersteuning voor pgTAP via supabase test db.", "Normal") + p(doc, "Testcommando:", "Normal") + p(doc, "supabase test db", "Normal") + p(doc, "Testbestanden staan in supabase/tests/*.sql en volgen de naamgeving test__rls.sql.", "Normal") + + p(doc, "4. Layer 1: Unit tests", "Heading 1") + p( + doc, + "Unit tests dekken pure functies en logica die geen externe afhankelijkheden hebben. " + "Dit zijn de snelste en meest stabiele tests. Ze worden co-located met de bronbestanden in __tests__-mappen of als .test.ts-bestanden.", + ) + + p(doc, "4.1 Budgetberekening (ST-203 — hoogste prioriteit)", "Heading 2") + p( + doc, + "De mapping van energiescore naar energieniveau en dagbudget is de kern van het product. " + "Dit is de eerste plek waar tests verplicht zijn. De functie moet puur zijn: geen neveneffecten, geen database-oproepen.", + ) + table( + doc, + ["Testgeval", "Input", "Verwacht resultaat"], + [ + ["Minimale score", "energyScore = 1", "energyLevel = 'very_low', dailyBudget = minimumwaarde"], + ["Maximale score", "energyScore = 10", "energyLevel = 'high', dailyBudget = maximumwaarde"], + ["Grenswaarden", "elke overgangswaarde in de schaal", "Correct niveau en bijbehorend budget"], + ["Consistentie", "zelfde score twee keer", "Altijd gelijk resultaat (deterministisch)"], + ["Ongeldige invoer", "energyScore = 0 of 11", "Zod gooit een ZodError"], + ], + ) + + p(doc, "4.2 Zod-schema's", "Heading 2") + p( + doc, + "Elk domeinobject krijgt een Zod-schema. De schema's worden getest door geldige en ongeldige invoer te parseren " + "en het resultaat te verifiëren. Gebruik zod-fixture om realistische testfixtures te genereren.", + ) + table( + doc, + ["Schema", "Locatie", "Te testen gevallen"], + [ + ["MorningCheckInSchema", "lib/checkin/schemas.ts", "Geldige check-in, score buiten bereik, ontbrekend verplicht veld, te lange notitie"], + ["OnboardingSubmissionSchema", "lib/onboarding/schemas.ts", "Geldige onboarding, ongeldige tijdzone, ongeldige schermnaam"], + ["SettingsSubmissionSchema", "lib/profile/schemas.ts", "Geldige settings, ongeldige herinneringstijd, onbekende locale"], + ["PlannedActivitySchema", "lib/planning/schemas.ts", "Geldige activiteit, negatieve energiepunten, te lange naam"], + ], + ) + + p(doc, "4.3 Hulpfuncties en navigatie-utilities", "Heading 2") + table( + doc, + ["Functie", "Bestand", "Te testen gevallen"], + [ + ["sanitizeNextPath()", "lib/auth/navigation.ts", "Geldig pad, pad zonder leading slash, dubbele slash (open redirect), leeg pad"], + ["buildPathWithQuery()", "lib/auth/navigation.ts", "Pad zonder params, één param, meerdere params, speciale tekens in waarde"], + ["getAuthNotice()", "lib/auth/messages.ts", "Bekende foutcode, onbekende code, ontbrekende code, bekende statuscode"], + ["cn()", "lib/utils.ts", "Lege invoer, conflicterende Tailwind-klassen, conditionals"], + ], + ) + + p(doc, "5. Layer 2: Integratietests", "Heading 1") + p( + doc, + "Integratietests verifiëren dat de servicelaag correct samenwerkt met Supabase. " + "Server actions worden niet direct getest — de businesslogica zit in de servicelaag (lib/*/service.ts) " + "en wordt daar getest. Server actions worden afgedekt door E2E-tests.", + ) + + p(doc, "5.1 Servicelaag (lib/profile/service.ts en toekomstige services)", "Heading 2") + p( + doc, + "Gebruik een geïsoleerde testdatabase (Supabase lokaal of een aparte testproject-URL). " + "Elke test maakt eigen data aan en ruimt die na afloop op. Gebruik vi.mock() niet voor de database — " + "echte Supabase-queries geven meer vertrouwen en voorkomen dat mock-gedrag verschilt van productiegedrag.", + ) + table( + doc, + ["Test", "Doel"], + [ + ["getProfileBundleForCurrentUser()", "Retourneert gecombineerd profiel en settings voor bestaande gebruiker"], + ["ensureProfileBundleForCurrentUser()", "Maakt records aan als ze niet bestaan (bootstrap)"], + ["completeOnboardingForCurrentUser()", "Slaat onboarding op en zet onboarding_seen op true"], + ["saveSettingsForCurrentUser()", "Wijzigingen worden persistent opgeslagen"], + ["getProfileBundleForCurrentUser() — niet ingelogd", "Gooit een fout of retourneert null"], + ], + ) + + p(doc, "5.2 Server actions — mocking aanpak", "Heading 2") + p( + doc, + "Server actions zijn dunne wrappers rond de servicelaag. Ze worden getest via vi.mock() voor next/navigation " + "om redirect-gedrag te verifiëren, en via E2E-tests voor de volledige flow. " + "De businesslogica (validatie, berekening) wordt in unit- en integratietests afgedekt.", + ) + p(doc, "Aanbevolen patroon voor server action tests:", "Normal") + p(doc, "vi.mock('next/navigation', () => ({ redirect: vi.fn() }))", "Normal") + p(doc, "vi.mock('@/lib/profile/service') // mock de servicelaag", "Normal") + p(doc, "// Test de actie en verifieer dat redirect en service correct worden aangeroepen", "Normal") + + p(doc, "6. Layer 3: RLS en security tests (pgTAP)", "Heading 1") + p( + doc, + "RLS-tests worden uitgevoerd direct in PostgreSQL via pgTAP. " + "Elke tabel krijgt een eigen testbestand. De tests verifiëren dat gebruikers uitsluitend hun eigen records kunnen lezen, " + "schrijven en verwijderen. Tests worden uitgevoerd als een niet-geprivilegieerde databaserol, " + "niet als de SQL Editor-rol (die RLS omzeilt).", + ) + table( + doc, + ["Testgeval", "Te verifiëren"], + [ + ["SELECT op eigen rij", "Gebruiker A kan zijn eigen profiel opvragen"], + ["SELECT op andermans rij", "Gebruiker A kan het profiel van gebruiker B niet zien (0 rijen)"], + ["INSERT voor zichzelf", "Gebruiker A mag een check-in aanmaken voor eigen profiel"], + ["INSERT voor een ander", "Gebruiker A kan geen check-in aanmaken voor profiel van B (RLS-fout)"], + ["UPDATE op eigen rij", "Gebruiker A mag eigen settings aanpassen"], + ["UPDATE op andermans rij", "Gebruiker A kan settings van B niet aanpassen (0 updated rows)"], + ["DELETE op eigen rij", "Verwijderen van eigen record lukt"], + ["DELETE op andermans rij", "Verwijderen van andermans record lukt niet"], + ["Unauthenticated access", "Queries zonder geldig JWT retourneren 0 rijen of een fout"], + ], + ) + p(doc, "Testbestandsstructuur:", "Normal") + p(doc, "supabase/tests/test_profiles_rls.sql", "Normal") + p(doc, "supabase/tests/test_user_settings_rls.sql", "Normal") + p(doc, "supabase/tests/test_morning_check_ins_rls.sql", "Normal") + p(doc, "supabase/tests/test_activities_rls.sql", "Normal") + + p(doc, "7. Layer 4: End-to-end tests (Playwright)", "Heading 1") + p( + doc, + "E2E-tests verifiëren de volledige gebruikersflows in een echte browser. " + "Elke testrun gebruikt een authentiek Supabase-testaccount. " + "Authenticatie gebeurt programmatisch via de Supabase REST API om tijd te besparen en flakiness te beperken: " + "het auth-token wordt eenmalig opgehaald in een setup-stap en hergebruikt als cookie-state voor alle tests.", + ) + + p(doc, "7.1 Authenticatiepatroon", "Heading 2") + p( + doc, + "Maak een global setup-bestand (playwright/global-setup.ts) dat één keer inlogt via de Supabase Auth REST API " + "en de sessiestatus opslaat in playwright/.auth/user.json. " + "Testbestanden importeren deze opgeslagen staat en starten al ingelogd.", + ) + p(doc, "Voordeel: authenticatie hoeft maar één keer per testsuite te draaien, niet per test.", "Normal") + p(doc, "In CI: gebruik --workers=1 als de Supabase connection pool dat vereist.", "Normal") + p(doc, "Gebruik data-testid-attributen op interactieve elementen voor stabiele selectors.", "Normal") + + p(doc, "7.2 Te testen gebruikersflows", "Heading 2") + table( + doc, + ["Flow", "Stappen", "Kritieke assertions"], + [ + ["Registratie en e-mailbevestiging", "Aanmelden, e-mail bevestigen, onboarding afronden", "Dashboard is bereikbaar na bevestiging"], + ["Inloggen", "Inlogformulier invullen, submit", "Dashboard zichtbaar, naam of profiel aanwezig"], + ["Onboarding", "Drie stappen doorlopen, tijdzone en herinneringen instellen", "Dashboard toont welkomstbericht, onboarding niet opnieuw zichtbaar"], + ["Instellingen wijzigen", "Naar instellingen navigeren, tijdzone aanpassen, opslaan", "Succesbericht zichtbaar, nieuwe instelling persistent"], + ["Ochtendcheck-in", "Energiescore invoeren, slaapkwaliteit kiezen, opslaan", "Dashboard toont budget en energieniveau"], + ["Activiteit plannen", "Activiteit aanmaken met naam, categorie en energiepunten", "Energiemeter update direct, activiteit staat in dagoverzicht"], + ["Activiteit als uitgevoerd markeren", "Activiteit afsluiten met werkelijke duur en vermoeidheidsscore", "Status wijzigt naar uitgevoerd in dagoverzicht"], + ["Activiteit overslaan", "Skip kiezen met reden", "Status wijzigt naar geskipt, reden opgeslagen"], + ["Uitloggen", "Uitlogknop", "Redirect naar login, dashboard niet toegankelijk zonder sessie"], + ["Beveiligde route zonder sessie", "Dashboard-URL bezoeken zonder login", "Redirect naar login"], + ], + ) + + p(doc, "8. Testdata-management", "Heading 1") + p( + doc, + "Goede testdata-management voorkomt dat tests elkaar beïnvloeden en maakt tests herhaalbaar. " + "De volgende principes gelden:", + ) + bullets( + doc, + [ + "Elke E2E-test maakt zijn eigen testgebruiker aan of hergebruikt een dedicated testaccount.", + "Unit- en integratietests zijn stateless: ze maken geen gebruik van gedeelde databaserecords.", + "Gebruik zod-fixture om valide testfixtures te genereren vanuit Zod-schema's (voorkomt handmatig bijhouden van testobjecten).", + "Na integratietests worden aangemaakte records verwijderd (cleanup in afterEach of afterAll).", + "Productiedata mag nooit worden gebruikt in tests. Gebruik een aparte Supabase-testomgeving.", + "Seed-scripts voor statische referentiedata (activity_categories, skip_reasons) staan in supabase/seed.sql.", + ], + ) + + p(doc, "9. CI/CD-integratie", "Heading 1") + p( + doc, + "Tests worden automatisch uitgevoerd in GitHub Actions. " + "De CI-pipeline is opgesplitst in twee jobs zodat de snelle unit- en integratietests niet worden vertraagd door E2E-tests.", + ) + table( + doc, + ["Job", "Trigger", "Stappen", "Blokkeerend voor merge"], + [ + ["Lint en build", "PR en push naar main", "npm ci, npm run lint, npm run build", "Ja"], + ["Unit en integratie", "PR en push naar main", "npm ci, npx vitest run, supabase test db", "Ja"], + ["E2E", "PR naar main", "npm ci, npx playwright install, npx playwright test --workers=1", "Ja"], + ], + ) + p( + doc, + "De omgevingsvariabelen NEXT_PUBLIC_SUPABASE_URL en NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY " + "worden als GitHub Actions secrets meegegeven aan de testjobs. " + "Gebruik een apart Supabase-testproject zodat testdata de productiedatabase niet verontreinigt.", + ) + + p(doc, "10. Bestandsstructuur", "Heading 1") + table( + doc, + ["Pad", "Inhoud"], + [ + ["lib/checkin/__tests__/budget.test.ts", "Unit tests voor budgetberekening"], + ["lib/checkin/schemas.ts", "Zod-schema's voor check-in"], + ["lib/auth/__tests__/navigation.test.ts", "Unit tests voor sanitizeNextPath en buildPathWithQuery"], + ["lib/auth/__tests__/messages.test.ts", "Unit tests voor getAuthNotice"], + ["lib/profile/__tests__/service.test.ts", "Integratietests voor profileservice"], + ["supabase/tests/test_profiles_rls.sql", "pgTAP RLS-tests voor de profiles-tabel"], + ["supabase/tests/test_user_settings_rls.sql", "pgTAP RLS-tests voor user_settings"], + ["e2e/auth.spec.ts", "Playwright-tests voor registratie, login en uitloggen"], + ["e2e/onboarding.spec.ts", "Playwright-tests voor onboardingflow"], + ["e2e/checkin.spec.ts", "Playwright-tests voor ochtendcheck-in"], + ["e2e/planning.spec.ts", "Playwright-tests voor activiteiten plannen en evalueren"], + ["playwright/global-setup.ts", "Programmatische Supabase-login, sessiestate opslaan"], + ["playwright.config.ts", "Playwright-configuratie inclusief auth-setup en workers"], + ], + ) + + p(doc, "11. Acceptatiecriteria per laag", "Heading 1") + table( + doc, + ["Laag", "Minimale eis voor launch"], + [ + ["Unit", "Budgetberekening volledig gedekt inclusief grenswaarden. Alle Zod-schema's getest op geldige en ongeldige invoer. Navigatie-utilities getest op open-redirect-preventie."], + ["Integratie", "Profileservice getest op happy path en bootstrappatroon. Server actions getest op redirect-gedrag bij succes en fout."], + ["RLS (pgTAP)", "Alle tabellen met gebruikersdata hebben tests voor SELECT, INSERT, UPDATE en DELETE als owner en als andere gebruiker. Unauthenticated access getest."], + ["E2E", "Login, onboarding, check-in en instellingen zijn geautomatiseerd getest. Beveiligde route zonder sessie redirect naar login."], + ["Handmatig", "Kernflows geverifieerd op mobiel. Toegankelijkheidscheck op touch targets en contrast. Copy getoetst op niet-medische formulering."], + ], + ) + + p(doc, "12. Bewuste keuzes en afwegingen", "Heading 1") + table( + doc, + ["Keuze", "Alternatief", "Reden voor keuze"], + [ + ["Vitest boven Jest", "Jest", "Sneller, native ESM, minder configuratie voor Next.js-projecten in 2026."], + ["Echte Supabase in integratietests", "Gemockte Supabase-client", "Mocks verbergen RLS- en querygedrag. Echte database geeft meer vertrouwen. Precedent: productie-incident door mock/prod-divergentie."], + ["pgTAP voor RLS", "Applicatielaag-tests voor RLS", "RLS draait in de database; alleen pgTAP test op het exacte executieniveau."], + ["Programmatische Playwright-login", "UI-login per test", "Sneller, minder foutgevoelig, vermijdt het testen van hetzelfde auth-pad bij elke test."], + ["Zod voor validatie", "Handmatige validatiefuncties", "Eén bron van waarheid voor types en validatie. zod-fixture genereert automatisch testdata."], + ["Geen snapshot tests", "React Testing Library snapshots", "Snapshots zijn fragiel bij kleine UI-wijzigingen en geven weinig semantisch vertrouwen."], + ], + ) + + p(doc, "13. Externe referenties", "Heading 1") + references = [ + ("Next.js Testing Guide — Vitest", "https://nextjs.org/docs/app/guides/testing/vitest"), + ("Next.js Testing Guide — Playwright", "https://nextjs.org/docs/app/guides/testing/playwright"), + ("Supabase Testing Overview", "https://supabase.com/docs/guides/local-development/testing/overview"), + ("pgTAP documentatie", "https://pgtap.org/"), + ("Zod documentatie", "https://zod.dev/"), + ("zod-fixture — testdata genereren vanuit Zod-schema's", "https://github.com/timdeschryver/zod-fixture"), + ("Playwright — Supabase auth via REST API", "https://mokkapps.de/blog/login-at-supabase-via-rest-api-in-playwright-e2e-test"), + ("Playwright — opslaan en hergebruiken van auth-state", "https://playwright.dev/docs/auth"), + ] + for name, url in references: + para = doc.add_paragraph(style="List Bullet") + para.add_run(f"{name}: ") + add_hyperlink(para, url, url) + + set_footer(doc, f"{PRODUCT_NAME} Testplan v0.1") + doc.save(BASE_DIR / "inspannings-monitor-07-testplan-v01.docx") + + def main() -> None: BASE_DIR.mkdir(parents=True, exist_ok=True) build_productkader() @@ -1201,6 +1593,7 @@ def main() -> None: build_roadmap() build_technische_architectuur() build_implementatieplan_backlog() + build_testplan() if __name__ == "__main__": diff --git a/docs/gpt-instructies.md b/docs/gpt-instructies.md new file mode 100644 index 0000000..68612c7 --- /dev/null +++ b/docs/gpt-instructies.md @@ -0,0 +1,111 @@ +# GPT Instructies voor Inspannings Monitor + +Datum: `2026-04-18` + +Dit document bundelt de inhoudelijke instructies, keuzes en werkafspraken die in +deze context door de gebruiker zijn gegeven. Het is bedoeld als compacte +contextbron voor vervolgwerk naast de formele specificaties en plannen. + +## 1. Productrichting en positionering + +- Kies bewust de route `wellness/self-management` voor de eerste release. +- Houd expliciet de mogelijkheid open om later een apart `medisch product`-spoor te starten. +- Volg de eerder aanbevolen guardrails voor intended use en non-intended use. +- Houd de MVP weg van medische claims, zorgverlenerrollen en deelscenario's. + +## 2. Naam, doelgroep en taal + +- Productnaam: `Inspannings Monitor` +- Doelgroep: `volwassenen` +- Voertaal eerste release: `Nederlands` + +## 3. Scope voor release 1 + +- Alleen individuele gebruikers +- Geen delen met zorgverleners +- Geen delen met naasten +- Geen AI in de kern-MVP +- Geen medische workflows in de MVP + +## 4. Technische keuzes + +- Hosting: `Vercel` +- Database: `Supabase PostgreSQL` +- Authenticatie: `Supabase Auth` +- UI foundation: `Tailwind CSS + shadcn/ui` + +## 5. Documentatie-instructies + +- Maak nieuwe documentatie voor de gekozen wellness-route. +- Neem de technische implementatielaag uit `v04` mee als aparte laag, niet vermengd met productscope. +- Bouw de documentatieset op als losse, duidelijke artefacten in plaats van één gemengd document. +- Houd documentatie beschikbaar in `.docx`, met ondersteunende Markdown-bestanden in de repository. + +## 6. Backlog en projectsturing + +- Gebruik `Linear` als backlogtool. +- Werk de documentatie door naar backlog- en importbestanden voor Linear. +- Gebruik de storystructuur (`ST-001`, `ST-101`, `ST-102`, enzovoort) als uitvoeringslijn. + +## 7. Implementatiekeuzes die expliciet zijn gevraagd + +- Bouw door vanaf `ST-001` met echte code, niet alleen plannen. +- Voeg `Supabase Auth` toe met e-mail/wachtwoord en verplichte verificatie. +- Bouw daarna profiel- en settingsfundering, onboarding en settingsbeheer. +- Verbeter de UI structureel door `shadcn/ui` te gebruiken in plaats van losse knop- en form-styling. + +## 8. Repository- en deploykeuzes + +- Publiceer het project op GitHub. +- Gebruik repositorynaam `inspannings-monitor`. +- Maak de repository `public`. +- Gebruik voor productie niet de root `jp-visser.nl`, omdat daar al de hoofdsite met cv en projectlinks staat. +- Gebruik als productiedomein: `inspannings-monitor.jp-visser.nl` + +## 9. CI/CD-afspraken + +- Gebruik `GitHub Actions` voor CI. +- Gebruik `Vercel` voor automatische preview- en production-deployments. +- Gebruik `main` als production branch. +- Bescherm `main` met: + - pull requests verplicht + - verplichte check `Lint and build` + - force pushes geblokkeerd + - branch deletion geblokkeerd + +## 10. Security-afspraken + +- Gebruik geen `service_role` key in de frontend-app. +- Gebruik geen admin-key in Vercel voor deze frontend. +- Behandel de eerder gebruikte Supabase `service_role` key als gecompromitteerd. +- Houd lokale env-bestanden buiten git. + +## 11. Werkvoorkeuren uit deze context + +- Ga praktisch door met de volgende stap als de richting duidelijk is. +- Maak documentatie en implementatie samen voortschrijdend concreet. +- Leg belangrijke keuzes expliciet vast wanneer ze eenmaal zijn besloten. +- Geef voor gebruikersfeedback na redirects of server actions de voorkeur aan een + centrale toastlaag boven losse inline statusnotices, tenzij een scherm expliciet + een andere vorm vraagt. + +## 12. Korte besluitlog uit deze thread + +1. Twee oorspronkelijke documenten zijn beoordeeld en omgezet naar een nieuwe documentatieset. +2. De wellness-route is expliciet gekozen met opengehouden future-medical track. +3. Productnaam is vastgezet op `Inspannings Monitor`. +4. Release 1 is vastgezet op individuele volwassen gebruikers in het Nederlands. +5. De stack is vastgezet op `Vercel + Supabase Auth + Supabase PostgreSQL`. +6. De technische implementatielaag uit `v04` is teruggebracht als apart document. +7. De backlog is uitgewerkt en voorbereid voor `Linear`. +8. De app is opgebouwd via de stories `ST-001`, `ST-101`, `ST-102`, `ST-103` en `ST-104`. +9. De UI is later structureel gemigreerd naar `shadcn/ui`. +10. De repository is publiek gemaakt, gekoppeld aan Vercel en op `inspannings-monitor.jp-visser.nl` gezet. +11. CI/CD en branch protection zijn ingericht rond `main` en `Lint and build`. + +## 13. Gerelateerde documenten + +- [docs/README.md](/Users/janpetervisser/Development/third/docs/README.md) +- [inspannings-monitor-cicd-en-deploy.md](/Users/janpetervisser/Development/third/docs/inspannings-monitor-cicd-en-deploy.md) +- [inspannings-monitor-dagelijkse-deploy-checklist.md](/Users/janpetervisser/Development/third/docs/inspannings-monitor-dagelijkse-deploy-checklist.md) +- [inspannings-monitor-ops-security-notitie.md](/Users/janpetervisser/Development/third/docs/inspannings-monitor-ops-security-notitie.md) diff --git a/docs/inspannings-monitor-01-productkader-en-positionering-v06.docx b/docs/inspannings-monitor-01-productkader-en-positionering-v06.docx index 6e0df11..e0c2ae5 100644 Binary files a/docs/inspannings-monitor-01-productkader-en-positionering-v06.docx and b/docs/inspannings-monitor-01-productkader-en-positionering-v06.docx differ diff --git a/docs/inspannings-monitor-02-functionele-specificatie-mvp-v06.docx b/docs/inspannings-monitor-02-functionele-specificatie-mvp-v06.docx index c128fcc..fbcea2b 100644 Binary files a/docs/inspannings-monitor-02-functionele-specificatie-mvp-v06.docx and b/docs/inspannings-monitor-02-functionele-specificatie-mvp-v06.docx differ diff --git a/docs/inspannings-monitor-03-privacy-security-safety-baseline-v02.docx b/docs/inspannings-monitor-03-privacy-security-safety-baseline-v02.docx index acc3c10..e1a51fd 100644 Binary files a/docs/inspannings-monitor-03-privacy-security-safety-baseline-v02.docx and b/docs/inspannings-monitor-03-privacy-security-safety-baseline-v02.docx differ diff --git a/docs/inspannings-monitor-04-roadmap-wellness-naar-medisch-v02.docx b/docs/inspannings-monitor-04-roadmap-wellness-naar-medisch-v02.docx index 1e99be5..8f51a3a 100644 Binary files a/docs/inspannings-monitor-04-roadmap-wellness-naar-medisch-v02.docx and b/docs/inspannings-monitor-04-roadmap-wellness-naar-medisch-v02.docx differ diff --git a/docs/inspannings-monitor-05-technische-architectuur-en-implementatie-v01.docx b/docs/inspannings-monitor-05-technische-architectuur-en-implementatie-v01.docx index ef48942..c80a553 100644 Binary files a/docs/inspannings-monitor-05-technische-architectuur-en-implementatie-v01.docx and b/docs/inspannings-monitor-05-technische-architectuur-en-implementatie-v01.docx differ diff --git a/docs/inspannings-monitor-06-implementatieplan-en-backlog-v01.docx b/docs/inspannings-monitor-06-implementatieplan-en-backlog-v01.docx index 96077d1..5cf4202 100644 Binary files a/docs/inspannings-monitor-06-implementatieplan-en-backlog-v01.docx and b/docs/inspannings-monitor-06-implementatieplan-en-backlog-v01.docx differ diff --git a/docs/inspannings-monitor-dagelijkse-deploy-checklist.md b/docs/inspannings-monitor-dagelijkse-deploy-checklist.md new file mode 100644 index 0000000..250d246 --- /dev/null +++ b/docs/inspannings-monitor-dagelijkse-deploy-checklist.md @@ -0,0 +1,59 @@ +# Inspannings Monitor Dagelijkse Deploy Checklist + +Deze checklist is bedoeld voor de normale werkflow van branch naar productie. + +## 1. Werk starten + +1. Maak een nieuwe branch vanaf `main`. +2. Geef de branch een duidelijke naam, bijvoorbeeld `feature/st-201-ochtend-checkin`. +3. Werk lokaal en controleer tussendoor met: + - `npm run lint` + - `npm run build` + +## 2. Wijzigingen publiceren + +1. Commit je werk lokaal. +2. Push de branch naar GitHub. +3. Open een pull request naar `main`. + +## 3. CI controleren + +1. Open de pull request in GitHub. +2. Controleer of de verplichte status check `Lint and build` groen is. +3. Merge niet zolang deze check faalt. + +## 4. Preview deployment controleren + +1. Open de Vercel preview deployment die aan de pull request hangt. +2. Controleer minimaal: + - landingpagina `/` + - login `/login` + - signup `/sign-up` + - dashboard `/dashboard` +3. Controleer bij auth-wijzigingen ook de bevestigingsflow via `/auth/confirm`. + +## 5. Merge naar productie + +1. Merge de pull request naar `main`. +2. Wacht tot Vercel automatisch de production deployment uitvoert. +3. Controleer daarna productie op: + - [inspannings-monitor.jp-visser.nl](https://inspannings-monitor.jp-visser.nl) + - login/signup + - dashboard + - settings + +## 6. Bij problemen + +1. Open de laatste deployment in Vercel. +2. Controleer build logs en runtime logs. +3. Revert de merge in GitHub als productie echt stuk is. +4. Laat Vercel daarna automatisch opnieuw deployen vanaf de herstelde `main`. + +## 7. Huidige projectafspraken + +- `main` is beschermd +- pull requests zijn verplicht +- status check `Lint and build` is verplicht +- force pushes naar `main` zijn geblokkeerd +- branch deletion van `main` is geblokkeerd +- productie draait op [inspannings-monitor.jp-visser.nl](https://inspannings-monitor.jp-visser.nl) diff --git a/docs/inspannings-monitor-ops-security-notitie.md b/docs/inspannings-monitor-ops-security-notitie.md new file mode 100644 index 0000000..86b24c8 --- /dev/null +++ b/docs/inspannings-monitor-ops-security-notitie.md @@ -0,0 +1,80 @@ +# Inspannings Monitor Ops en Security Notitie + +Datum: `2026-04-18` + +Deze notitie legt de actuele operationele en security-besluiten vast rond +repositorybeheer, deployment en secrets. + +## 1. Huidige operationele status + +- GitHub-repository: `public` +- Standaardbranch: `main` +- CI: GitHub Actions workflow `CI` +- Verplichte status check op `main`: `Lint and build` +- Productiehosting: `Vercel` +- Productiedomein: [inspannings-monitor.jp-visser.nl](https://inspannings-monitor.jp-visser.nl) + +## 2. Branch protection + +De branch `main` is beschermd met: + +- pull requests verplicht +- required status check `Lint and build` +- force pushes geblokkeerd +- branch deletion geblokkeerd + +Bewuste huidige keuze: + +- `Require branches to be up to date before merging` staat niet verplicht aan + +Dat is voor de huidige projectfase acceptabel en houdt de flow eenvoudig. + +## 3. Vercel en deploymentbeleid + +De gekozen deployroute is: + +- feature branches en pull requests krijgen preview deployments via Vercel +- merges naar `main` geven een automatische production deployment + +Voor deze frontend-app worden in Vercel alleen publieke Supabase-variabelen gebruikt: + +- `NEXT_PUBLIC_SUPABASE_URL` +- `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` + +Er hoort geen `service_role` of andere admin-key in Vercel te staan. + +## 4. Secret-incident en respons + +Tijdens de setupfase heeft lokaal een Supabase `service_role` key in een +omgevingsbestand gestaan en daarmee tijdelijk in git-tracking gezeten. + +Reeds genomen maatregelen: + +- `.env` en `.env.local` zijn uit git-tracking gehaald +- `.gitignore` is aangescherpt zodat lokale env-bestanden niet opnieuw meegaan +- de applicatiecode gebruikt geen `service_role` key +- de frontend gebruikt alleen de publishable key +- in het Supabase-dashboard stond de legacy JWT-keysectie op het moment van controle uitgeschakeld + +## 5. Resterende security-aandachtspunten + +Deze punten zijn nog belangrijk, ook als de app nu functioneel goed draait: + +1. Behandel de eerder gebruikte `service_role` key als gecompromitteerd. +2. Gebruik die key nergens meer opnieuw. +3. Gebruik voor toekomstige server/admin-taken alleen een nieuwe secret-key als dat echt nodig is. +4. Overweeg de oude secret ook uit de Git-history te verwijderen als je de repositoryhistorie volledig wilt opschonen. + +## 6. Praktische beheerafspraken + +- secrets nooit in de repository opslaan +- `.env.example` alleen als template gebruiken +- deploys alleen via GitHub + Vercel laten lopen +- wijzigingen naar productie via pull request en `main` +- production altijd kort valideren na merge + +## 7. Relevante documenten + +- [inspannings-monitor-cicd-en-deploy.md](/Users/janpetervisser/Development/third/docs/inspannings-monitor-cicd-en-deploy.md) +- [inspannings-monitor-dagelijkse-deploy-checklist.md](/Users/janpetervisser/Development/third/docs/inspannings-monitor-dagelijkse-deploy-checklist.md) +- [README.md](/Users/janpetervisser/Development/third/README.md) diff --git a/lib/auth/messages.ts b/lib/auth/messages.ts index fcfe3e4..c4c45ce 100644 --- a/lib/auth/messages.ts +++ b/lib/auth/messages.ts @@ -22,6 +22,14 @@ const errorMessages: Record = { tone: "error", text: "Vul zowel je e-mailadres als je wachtwoord in.", }, + "invalid-email": { + tone: "error", + text: "Gebruik een geldig e-mailadres.", + }, + "password-too-short": { + tone: "error", + text: "Gebruik een wachtwoord van minimaal 8 tekens.", + }, "signup-failed": { tone: "error", text: "Je account kon niet worden aangemaakt. Probeer het opnieuw.", diff --git a/lib/config/feature-flags.ts b/lib/config/feature-flags.ts new file mode 100644 index 0000000..79830ab --- /dev/null +++ b/lib/config/feature-flags.ts @@ -0,0 +1,3 @@ +export function isTestWizardEnabled() { + return process.env.NEXT_PUBLIC_ENABLE_TEST_WIZARD === "true"; +} diff --git a/lib/feedback/status-messages.ts b/lib/feedback/status-messages.ts new file mode 100644 index 0000000..bcf955d --- /dev/null +++ b/lib/feedback/status-messages.ts @@ -0,0 +1,105 @@ +import { getAuthNotice } from "@/lib/auth/messages"; + +export type StatusToastVariant = "success" | "info" | "warning" | "error"; + +export type StatusToast = { + variant: StatusToastVariant; + title?: string; + message: string; +}; + +const dashboardStatusToasts: Record = { + "onboarding-completed": { + variant: "success", + title: "Onboarding opgeslagen", + message: "Je basisinstellingen staan nu klaar.", + }, + "onboarding-skipped": { + variant: "info", + title: "Onboarding overgeslagen", + message: "Je kunt de onboarding later alsnog afronden vanuit het dashboard.", + }, + "test-wizard-completed": { + variant: "success", + title: "Test wizard afgerond", + message: "De generieke wizard-flow werkt nu vanaf het dashboard.", + }, +}; + +const settingsStatusToasts: Record = { + saved: { + variant: "success", + title: "Instellingen opgeslagen", + message: "Je voorkeuren zijn bijgewerkt.", + }, +}; + +const settingsErrorToasts: Record = { + "invalid-settings-input": { + variant: "error", + title: "Instellingen niet opgeslagen", + message: "Controleer je tijd, timezone en voorkeurvelden en probeer het opnieuw.", + }, +}; + +const onboardingErrorToasts: Record = { + "invalid-onboarding-input": { + variant: "error", + title: "Onboarding niet opgeslagen", + message: "Controleer je ingevoerde voorkeuren en probeer het opnieuw.", + }, +}; + +export function getDashboardStatusToast(status: string | null): StatusToast | null { + if (!status) { + return null; + } + + return dashboardStatusToasts[status] ?? null; +} + +export function getSettingsStatusToast( + error: string | null, + status: string | null, +): StatusToast | null { + if (error && settingsErrorToasts[error]) { + return settingsErrorToasts[error]; + } + + if (!status) { + return null; + } + + return settingsStatusToasts[status] ?? null; +} + +export function getOnboardingStatusToast( + error: string | null, + status: string | null, +): StatusToast | null { + if (error && onboardingErrorToasts[error]) { + return onboardingErrorToasts[error]; + } + + if (!status) { + return null; + } + + return null; +} + +export function getAuthStatusToast( + error: string | null, + status: string | null, +): StatusToast | null { + const notice = getAuthNotice(error, status); + + if (!notice) { + return null; + } + + return { + variant: notice.tone, + message: notice.text, + }; +} diff --git a/lib/feedback/toast.ts b/lib/feedback/toast.ts new file mode 100644 index 0000000..70aada0 --- /dev/null +++ b/lib/feedback/toast.ts @@ -0,0 +1,26 @@ +"use client"; + +import { toast } from "sonner"; +import type { StatusToast } from "@/lib/feedback/status-messages"; + +export function showStatusToast(statusToast: StatusToast) { + const description = statusToast.title ? statusToast.message : undefined; + const message = statusToast.title ?? statusToast.message; + + if (statusToast.variant === "success") { + toast.success(message, { description }); + return; + } + + if (statusToast.variant === "info") { + toast.info(message, { description }); + return; + } + + if (statusToast.variant === "warning") { + toast.warning(message, { description }); + return; + } + + toast.error(message, { description }); +} diff --git a/lib/forms/parse.ts b/lib/forms/parse.ts new file mode 100644 index 0000000..82faaa7 --- /dev/null +++ b/lib/forms/parse.ts @@ -0,0 +1,117 @@ +const TIME_VALUE_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; +const EMAIL_VALUE_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export class FormDataValidationError extends Error { + code: string; + + constructor(code: string) { + super(code); + this.code = code; + this.name = "FormDataValidationError"; + } +} + +function fail(code: string): never { + throw new FormDataValidationError(code); +} + +export function getOptionalString(formData: FormData, key: string): string | null { + const value = formData.get(key); + + if (typeof value !== "string") { + return null; + } + + return value.trim(); +} + +export function getRequiredString( + formData: FormData, + key: string, + errorCode: string, +): string { + const value = formData.get(key); + + if (typeof value !== "string") { + fail(errorCode); + } + + const trimmedValue = value.trim(); + + if (!trimmedValue) { + fail(errorCode); + } + + return trimmedValue; +} + +export function getBooleanValue( + formData: FormData, + key: string, + errorCode: string, +): boolean { + const value = formData.get(key); + + if (value === "true") { + return true; + } + + if (value === "false") { + return false; + } + + fail(errorCode); +} + +export function getEnumValue( + formData: FormData, + key: string, + allowedValues: readonly TValue[], + errorCode: string, +): TValue { + const value = getRequiredString(formData, key, errorCode); + + if (!allowedValues.includes(value as TValue)) { + fail(errorCode); + } + + return value as TValue; +} + +export function getOptionalTimeValue( + formData: FormData, + key: string, + errorCode: string, +): string | null { + const value = getOptionalString(formData, key); + + if (!value) { + return null; + } + + if (!TIME_VALUE_PATTERN.test(value)) { + fail(errorCode); + } + + return value; +} + +export function assertEmail(value: string, errorCode: string): string { + if (!EMAIL_VALUE_PATTERN.test(value)) { + fail(errorCode); + } + + return value; +} + +export function assertMinLength( + value: string, + minimumLength: number, + errorCode: string, +): string { + if (value.length < minimumLength) { + fail(errorCode); + } + + return value; +} diff --git a/lib/onboarding/use-onboarding-draft.ts b/lib/onboarding/use-onboarding-draft.ts new file mode 100644 index 0000000..aa1e1a3 --- /dev/null +++ b/lib/onboarding/use-onboarding-draft.ts @@ -0,0 +1,40 @@ +"use client"; + +import { useState } from "react"; +import { + type PreferenceDraft, + usePreferenceDraft, +} from "@/lib/preferences/use-preferences-draft"; +import type { ProfileBundle } from "@/lib/profile/types"; + +export type OnboardingDraft = PreferenceDraft & { + displayName: string; +}; + +export function useOnboardingDraft(profileBundle: ProfileBundle) { + const { draft: preferenceDraft, updateDraft: updatePreferenceDraft } = + usePreferenceDraft(profileBundle); + const [displayName, setDisplayName] = useState( + profileBundle.profile.displayName ?? "", + ); + + function updateDraft(patch: Partial) { + const { displayName: nextDisplayName, ...preferencePatch } = patch; + + if (nextDisplayName !== undefined) { + setDisplayName(nextDisplayName); + } + + if (Object.keys(preferencePatch).length > 0) { + updatePreferenceDraft(preferencePatch as Partial); + } + } + + return { + draft: { + displayName, + ...preferenceDraft, + }, + updateDraft, + }; +} diff --git a/lib/preferences/use-preferences-draft.ts b/lib/preferences/use-preferences-draft.ts new file mode 100644 index 0000000..2cd77f1 --- /dev/null +++ b/lib/preferences/use-preferences-draft.ts @@ -0,0 +1,40 @@ +"use client"; + +import { useState } from "react"; +import type { ProfileBundle } from "@/lib/profile/types"; + +export const DEFAULT_MORNING_REMINDER_TIME = "08:30"; + +export type PreferenceDraft = { + timezone: string; + showEnergyPoints: boolean; + morningReminderEnabled: boolean; + morningReminderTime: string; + reflectionReminderEnabled: boolean; +}; + +export function buildPreferenceDraft(profileBundle: ProfileBundle): PreferenceDraft { + return { + timezone: profileBundle.profile.timezone, + showEnergyPoints: profileBundle.settings.showEnergyPoints, + morningReminderEnabled: profileBundle.settings.morningReminderEnabled, + morningReminderTime: + profileBundle.settings.morningReminderTime ?? DEFAULT_MORNING_REMINDER_TIME, + reflectionReminderEnabled: profileBundle.settings.reflectionReminderEnabled, + }; +} + +export function usePreferenceDraft(profileBundle: ProfileBundle) { + const [draft, setDraft] = useState(() => + buildPreferenceDraft(profileBundle), + ); + + function updateDraft(patch: Partial) { + setDraft((currentDraft) => ({ ...currentDraft, ...patch })); + } + + return { + draft, + updateDraft, + }; +} diff --git a/lib/search-params.ts b/lib/search-params.ts new file mode 100644 index 0000000..12cc2e9 --- /dev/null +++ b/lib/search-params.ts @@ -0,0 +1,6 @@ +export type PageSearchParams = Record; + +export function getParamValue(params: PageSearchParams, key: string): string | null { + const value = params[key]; + return typeof value === "string" ? value : null; +} diff --git a/lib/wizard/types.ts b/lib/wizard/types.ts new file mode 100644 index 0000000..bcb3d65 --- /dev/null +++ b/lib/wizard/types.ts @@ -0,0 +1,19 @@ +export type WizardStepDefinition = { + id: string; + eyebrow?: string; + title: string; + description?: string; + canContinue?: (draft: TDraft) => boolean; +}; + +export type WizardFlowState = { + steps: WizardStepDefinition[]; + currentStepIndex: number; + currentStep: WizardStepDefinition; + isFirstStep: boolean; + isLastStep: boolean; + canContinue: boolean; + goToNextStep: () => void; + goToPreviousStep: () => void; + goToStep: (stepId: string) => void; +}; diff --git a/lib/wizard/use-wizard-flow.ts b/lib/wizard/use-wizard-flow.ts new file mode 100644 index 0000000..c122400 --- /dev/null +++ b/lib/wizard/use-wizard-flow.ts @@ -0,0 +1,67 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { WizardFlowState, WizardStepDefinition } from "@/lib/wizard/types"; + +type UseWizardFlowOptions = { + steps: WizardStepDefinition[]; + draft: TDraft; + initialStepId?: string; +}; + +export function useWizardFlow({ + steps, + draft, + initialStepId, +}: UseWizardFlowOptions): WizardFlowState { + const initialStepIndex = useMemo(() => { + if (!initialStepId) { + return 0; + } + + const stepIndex = steps.findIndex((step) => step.id === initialStepId); + return stepIndex >= 0 ? stepIndex : 0; + }, [initialStepId, steps]); + + const [currentStepIndex, setCurrentStepIndex] = useState(initialStepIndex); + const currentStep = steps[currentStepIndex]; + const isFirstStep = currentStepIndex === 0; + const isLastStep = currentStepIndex === steps.length - 1; + const canContinue = currentStep?.canContinue ? currentStep.canContinue(draft) : true; + + function goToNextStep() { + if (isLastStep || !canContinue) { + return; + } + + setCurrentStepIndex((stepIndex) => Math.min(steps.length - 1, stepIndex + 1)); + } + + function goToPreviousStep() { + if (isFirstStep) { + return; + } + + setCurrentStepIndex((stepIndex) => Math.max(0, stepIndex - 1)); + } + + function goToStep(stepId: string) { + const stepIndex = steps.findIndex((step) => step.id === stepId); + + if (stepIndex >= 0) { + setCurrentStepIndex(stepIndex); + } + } + + return { + steps, + currentStepIndex, + currentStep, + isFirstStep, + isLastStep, + canContinue, + goToNextStep, + goToPreviousStep, + goToStep, + }; +} diff --git a/package-lock.json b/package-lock.json index a9c33d8..fc39f3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,11 @@ "clsx": "^2.1.1", "lucide-react": "^1.8.0", "next": "16.2.0", + "next-themes": "^0.4.6", "react": "19.2.0", "react-dom": "19.2.0", "shadcn": "^4.3.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0" }, @@ -7386,6 +7388,16 @@ } } }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -8815,6 +8827,16 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index de51b45..6a78528 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,11 @@ "clsx": "^2.1.1", "lucide-react": "^1.8.0", "next": "16.2.0", + "next-themes": "^0.4.6", "react": "19.2.0", "react-dom": "19.2.0", "shadcn": "^4.3.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0" },