Add wizard flows, toast feedback, and strict form validation

This commit is contained in:
Janpeter Visser 2026-04-18 18:14:36 +02:00
parent 96b26aa5d1
commit 000d2351c1
47 changed files with 2169 additions and 500 deletions

View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -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<Record<string, string | string[] | undefined>>;
searchParams: Promise<PageSearchParams>;
};
function getParamValue(
params: Record<string, string | string[] | undefined>,
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 (
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] px-6 py-10 text-slate-900 sm:px-8">
<div className="mx-auto flex max-w-6xl flex-col gap-8">
{notice ? (
<Alert className="rounded-[1.5rem] border-emerald-200 bg-emerald-50 text-emerald-950 [&_svg]:text-emerald-700">
<AlertDescription className="leading-7 text-current">
{notice}
</AlertDescription>
</Alert>
) : null}
<StatusToastBridge toast={statusToast} />
<header className="flex flex-col gap-5 rounded-[2rem] border border-black/10 bg-white/75 p-6 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur sm:flex-row sm:items-start sm:justify-between sm:p-8">
<div>
@ -117,6 +94,17 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
>
Instellingen
</Link>
{isTestWizardEnabled() ? (
<Link
href="/wizard-test"
className={cn(
buttonVariants({ variant: "outline", size: "lg" }),
"h-11 rounded-full px-5",
)}
>
Test wizard
</Link>
) : null}
<Button type="submit" size="lg" className="h-11 rounded-full px-5">
Uitloggen
</Button>
@ -185,6 +173,22 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
</CardDescription>
</CardContent>
</Card>
{isTestWizardEnabled() ? (
<Card className="rounded-[1.75rem] border border-border/60 bg-card/90 py-0 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
<CardHeader className="pb-0">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
Wizard core
</p>
<CardTitle className="text-lg text-slate-900">Interne testwizard actief</CardTitle>
</CardHeader>
<CardContent className="pb-6">
<CardDescription className="text-sm leading-7 text-muted-foreground">
Gebruik deze alleen in development of preview om nieuwe multi-step flows te controleren.
</CardDescription>
</CardContent>
</Card>
) : null}
</section>
{!profile.onboardingCompleted ? (

View file

@ -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 (
<html lang="nl">
<body className="min-h-screen">{children}</body>
<body className="min-h-screen">
{children}
<Toaster position="top-right" richColors closeButton />
</body>
</html>
);
}

View file

@ -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<Record<string, string | string[] | undefined>>;
searchParams: Promise<PageSearchParams>;
};
function getParamValue(
params: Record<string, string | string[] | undefined>,
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) {
</p>
}
>
<AuthNotice notice={notice} />
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
{!authState.isConfigured ? (
<Alert className="rounded-[1.5rem] border-sky-200 bg-sky-50 text-sky-950 [&_svg]:text-sky-700">

View file

@ -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<null> {
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<null> {
await markOnboardingSeenForCurrentUser();
redirect(buildPathWithQuery("/dashboard", { status: "onboarding-skipped" }));
return null;
}

View file

@ -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<PageSearchParams>;
};
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 (
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] px-6 py-10 text-slate-900 sm:px-8">
<div className="mx-auto flex max-w-6xl flex-col gap-8">
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
<OnboardingFlow profileBundle={profileBundle} />
</div>
</main>

View file

@ -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<Record<string, string | string[] | undefined>>;
searchParams: Promise<PageSearchParams>;
};
function getParamValue(
params: Record<string, string | string[] | undefined>,
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) {
</div>
</header>
<AuthNotice notice={notice} />
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
<section className="grid gap-6 lg:grid-cols-[1.35fr_0.95fr]">
<Card className="rounded-[2rem] border border-border/60 bg-card/90 py-0 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur">

View file

@ -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<null> {
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;
}

View file

@ -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<Record<string, string | string[] | undefined>>;
searchParams: Promise<PageSearchParams>;
};
function getParamValue(
params: Record<string, string | string[] | undefined>,
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 (
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] px-6 py-10 text-slate-900 sm:px-8">
<div className="mx-auto flex max-w-6xl flex-col gap-8">
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
<header className="flex flex-col gap-5 rounded-[2rem] border border-black/10 bg-white/75 p-6 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur sm:flex-row sm:items-start sm:justify-between sm:p-8">
<div>
<div className="flex flex-wrap items-center gap-3 text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
@ -106,14 +97,6 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps)
</div>
</header>
{notice ? (
<Alert className="rounded-[1.5rem] border-emerald-200 bg-emerald-50 text-emerald-950 [&_svg]:text-emerald-700">
<AlertDescription className="leading-7 text-current">
{notice}
</AlertDescription>
</Alert>
) : null}
<section className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
<SettingsForm profileBundle={profileBundle} />

View file

@ -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<Record<string, string | string[] | undefined>>;
searchParams: Promise<PageSearchParams>;
};
function getParamValue(
params: Record<string, string | string[] | undefined>,
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) {
</p>
}
>
<AuthNotice notice={notice} />
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
{!authState.isConfigured ? (
<Alert className="rounded-[1.5rem] border-sky-200 bg-sky-50 text-sky-950 [&_svg]:text-sky-700">

31
app/wizard-test/page.tsx Normal file
View file

@ -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 (
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] px-6 py-10 text-slate-900 sm:px-8">
<div className="mx-auto flex max-w-6xl flex-col gap-8">
<TestWizardFlow />
</div>
</main>
);
}

View file

@ -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;
}

View file

@ -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<OnboardingDraft>[] = [
{
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<OnboardingDraft>) => void,
disabled: boolean,
) {
switch (stepId) {
case "intro":
return <OnboardingStepIntro />;
case "profile":
return (
<OnboardingStepProfile
draft={draft}
updateDraft={updateDraft}
disabled={disabled}
/>
);
case "preferences":
return (
<OnboardingStepPreferences
draft={draft}
updateDraft={updateDraft}
disabled={disabled}
/>
);
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 = (
<Alert className="rounded-[1.5rem] border-white/10 bg-white/8 text-primary-foreground [&_svg]:text-primary-foreground/80">
<AlertDescription className="leading-7 text-current">
<span className="block font-semibold">Release 1 blijft bewust wellness-first.</span>
<span className="mt-2 block">
Alleen voor individuele gebruikers, zonder delen of zorgverlenerstoegang.
</span>
<span className="block">
De app geeft geen diagnose, behandeling of medisch advies.
</span>
<span className="block">
Bij acute of snel verslechterende klachten hoort directe hulp via arts, huisartsenpost of 112 buiten deze app.
</span>
</AlertDescription>
</Alert>
);
const step = steps[currentStep];
const isFirstStep = currentStep === 0;
const isLastStep = currentStep === steps.length - 1;
const topAction = (
<>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
Korte onboarding
</p>
<form action={skipFormAction}>
<Button type="submit" variant="outline" disabled={isPending} className="rounded-full">
{isSkipping ? "Overslaan..." : "Nu overslaan"}
</Button>
</form>
</>
);
function goToPreviousStep() {
setCurrentStep((stepIndex) => Math.max(0, stepIndex - 1));
}
const backAction = (
<Button
type="button"
variant="outline"
onClick={wizard.goToPreviousStep}
disabled={wizard.isFirstStep || isPending}
className="rounded-full"
>
Vorige
</Button>
);
function goToNextStep(event: MouseEvent<HTMLButtonElement>) {
event.preventDefault();
setCurrentStep((stepIndex) => Math.min(steps.length - 1, stepIndex + 1));
}
const nextAction = wizard.isLastStep ? (
<Button
key="complete-onboarding"
type="submit"
form="onboarding-form"
disabled={isPending}
className="rounded-full"
>
{isCompleting ? "Onboarding opslaan..." : "Rond onboarding af"}
</Button>
) : (
<Button
key={`next-step-${wizard.currentStep.id}`}
type="button"
onClick={wizard.goToNextStep}
disabled={!wizard.canContinue || isPending}
className="rounded-full"
>
Ga verder
</Button>
);
return (
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
<section className="rounded-[2rem] border border-primary/15 bg-primary p-7 text-primary-foreground shadow-[0_18px_60px_rgba(22,58,43,0.18)] sm:p-9">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/70">
{step.eyebrow}
</p>
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl leading-tight sm:text-5xl">
{step.title}
</h1>
<p className="mt-5 max-w-xl text-base leading-8 text-primary-foreground/85">
{step.description}
</p>
<WizardShell
eyebrow={wizard.currentStep.eyebrow}
title={wizard.currentStep.title}
description={wizard.currentStep.description}
progressCurrent={wizard.currentStepIndex + 1}
progressTotal={wizard.steps.length}
topAction={topAction}
aside={aside}
backAction={backAction}
nextAction={nextAction}
>
<form
id="onboarding-form"
action={completeFormAction}
className="space-y-6"
aria-busy={isPending}
>
<input type="hidden" name="displayName" value={draft.displayName} />
<PreferenceHiddenFields draft={draft} />
<Alert className="mt-10 rounded-[1.5rem] border-white/10 bg-white/8 text-primary-foreground [&_svg]:text-primary-foreground/80">
<AlertDescription className="leading-7 text-current">
<span className="block font-semibold">Release 1 blijft bewust wellness-first.</span>
<span className="mt-2 block">
Alleen voor individuele gebruikers, zonder delen of zorgverlenerstoegang.
</span>
<span className="block">
De app geeft geen diagnose, behandeling of medisch advies.
</span>
<span className="block">
Bij acute of snel verslechterende klachten hoort directe hulp via arts, huisartsenpost of 112 buiten deze app.
</span>
</AlertDescription>
</Alert>
<ol className="mt-8 flex gap-3">
{steps.map((item, index) => (
<li
key={item.title}
className={`h-2 flex-1 rounded-full ${
index <= currentStep ? "bg-primary-foreground/85" : "bg-white/15"
}`}
/>
))}
</ol>
</section>
<section className="rounded-[2rem] border border-border/60 bg-card/90 p-6 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur sm:p-8">
<div className="mb-6 flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
Korte onboarding
</p>
<form action={skipOnboardingAction}>
<Button type="submit" variant="outline" className="rounded-full">
Nu overslaan
</Button>
</form>
</div>
<form action={completeOnboardingAction} className="space-y-6">
<input type="hidden" name="displayName" value={displayName} />
<input type="hidden" name="timezone" value={timezone} />
<input
type="hidden"
name="showEnergyPoints"
value={showEnergyPoints ? "true" : "false"}
/>
<input
type="hidden"
name="morningReminderEnabled"
value={morningReminderEnabled ? "true" : "false"}
/>
<input type="hidden" name="morningReminderTime" value={morningReminderTime} />
<input
type="hidden"
name="reflectionReminderEnabled"
value={reflectionReminderEnabled ? "true" : "false"}
/>
{currentStep === 0 ? (
<div className="space-y-4">
<Card className="rounded-[1.5rem] border border-border/60 bg-background/80 py-0">
<CardHeader className="pb-0">
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
Wat je hier wél krijgt
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-sm leading-7 text-muted-foreground">
Een rustige plan-doe-evalueer flow met energiebudgetten, zonder
druk, score-oordeel of medische terminologie.
</CardDescription>
</CardContent>
</Card>
<Card className="rounded-[1.5rem] border border-border/60 bg-background/80 py-0">
<CardHeader className="pb-0">
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
Wat deze app niet doet
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-sm leading-7 text-muted-foreground">
Geen diagnose, geen behandeling, geen medische triage en geen
automatisch delen met derden.
</CardDescription>
</CardContent>
</Card>
</div>
) : null}
{currentStep === 1 ? (
<div className="space-y-5">
<div className="space-y-2">
<Label htmlFor="display-name" className="text-slate-800">
Schermnaam
</Label>
<Input
id="display-name"
className="h-12 rounded-[1.25rem] bg-background/80 px-4 text-base md:text-base"
type="text"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
placeholder="Optioneel, bijvoorbeeld Jan"
maxLength={40}
/>
</div>
<Alert className="rounded-[1.5rem] border-sky-200 bg-sky-50 text-sky-950 [&_svg]:text-sky-700">
<AlertDescription className="leading-7 text-current">
Voertaal voor release 1 staat vast op <strong>Nederlands</strong>.
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label className="text-slate-800">Timezone</Label>
<Select
value={timezone}
onValueChange={(value) =>
setTimezone(value ?? profileBundle.profile.timezone)
}
>
<SelectTrigger className="h-12 w-full rounded-[1.25rem] bg-background/80 px-4 text-base">
<SelectValue placeholder="Kies een timezone" />
</SelectTrigger>
<SelectContent>
{ONBOARDING_TIMEZONE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : null}
{currentStep === 2 ? (
<div className="space-y-4">
<Card className="rounded-[1.5rem] border border-border/60 bg-background/80 py-0">
<CardContent className="flex items-start justify-between gap-4 py-5">
<div className="space-y-1">
<Label className="text-sm font-semibold text-slate-900">
Toon energiebudgetpunten
</Label>
<p className="text-sm leading-7 text-muted-foreground">
Laat geplande en resterende punten zichtbaar zien in de interface.
</p>
</div>
<Switch checked={showEnergyPoints} onCheckedChange={setShowEnergyPoints} />
</CardContent>
</Card>
<Card className="rounded-[1.5rem] border border-border/60 bg-background/80 py-0">
<CardContent className="space-y-4 py-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label className="text-sm font-semibold text-slate-900">
Zet een lichte ochtendreminder aan
</Label>
<p className="text-sm leading-7 text-muted-foreground">
Handig als je later een korte check-in wilt doen zonder extra druk.
</p>
</div>
<Switch
checked={morningReminderEnabled}
onCheckedChange={setMorningReminderEnabled}
/>
</div>
{morningReminderEnabled ? (
<>
<Separator />
<div className="space-y-2">
<Label htmlFor="morning-reminder-time" className="text-slate-800">
Tijdstip voor de ochtendreminder
</Label>
<Input
id="morning-reminder-time"
className="h-12 rounded-[1.25rem] bg-white px-4 text-base md:text-base"
type="time"
value={morningReminderTime}
onChange={(event) => setMorningReminderTime(event.target.value)}
/>
</div>
</>
) : null}
</CardContent>
</Card>
<Card className="rounded-[1.5rem] border border-border/60 bg-background/80 py-0">
<CardContent className="flex items-start justify-between gap-4 py-5">
<div className="space-y-1">
<Label className="text-sm font-semibold text-slate-900">
Sta lichte reflectieprompts toe
</Label>
<p className="text-sm leading-7 text-muted-foreground">
Optionele terugblikprompts kunnen later helpen om rustiger patronen te zien.
</p>
</div>
<Switch
checked={reflectionReminderEnabled}
onCheckedChange={setReflectionReminderEnabled}
/>
</CardContent>
</Card>
</div>
) : null}
<Separator />
<div className="flex flex-wrap items-center justify-between gap-3">
<Button
type="button"
variant="outline"
onClick={goToPreviousStep}
disabled={isFirstStep}
className="rounded-full"
>
Vorige
</Button>
{isLastStep ? (
<Button
key="complete-onboarding"
type="submit"
className="rounded-full"
>
Rond onboarding af
</Button>
) : (
<Button
key={`next-step-${currentStep}`}
type="button"
onClick={goToNextStep}
className="rounded-full"
>
Ga verder
</Button>
)}
</div>
</form>
</section>
</div>
{renderCurrentStep(wizard.currentStep.id, draft, updateDraft, isPending)}
</form>
</WizardShell>
);
}

View file

@ -0,0 +1,41 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export function OnboardingStepIntro() {
return (
<div className="space-y-4">
<Card className="rounded-[1.5rem] border border-border/60 bg-background/80 py-0">
<CardHeader className="pb-0">
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
Wat je hier wél krijgt
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-sm leading-7 text-muted-foreground">
Een rustige plan-doe-evalueer flow met energiebudgetten, zonder druk,
score-oordeel of medische terminologie.
</CardDescription>
</CardContent>
</Card>
<Card className="rounded-[1.5rem] border border-border/60 bg-background/80 py-0">
<CardHeader className="pb-0">
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
Wat deze app niet doet
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-sm leading-7 text-muted-foreground">
Geen diagnose, geen behandeling, geen medische triage en geen automatisch
delen met derden.
</CardDescription>
</CardContent>
</Card>
</div>
);
}

View file

@ -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<OnboardingDraft>) => void;
disabled?: boolean;
};
export function OnboardingStepPreferences({
draft,
updateDraft,
disabled = false,
}: OnboardingStepPreferencesProps) {
return (
<div className="space-y-4">
<Card className="rounded-[1.5rem] border border-border/60 bg-background/80 py-0">
<CardContent className="flex items-start justify-between gap-4 py-5">
<div className="space-y-1">
<Label className="text-sm font-semibold text-slate-900">
Toon energiebudgetpunten
</Label>
<p className="text-sm leading-7 text-muted-foreground">
Laat geplande en resterende punten zichtbaar zien in de interface.
</p>
</div>
<Switch
disabled={disabled}
checked={draft.showEnergyPoints}
onCheckedChange={(checked) => updateDraft({ showEnergyPoints: checked })}
/>
</CardContent>
</Card>
<Card className="rounded-[1.5rem] border border-border/60 bg-background/80 py-0">
<CardContent className="space-y-4 py-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label className="text-sm font-semibold text-slate-900">
Zet een lichte ochtendreminder aan
</Label>
<p className="text-sm leading-7 text-muted-foreground">
Handig als je later een korte check-in wilt doen zonder extra druk.
</p>
</div>
<Switch
disabled={disabled}
checked={draft.morningReminderEnabled}
onCheckedChange={(checked) =>
updateDraft({ morningReminderEnabled: checked })
}
/>
</div>
{draft.morningReminderEnabled ? (
<>
<Separator />
<div className="space-y-2">
<Label htmlFor="morning-reminder-time" className="text-slate-800">
Tijdstip voor de ochtendreminder
</Label>
<Input
id="morning-reminder-time"
className="h-12 rounded-[1.25rem] bg-white px-4 text-base md:text-base"
disabled={disabled}
type="time"
value={draft.morningReminderTime}
onChange={(event) =>
updateDraft({ morningReminderTime: event.target.value })
}
/>
</div>
</>
) : null}
</CardContent>
</Card>
<Card className="rounded-[1.5rem] border border-border/60 bg-background/80 py-0">
<CardContent className="flex items-start justify-between gap-4 py-5">
<div className="space-y-1">
<Label className="text-sm font-semibold text-slate-900">
Sta lichte reflectieprompts toe
</Label>
<p className="text-sm leading-7 text-muted-foreground">
Optionele terugblikprompts kunnen later helpen om rustiger patronen te zien.
</p>
</div>
<Switch
disabled={disabled}
checked={draft.reflectionReminderEnabled}
onCheckedChange={(checked) =>
updateDraft({ reflectionReminderEnabled: checked })
}
/>
</CardContent>
</Card>
</div>
);
}

View file

@ -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<OnboardingDraft>) => void;
disabled?: boolean;
};
export function OnboardingStepProfile({
draft,
updateDraft,
disabled = false,
}: OnboardingStepProfileProps) {
return (
<div className="space-y-5">
<div className="space-y-2">
<Label htmlFor="display-name" className="text-slate-800">
Schermnaam
</Label>
<Input
id="display-name"
className="h-12 rounded-[1.25rem] bg-background/80 px-4 text-base md:text-base"
disabled={disabled}
type="text"
value={draft.displayName}
onChange={(event) => updateDraft({ displayName: event.target.value })}
placeholder="Optioneel, bijvoorbeeld Jan"
maxLength={40}
/>
</div>
<Alert className="rounded-[1.5rem] border-sky-200 bg-sky-50 text-sky-950 [&_svg]:text-sky-700">
<AlertDescription className="leading-7 text-current">
Voertaal voor release 1 staat vast op <strong>Nederlands</strong>.
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label className="text-slate-800">Timezone</Label>
<Select
disabled={disabled}
value={draft.timezone}
onValueChange={(value) =>
updateDraft({
timezone: value ?? draft.timezone,
})
}
>
<SelectTrigger className="h-12 w-full rounded-[1.25rem] bg-background/80 px-4 text-base">
<SelectValue placeholder="Kies een timezone" />
</SelectTrigger>
<SelectContent>
{ONBOARDING_TIMEZONE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}

View file

@ -0,0 +1,31 @@
import type { PreferenceDraft } from "@/lib/preferences/use-preferences-draft";
type PreferenceHiddenFieldsProps = {
draft: PreferenceDraft;
};
export function PreferenceHiddenFields({
draft,
}: PreferenceHiddenFieldsProps) {
return (
<>
<input type="hidden" name="timezone" value={draft.timezone} />
<input
type="hidden"
name="showEnergyPoints"
value={draft.showEnergyPoints ? "true" : "false"}
/>
<input
type="hidden"
name="morningReminderEnabled"
value={draft.morningReminderEnabled ? "true" : "false"}
/>
<input type="hidden" name="morningReminderTime" value={draft.morningReminderTime} />
<input
type="hidden"
name="reflectionReminderEnabled"
value={draft.reflectionReminderEnabled ? "true" : "false"}
/>
</>
);
}

View file

@ -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 (
<form action={saveSettingsAction} className="space-y-6">
<form action={formAction} className="space-y-6" aria-busy={isPending}>
<input type="hidden" name="locale" value={locale} />
<input type="hidden" name="timezone" value={timezone} />
<input
type="hidden"
name="showEnergyPoints"
value={showEnergyPoints ? "true" : "false"}
/>
<input
type="hidden"
name="morningReminderEnabled"
value={morningReminderEnabled ? "true" : "false"}
/>
<input type="hidden" name="morningReminderTime" value={morningReminderTime} />
<input
type="hidden"
name="reflectionReminderEnabled"
value={reflectionReminderEnabled ? "true" : "false"}
/>
<PreferenceHiddenFields draft={draft} />
<Card className="rounded-[1.75rem] border border-border/60 bg-card/90 py-0 shadow-[0_18px_60px_rgba(71,85,105,0.1)]">
<CardHeader className="pb-0">
@ -107,6 +82,7 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
<div className="space-y-2">
<Label className="text-slate-800">Taal</Label>
<Select
disabled={isPending}
value={locale}
onValueChange={(value) => setLocale(value ?? profileBundle.profile.locale)}
>
@ -126,9 +102,12 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
<div className="space-y-2">
<Label className="text-slate-800">Timezone</Label>
<Select
value={timezone}
disabled={isPending}
value={draft.timezone}
onValueChange={(value) =>
setTimezone(value ?? profileBundle.profile.timezone)
updateDraft({
timezone: value ?? profileBundle.profile.timezone,
})
}
>
<SelectTrigger className="h-12 w-full rounded-[1.25rem] bg-background/80 px-4 text-base">
@ -165,8 +144,11 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
</div>
<Switch
id="show-energy-points"
checked={showEnergyPoints}
onCheckedChange={setShowEnergyPoints}
disabled={isPending}
checked={draft.showEnergyPoints}
onCheckedChange={(showEnergyPoints) =>
updateDraft({ showEnergyPoints })
}
/>
</CardContent>
</Card>
@ -193,14 +175,17 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
Zet een lichte reminder aan voor een rustige start van je check-in.
</p>
</div>
<Switch
id="morning-reminder-enabled"
checked={morningReminderEnabled}
onCheckedChange={setMorningReminderEnabled}
/>
<Switch
id="morning-reminder-enabled"
disabled={isPending}
checked={draft.morningReminderEnabled}
onCheckedChange={(morningReminderEnabled) =>
updateDraft({ morningReminderEnabled })
}
/>
</div>
{morningReminderEnabled ? (
{draft.morningReminderEnabled ? (
<>
<Separator />
<div className="space-y-2">
@ -210,9 +195,12 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
<Input
id="morning-reminder-time"
className="h-12 rounded-[1.25rem] bg-white px-4 text-base md:text-base"
disabled={isPending}
type="time"
value={morningReminderTime}
onChange={(event) => setMorningReminderTime(event.target.value)}
value={draft.morningReminderTime}
onChange={(event) =>
updateDraft({ morningReminderTime: event.target.value })
}
/>
</div>
</>
@ -232,8 +220,11 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
</div>
<Switch
id="reflection-reminder-enabled"
checked={reflectionReminderEnabled}
onCheckedChange={setReflectionReminderEnabled}
disabled={isPending}
checked={draft.reflectionReminderEnabled}
onCheckedChange={(reflectionReminderEnabled) =>
updateDraft({ reflectionReminderEnabled })
}
/>
</CardContent>
</Card>
@ -258,11 +249,13 @@ export function SettingsForm({ profileBundle }: SettingsFormProps) {
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm leading-7 text-muted-foreground">
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."}
</p>
<Button type="submit" size="lg" className="h-11 rounded-full px-5">
Instellingen opslaan
<Button type="submit" size="lg" disabled={isPending} className="h-11 rounded-full px-5">
{isPending ? "Instellingen opslaan..." : "Instellingen opslaan"}
</Button>
</div>
</form>

42
components/ui/sonner.tsx Normal file
View file

@ -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 (
<Sonner
theme="light"
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
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 };

View file

@ -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<string, never>;
const steps: WizardStepDefinition<TestWizardDraft>[] = [
{
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<string, string> = {
"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 = (
<Alert className="rounded-[1.5rem] border-white/10 bg-white/8 text-primary-foreground [&_svg]:text-primary-foreground/80">
<AlertDescription className="leading-7 text-current">
<span className="block font-semibold">Interne testwizard</span>
<span className="mt-2 block">
Alleen bedoeld om de generieke wizard-core te controleren voor toekomstige flows.
</span>
</AlertDescription>
</Alert>
);
const topAction = (
<>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
Test wizard
</p>
<Button
type="button"
variant="outline"
className="rounded-full"
onClick={() => router.push("/dashboard")}
>
Terug naar dashboard
</Button>
</>
);
const backAction = (
<Button
type="button"
variant="outline"
onClick={wizard.goToPreviousStep}
disabled={wizard.isFirstStep}
className="rounded-full"
>
Vorige
</Button>
);
const nextAction = wizard.isLastStep ? (
<Button type="button" className="rounded-full" onClick={finishWizard}>
Rond testwizard af
</Button>
) : (
<Button type="button" className="rounded-full" onClick={wizard.goToNextStep}>
Ga verder
</Button>
);
return (
<WizardShell
eyebrow={wizard.currentStep.eyebrow}
title={wizard.currentStep.title}
description={wizard.currentStep.description}
progressCurrent={wizard.currentStepIndex + 1}
progressTotal={wizard.steps.length}
topAction={topAction}
aside={aside}
backAction={backAction}
nextAction={nextAction}
>
<Card className="rounded-[1.5rem] border border-border/60 bg-background/80 py-0 shadow-none">
<CardHeader className="pb-0">
<CardTitle className="font-[family-name:var(--font-display)] text-2xl">
{wizard.currentStep.title}
</CardTitle>
</CardHeader>
<CardContent className="pb-6">
<CardDescription className="text-sm leading-7 text-muted-foreground">
{testStepDescriptions[wizard.currentStep.id]}
</CardDescription>
</CardContent>
</Card>
</WizardShell>
);
}

View file

@ -0,0 +1,22 @@
import { cn } from "@/lib/utils";
type WizardProgressProps = {
current: number;
total: number;
};
export function WizardProgress({ current, total }: WizardProgressProps) {
return (
<ol className="mt-8 flex gap-3" aria-label="Voortgang">
{Array.from({ length: total }, (_, index) => (
<li
key={index}
className={cn(
"h-2 flex-1 rounded-full transition-colors",
index < current ? "bg-primary-foreground/85" : "bg-white/15",
)}
/>
))}
</ol>
);
}

View file

@ -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 (
<div className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
<section className="rounded-[2rem] border border-primary/15 bg-primary p-7 text-primary-foreground shadow-[0_18px_60px_rgba(22,58,43,0.18)] sm:p-9">
{eyebrow ? (
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary-foreground/70">
{eyebrow}
</p>
) : null}
<h1 className="mt-4 font-[family-name:var(--font-display)] text-4xl leading-tight sm:text-5xl">
{title}
</h1>
{description ? (
<p className="mt-5 max-w-xl text-base leading-8 text-primary-foreground/85">
{description}
</p>
) : null}
{aside ? <div className="mt-10">{aside}</div> : null}
<WizardProgress current={progressCurrent} total={progressTotal} />
</section>
<section className="rounded-[2rem] border border-border/60 bg-card/90 p-6 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur sm:p-8">
{topAction ? (
<div className="mb-6 flex items-center justify-between gap-3">{topAction}</div>
) : null}
<div className="space-y-6">{children}</div>
{backAction || nextAction ? (
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
<div>{backAction}</div>
<div>{nextAction}</div>
</div>
) : null}
</section>
</div>
);
}

View file

@ -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

View file

@ -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<typeof MorningCheckInSchema>", "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_<tabelnaam>_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__":

111
docs/gpt-instructies.md Normal file
View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -22,6 +22,14 @@ const errorMessages: Record<string, AuthNotice> = {
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.",

View file

@ -0,0 +1,3 @@
export function isTestWizardEnabled() {
return process.env.NEXT_PUBLIC_ENABLE_TEST_WIZARD === "true";
}

View file

@ -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<string, StatusToast> = {
"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<string, StatusToast> = {
saved: {
variant: "success",
title: "Instellingen opgeslagen",
message: "Je voorkeuren zijn bijgewerkt.",
},
};
const settingsErrorToasts: Record<string, StatusToast> = {
"invalid-settings-input": {
variant: "error",
title: "Instellingen niet opgeslagen",
message: "Controleer je tijd, timezone en voorkeurvelden en probeer het opnieuw.",
},
};
const onboardingErrorToasts: Record<string, StatusToast> = {
"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,
};
}

26
lib/feedback/toast.ts Normal file
View file

@ -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 });
}

117
lib/forms/parse.ts Normal file
View file

@ -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<const TValue extends string>(
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;
}

View file

@ -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<OnboardingDraft>) {
const { displayName: nextDisplayName, ...preferencePatch } = patch;
if (nextDisplayName !== undefined) {
setDisplayName(nextDisplayName);
}
if (Object.keys(preferencePatch).length > 0) {
updatePreferenceDraft(preferencePatch as Partial<PreferenceDraft>);
}
}
return {
draft: {
displayName,
...preferenceDraft,
},
updateDraft,
};
}

View file

@ -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<PreferenceDraft>(() =>
buildPreferenceDraft(profileBundle),
);
function updateDraft(patch: Partial<PreferenceDraft>) {
setDraft((currentDraft) => ({ ...currentDraft, ...patch }));
}
return {
draft,
updateDraft,
};
}

6
lib/search-params.ts Normal file
View file

@ -0,0 +1,6 @@
export type PageSearchParams = Record<string, string | string[] | undefined>;
export function getParamValue(params: PageSearchParams, key: string): string | null {
const value = params[key];
return typeof value === "string" ? value : null;
}

19
lib/wizard/types.ts Normal file
View file

@ -0,0 +1,19 @@
export type WizardStepDefinition<TDraft> = {
id: string;
eyebrow?: string;
title: string;
description?: string;
canContinue?: (draft: TDraft) => boolean;
};
export type WizardFlowState<TDraft> = {
steps: WizardStepDefinition<TDraft>[];
currentStepIndex: number;
currentStep: WizardStepDefinition<TDraft>;
isFirstStep: boolean;
isLastStep: boolean;
canContinue: boolean;
goToNextStep: () => void;
goToPreviousStep: () => void;
goToStep: (stepId: string) => void;
};

View file

@ -0,0 +1,67 @@
"use client";
import { useMemo, useState } from "react";
import type { WizardFlowState, WizardStepDefinition } from "@/lib/wizard/types";
type UseWizardFlowOptions<TDraft> = {
steps: WizardStepDefinition<TDraft>[];
draft: TDraft;
initialStepId?: string;
};
export function useWizardFlow<TDraft>({
steps,
draft,
initialStepId,
}: UseWizardFlowOptions<TDraft>): WizardFlowState<TDraft> {
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,
};
}

22
package-lock.json generated
View file

@ -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",

View file

@ -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"
},