Add wizard flows, toast feedback, and strict form validation
This commit is contained in:
parent
96b26aa5d1
commit
000d2351c1
47 changed files with 2169 additions and 500 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
19
app/page.tsx
19
app/page.tsx
|
|
@ -1,6 +1,6 @@
|
|||
import Link from "next/link";
|
||||
import { signOutAction } from "@/app/auth-actions";
|
||||
import { AuthNotice } from "@/components/auth/auth-notice";
|
||||
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -9,8 +9,9 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { getAuthNotice } from "@/lib/auth/messages";
|
||||
import { getAuthState } from "@/lib/auth/session";
|
||||
import { getAuthStatusToast } from "@/lib/feedback/status-messages";
|
||||
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
@ -38,21 +39,13 @@ const releaseFocus = [
|
|||
];
|
||||
|
||||
type HomePageProps = {
|
||||
searchParams: Promise<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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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
31
app/wizard-test/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
components/feedback/status-toast-bridge.tsx
Normal file
53
components/feedback/status-toast-bridge.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
41
components/onboarding/onboarding-step-intro.tsx
Normal file
41
components/onboarding/onboarding-step-intro.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
components/onboarding/onboarding-step-preferences.tsx
Normal file
106
components/onboarding/onboarding-step-preferences.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
components/onboarding/onboarding-step-profile.tsx
Normal file
74
components/onboarding/onboarding-step-profile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
components/preferences/preference-hidden-fields.tsx
Normal file
31
components/preferences/preference-hidden-fields.tsx
Normal 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"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
42
components/ui/sonner.tsx
Normal 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 };
|
||||
146
components/wizard/test-wizard-flow.tsx
Normal file
146
components/wizard/test-wizard-flow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
components/wizard/wizard-progress.tsx
Normal file
22
components/wizard/wizard-progress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
components/wizard/wizard-shell.tsx
Normal file
63
components/wizard/wizard-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
111
docs/gpt-instructies.md
Normal 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)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
59
docs/inspannings-monitor-dagelijkse-deploy-checklist.md
Normal file
59
docs/inspannings-monitor-dagelijkse-deploy-checklist.md
Normal 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)
|
||||
80
docs/inspannings-monitor-ops-security-notitie.md
Normal file
80
docs/inspannings-monitor-ops-security-notitie.md
Normal 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)
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
3
lib/config/feature-flags.ts
Normal file
3
lib/config/feature-flags.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function isTestWizardEnabled() {
|
||||
return process.env.NEXT_PUBLIC_ENABLE_TEST_WIZARD === "true";
|
||||
}
|
||||
105
lib/feedback/status-messages.ts
Normal file
105
lib/feedback/status-messages.ts
Normal 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
26
lib/feedback/toast.ts
Normal 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
117
lib/forms/parse.ts
Normal 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;
|
||||
}
|
||||
40
lib/onboarding/use-onboarding-draft.ts
Normal file
40
lib/onboarding/use-onboarding-draft.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
40
lib/preferences/use-preferences-draft.ts
Normal file
40
lib/preferences/use-preferences-draft.ts
Normal 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
6
lib/search-params.ts
Normal 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
19
lib/wizard/types.ts
Normal 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;
|
||||
};
|
||||
67
lib/wizard/use-wizard-flow.ts
Normal file
67
lib/wizard/use-wizard-flow.ts
Normal 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
22
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue