Add wizard flows, toast feedback, and strict form validation

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

View file

@ -7,28 +7,76 @@ import {
getRequestOrigin,
sanitizeNextPath,
} from "@/lib/auth/navigation";
import {
assertEmail,
assertMinLength,
FormDataValidationError,
getOptionalString,
getRequiredString,
} from "@/lib/forms/parse";
import { createClient } from "@/lib/supabase/server";
import { hasSupabaseEnv } from "@/lib/supabase/config";
function getString(formData: FormData, key: string) {
const value = formData.get(key);
return typeof value === "string" ? value.trim() : "";
function parseSignInFormData(formData: FormData) {
const next = sanitizeNextPath(getOptionalString(formData, "next"));
const email = assertEmail(
getRequiredString(formData, "email", "missing-fields"),
"invalid-email",
);
const password = assertMinLength(
getRequiredString(formData, "password", "missing-fields"),
8,
"password-too-short",
);
return {
next,
email,
password,
};
}
function parseSignUpFormData(formData: FormData) {
const next = sanitizeNextPath(getOptionalString(formData, "next"));
const email = assertEmail(
getRequiredString(formData, "email", "missing-fields"),
"invalid-email",
);
const password = assertMinLength(
getRequiredString(formData, "password", "missing-fields"),
8,
"password-too-short",
);
return {
next,
email,
password,
};
}
export async function signInAction(formData: FormData) {
const next = sanitizeNextPath(getString(formData, "next"));
let next = sanitizeNextPath(getOptionalString(formData, "next"));
let email = "";
let password = "";
try {
const parsedFormData = parseSignInFormData(formData);
next = parsedFormData.next;
email = parsedFormData.email;
password = parsedFormData.password;
} catch (error) {
if (error instanceof FormDataValidationError) {
redirect(buildPathWithQuery("/login", { error: error.code, next }));
}
throw error;
}
if (!hasSupabaseEnv()) {
redirect(buildPathWithQuery("/login", { error: "auth-not-configured", next }));
}
const email = getString(formData, "email");
const password = getString(formData, "password");
if (!email || !password) {
redirect(buildPathWithQuery("/login", { error: "missing-fields", next }));
}
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({
email,
@ -50,7 +98,22 @@ export async function signInAction(formData: FormData) {
}
export async function signUpAction(formData: FormData) {
const next = sanitizeNextPath(getString(formData, "next"));
let next = sanitizeNextPath(getOptionalString(formData, "next"));
let email = "";
let password = "";
try {
const parsedFormData = parseSignUpFormData(formData);
next = parsedFormData.next;
email = parsedFormData.email;
password = parsedFormData.password;
} catch (error) {
if (error instanceof FormDataValidationError) {
redirect(buildPathWithQuery("/sign-up", { error: error.code, next }));
}
throw error;
}
if (!hasSupabaseEnv()) {
redirect(
@ -58,13 +121,6 @@ export async function signUpAction(formData: FormData) {
);
}
const email = getString(formData, "email");
const password = getString(formData, "password");
if (!email || !password) {
redirect(buildPathWithQuery("/sign-up", { error: "missing-fields", next }));
}
const supabase = await createClient();
const headerStore = await headers();
const origin = getRequestOrigin(headerStore);

View file

@ -1,7 +1,7 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { signOutAction } from "@/app/auth-actions";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Card,
@ -12,23 +12,18 @@ import {
} from "@/components/ui/card";
import { sanitizeNextPath } from "@/lib/auth/navigation";
import { getAuthState } from "@/lib/auth/session";
import { isTestWizardEnabled } from "@/lib/config/feature-flags";
import { getDashboardStatusToast } from "@/lib/feedback/status-messages";
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
import { cn } from "@/lib/utils";
export const dynamic = "force-dynamic";
type DashboardPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
searchParams: Promise<PageSearchParams>;
};
function getParamValue(
params: Record<string, string | string[] | undefined>,
key: string,
) {
const value = params[key];
return typeof value === "string" ? value : null;
}
function formatToggleState(value: boolean, enabledLabel = "Aan", disabledLabel = "Uit") {
return value ? enabledLabel : disabledLabel;
}
@ -37,18 +32,6 @@ function formatReminderTime(value: string | null) {
return value ? value.slice(0, 5) : "Nog niet ingesteld";
}
function getDashboardNotice(status: string | null) {
if (status === "onboarding-completed") {
return "Je onboarding is opgeslagen. Je basisinstellingen staan nu klaar.";
}
if (status === "onboarding-skipped") {
return "Je hebt de onboarding nu overgeslagen. Je kunt hem later alsnog afronden.";
}
return null;
}
export default async function DashboardPage({ searchParams }: DashboardPageProps) {
const authState = await getAuthState();
const resolvedSearchParams = await searchParams;
@ -68,7 +51,7 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
}
const { profile, settings } = profileBundle;
const notice = getDashboardNotice(getParamValue(resolvedSearchParams, "status"));
const statusToast = getDashboardStatusToast(getParamValue(resolvedSearchParams, "status"));
if (!profile.onboardingSeen) {
redirect("/onboarding");
@ -83,13 +66,7 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
return (
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] px-6 py-10 text-slate-900 sm:px-8">
<div className="mx-auto flex max-w-6xl flex-col gap-8">
{notice ? (
<Alert className="rounded-[1.5rem] border-emerald-200 bg-emerald-50 text-emerald-950 [&_svg]:text-emerald-700">
<AlertDescription className="leading-7 text-current">
{notice}
</AlertDescription>
</Alert>
) : null}
<StatusToastBridge toast={statusToast} />
<header className="flex flex-col gap-5 rounded-[2rem] border border-black/10 bg-white/75 p-6 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur sm:flex-row sm:items-start sm:justify-between sm:p-8">
<div>
@ -117,6 +94,17 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
>
Instellingen
</Link>
{isTestWizardEnabled() ? (
<Link
href="/wizard-test"
className={cn(
buttonVariants({ variant: "outline", size: "lg" }),
"h-11 rounded-full px-5",
)}
>
Test wizard
</Link>
) : null}
<Button type="submit" size="lg" className="h-11 rounded-full px-5">
Uitloggen
</Button>
@ -185,6 +173,22 @@ export default async function DashboardPage({ searchParams }: DashboardPageProps
</CardDescription>
</CardContent>
</Card>
{isTestWizardEnabled() ? (
<Card className="rounded-[1.75rem] border border-border/60 bg-card/90 py-0 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
<CardHeader className="pb-0">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
Wizard core
</p>
<CardTitle className="text-lg text-slate-900">Interne testwizard actief</CardTitle>
</CardHeader>
<CardContent className="pb-6">
<CardDescription className="text-sm leading-7 text-muted-foreground">
Gebruik deze alleen in development of preview om nieuwe multi-step flows te controleren.
</CardDescription>
</CardContent>
</Card>
) : null}
</section>
{!profile.onboardingCompleted ? (

View file

@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
export const metadata: Metadata = {
@ -14,7 +15,10 @@ export default function RootLayout({
}>) {
return (
<html lang="nl">
<body className="min-h-screen">{children}</body>
<body className="min-h-screen">
{children}
<Toaster position="top-right" richColors closeButton />
</body>
</html>
);
}

View file

@ -1,30 +1,23 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { AuthNotice } from "@/components/auth/auth-notice";
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
import { AuthPanel } from "@/components/auth/auth-panel";
import { signInAction } from "@/app/auth-actions";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getAuthNotice } from "@/lib/auth/messages";
import { buildPathWithQuery, sanitizeNextPath } from "@/lib/auth/navigation";
import { getAuthState } from "@/lib/auth/session";
import { getAuthStatusToast } from "@/lib/feedback/status-messages";
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
export const dynamic = "force-dynamic";
type LoginPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
searchParams: Promise<PageSearchParams>;
};
function getParamValue(
params: Record<string, string | string[] | undefined>,
key: string,
) {
const value = params[key];
return typeof value === "string" ? value : null;
}
export default async function LoginPage({ searchParams }: LoginPageProps) {
const authState = await getAuthState();
const resolvedSearchParams = await searchParams;
@ -34,7 +27,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
redirect(next);
}
const notice = getAuthNotice(
const statusToast = getAuthStatusToast(
getParamValue(resolvedSearchParams, "error"),
getParamValue(resolvedSearchParams, "status"),
);
@ -54,7 +47,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
</p>
}
>
<AuthNotice notice={notice} />
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
{!authState.isConfigured ? (
<Alert className="rounded-[1.5rem] border-sky-200 bg-sky-50 text-sky-950 [&_svg]:text-sky-700">

View file

@ -2,38 +2,80 @@
import { redirect } from "next/navigation";
import { buildPathWithQuery } from "@/lib/auth/navigation";
import {
FormDataValidationError,
getBooleanValue,
getEnumValue,
getOptionalString,
getOptionalTimeValue,
} from "@/lib/forms/parse";
import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options";
import {
completeOnboardingForCurrentUser,
markOnboardingSeenForCurrentUser,
} from "@/lib/profile/service";
import type { OnboardingSubmission } from "@/lib/profile/types";
function getString(formData: FormData, key: string) {
const value = formData.get(key);
return typeof value === "string" ? value.trim() : "";
}
function getBoolean(formData: FormData, key: string) {
return formData.get(key) === "true";
}
const ONBOARDING_TIMEZONE_VALUES = ONBOARDING_TIMEZONE_OPTIONS.map((option) => option.value);
function buildOnboardingSubmission(formData: FormData): OnboardingSubmission {
const morningReminderEnabled = getBooleanValue(
formData,
"morningReminderEnabled",
"invalid-onboarding-input",
);
const reminderTime = getOptionalTimeValue(
formData,
"morningReminderTime",
"invalid-onboarding-input",
);
return {
displayName: getString(formData, "displayName") || null,
timezone: getString(formData, "timezone"),
morningReminderEnabled: getBoolean(formData, "morningReminderEnabled"),
morningReminderTime: getString(formData, "morningReminderTime") || null,
reflectionReminderEnabled: getBoolean(formData, "reflectionReminderEnabled"),
showEnergyPoints: getBoolean(formData, "showEnergyPoints"),
displayName: getOptionalString(formData, "displayName") || null,
timezone: getEnumValue(
formData,
"timezone",
ONBOARDING_TIMEZONE_VALUES,
"invalid-onboarding-input",
),
morningReminderEnabled,
morningReminderTime: morningReminderEnabled ? reminderTime : null,
reflectionReminderEnabled: getBooleanValue(
formData,
"reflectionReminderEnabled",
"invalid-onboarding-input",
),
showEnergyPoints: getBooleanValue(
formData,
"showEnergyPoints",
"invalid-onboarding-input",
),
};
}
export async function completeOnboardingAction(formData: FormData) {
await completeOnboardingForCurrentUser(buildOnboardingSubmission(formData));
export async function completeOnboardingAction(
_previousState: null,
formData: FormData,
): Promise<null> {
try {
await completeOnboardingForCurrentUser(buildOnboardingSubmission(formData));
} catch (error) {
if (error instanceof FormDataValidationError) {
redirect(buildPathWithQuery("/onboarding", { error: error.code }));
}
throw error;
}
redirect(buildPathWithQuery("/dashboard", { status: "onboarding-completed" }));
return null;
}
export async function skipOnboardingAction() {
export async function skipOnboardingAction(
_previousState: null,
_formData: FormData,
): Promise<null> {
await markOnboardingSeenForCurrentUser();
redirect(buildPathWithQuery("/dashboard", { status: "onboarding-skipped" }));
return null;
}

View file

@ -1,13 +1,21 @@
import { redirect } from "next/navigation";
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
import { sanitizeNextPath } from "@/lib/auth/navigation";
import { getAuthState } from "@/lib/auth/session";
import { getOnboardingStatusToast } from "@/lib/feedback/status-messages";
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
export const dynamic = "force-dynamic";
export default async function OnboardingPage() {
type OnboardingPageProps = {
searchParams: Promise<PageSearchParams>;
};
export default async function OnboardingPage({ searchParams }: OnboardingPageProps) {
const authState = await getAuthState();
const resolvedSearchParams = await searchParams;
if (!authState.isConfigured) {
redirect("/login?error=auth-not-configured");
@ -27,9 +35,15 @@ export default async function OnboardingPage() {
redirect("/dashboard");
}
const statusToast = getOnboardingStatusToast(
getParamValue(resolvedSearchParams, "error"),
getParamValue(resolvedSearchParams, "status"),
);
return (
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] px-6 py-10 text-slate-900 sm:px-8">
<div className="mx-auto flex max-w-6xl flex-col gap-8">
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
<OnboardingFlow profileBundle={profileBundle} />
</div>
</main>

View file

@ -1,6 +1,6 @@
import Link from "next/link";
import { signOutAction } from "@/app/auth-actions";
import { AuthNotice } from "@/components/auth/auth-notice";
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Card,
@ -9,8 +9,9 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { getAuthNotice } from "@/lib/auth/messages";
import { getAuthState } from "@/lib/auth/session";
import { getAuthStatusToast } from "@/lib/feedback/status-messages";
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
import { cn } from "@/lib/utils";
export const dynamic = "force-dynamic";
@ -38,21 +39,13 @@ const releaseFocus = [
];
type HomePageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
searchParams: Promise<PageSearchParams>;
};
function getParamValue(
params: Record<string, string | string[] | undefined>,
key: string,
) {
const value = params[key];
return typeof value === "string" ? value : null;
}
export default async function Home({ searchParams }: HomePageProps) {
const authState = await getAuthState();
const resolvedSearchParams = await searchParams;
const notice = getAuthNotice(
const statusToast = getAuthStatusToast(
getParamValue(resolvedSearchParams, "error"),
getParamValue(resolvedSearchParams, "status"),
);
@ -118,7 +111,7 @@ export default async function Home({ searchParams }: HomePageProps) {
</div>
</header>
<AuthNotice notice={notice} />
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
<section className="grid gap-6 lg:grid-cols-[1.35fr_0.95fr]">
<Card className="rounded-[2rem] border border-border/60 bg-card/90 py-0 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur">

View file

@ -2,30 +2,68 @@
import { redirect } from "next/navigation";
import { buildPathWithQuery } from "@/lib/auth/navigation";
import {
FormDataValidationError,
getBooleanValue,
getEnumValue,
getOptionalTimeValue,
} from "@/lib/forms/parse";
import { ONBOARDING_TIMEZONE_OPTIONS } from "@/lib/onboarding/options";
import { saveSettingsForCurrentUser } from "@/lib/profile/service";
import type { SettingsSubmission } from "@/lib/profile/types";
function getString(formData: FormData, key: string) {
const value = formData.get(key);
return typeof value === "string" ? value.trim() : "";
}
function getBoolean(formData: FormData, key: string) {
return formData.get(key) === "true";
}
const LOCALE_VALUES = ["nl-NL"] as const;
const ONBOARDING_TIMEZONE_VALUES = ONBOARDING_TIMEZONE_OPTIONS.map((option) => option.value);
function buildSettingsSubmission(formData: FormData): SettingsSubmission {
const morningReminderEnabled = getBooleanValue(
formData,
"morningReminderEnabled",
"invalid-settings-input",
);
const reminderTime = getOptionalTimeValue(
formData,
"morningReminderTime",
"invalid-settings-input",
);
return {
locale: getString(formData, "locale"),
timezone: getString(formData, "timezone"),
morningReminderEnabled: getBoolean(formData, "morningReminderEnabled"),
morningReminderTime: getString(formData, "morningReminderTime") || null,
reflectionReminderEnabled: getBoolean(formData, "reflectionReminderEnabled"),
showEnergyPoints: getBoolean(formData, "showEnergyPoints"),
locale: getEnumValue(formData, "locale", LOCALE_VALUES, "invalid-settings-input"),
timezone: getEnumValue(
formData,
"timezone",
ONBOARDING_TIMEZONE_VALUES,
"invalid-settings-input",
),
morningReminderEnabled,
morningReminderTime: morningReminderEnabled ? reminderTime : null,
reflectionReminderEnabled: getBooleanValue(
formData,
"reflectionReminderEnabled",
"invalid-settings-input",
),
showEnergyPoints: getBooleanValue(
formData,
"showEnergyPoints",
"invalid-settings-input",
),
};
}
export async function saveSettingsAction(formData: FormData) {
await saveSettingsForCurrentUser(buildSettingsSubmission(formData));
export async function saveSettingsAction(
_previousState: null,
formData: FormData,
): Promise<null> {
try {
await saveSettingsForCurrentUser(buildSettingsSubmission(formData));
} catch (error) {
if (error instanceof FormDataValidationError) {
redirect(buildPathWithQuery("/settings", { error: error.code }));
}
throw error;
}
redirect(buildPathWithQuery("/settings", { status: "saved" }));
return null;
}

View file

@ -1,8 +1,8 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { signOutAction } from "@/app/auth-actions";
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
import { SettingsForm } from "@/components/settings/settings-form";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Card,
@ -13,31 +13,17 @@ import {
} from "@/components/ui/card";
import { sanitizeNextPath } from "@/lib/auth/navigation";
import { getAuthState } from "@/lib/auth/session";
import { getSettingsStatusToast } from "@/lib/feedback/status-messages";
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
import { cn } from "@/lib/utils";
export const dynamic = "force-dynamic";
type SettingsPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
searchParams: Promise<PageSearchParams>;
};
function getParamValue(
params: Record<string, string | string[] | undefined>,
key: string,
) {
const value = params[key];
return typeof value === "string" ? value : null;
}
function getSettingsNotice(status: string | null) {
if (status === "saved") {
return "Je instellingen zijn opgeslagen.";
}
return null;
}
export default async function SettingsPage({ searchParams }: SettingsPageProps) {
const authState = await getAuthState();
const resolvedSearchParams = await searchParams;
@ -60,7 +46,10 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps)
redirect("/onboarding");
}
const notice = getSettingsNotice(getParamValue(resolvedSearchParams, "status"));
const statusToast = getSettingsStatusToast(
getParamValue(resolvedSearchParams, "error"),
getParamValue(resolvedSearchParams, "status"),
);
const profileTitle =
profileBundle.profile.displayName ??
profileBundle.profile.email ??
@ -70,6 +59,8 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps)
return (
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] px-6 py-10 text-slate-900 sm:px-8">
<div className="mx-auto flex max-w-6xl flex-col gap-8">
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
<header className="flex flex-col gap-5 rounded-[2rem] border border-black/10 bg-white/75 p-6 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur sm:flex-row sm:items-start sm:justify-between sm:p-8">
<div>
<div className="flex flex-wrap items-center gap-3 text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
@ -106,14 +97,6 @@ export default async function SettingsPage({ searchParams }: SettingsPageProps)
</div>
</header>
{notice ? (
<Alert className="rounded-[1.5rem] border-emerald-200 bg-emerald-50 text-emerald-950 [&_svg]:text-emerald-700">
<AlertDescription className="leading-7 text-current">
{notice}
</AlertDescription>
</Alert>
) : null}
<section className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
<SettingsForm profileBundle={profileBundle} />

View file

@ -1,30 +1,23 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { AuthNotice } from "@/components/auth/auth-notice";
import { StatusToastBridge } from "@/components/feedback/status-toast-bridge";
import { AuthPanel } from "@/components/auth/auth-panel";
import { signUpAction } from "@/app/auth-actions";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getAuthNotice } from "@/lib/auth/messages";
import { buildPathWithQuery, sanitizeNextPath } from "@/lib/auth/navigation";
import { getAuthState } from "@/lib/auth/session";
import { getAuthStatusToast } from "@/lib/feedback/status-messages";
import { getParamValue, type PageSearchParams } from "@/lib/search-params";
export const dynamic = "force-dynamic";
type SignUpPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
searchParams: Promise<PageSearchParams>;
};
function getParamValue(
params: Record<string, string | string[] | undefined>,
key: string,
) {
const value = params[key];
return typeof value === "string" ? value : null;
}
export default async function SignUpPage({ searchParams }: SignUpPageProps) {
const authState = await getAuthState();
const resolvedSearchParams = await searchParams;
@ -34,7 +27,7 @@ export default async function SignUpPage({ searchParams }: SignUpPageProps) {
redirect(next);
}
const notice = getAuthNotice(
const statusToast = getAuthStatusToast(
getParamValue(resolvedSearchParams, "error"),
getParamValue(resolvedSearchParams, "status"),
);
@ -54,7 +47,7 @@ export default async function SignUpPage({ searchParams }: SignUpPageProps) {
</p>
}
>
<AuthNotice notice={notice} />
<StatusToastBridge toast={statusToast} paramKeys={["error", "status"]} />
{!authState.isConfigured ? (
<Alert className="rounded-[1.5rem] border-sky-200 bg-sky-50 text-sky-950 [&_svg]:text-sky-700">

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

@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import { TestWizardFlow } from "@/components/wizard/test-wizard-flow";
import { sanitizeNextPath } from "@/lib/auth/navigation";
import { getAuthState } from "@/lib/auth/session";
import { isTestWizardEnabled } from "@/lib/config/feature-flags";
export const dynamic = "force-dynamic";
export default async function WizardTestPage() {
const authState = await getAuthState();
if (!isTestWizardEnabled()) {
redirect("/dashboard");
}
if (!authState.isConfigured) {
redirect("/login?error=auth-not-configured");
}
if (!authState.isAuthenticated) {
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/wizard-test"))}`);
}
return (
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(167,201,87,0.22),_transparent_32%),linear-gradient(180deg,_#f5f4ee_0%,_#eef2e6_100%)] px-6 py-10 text-slate-900 sm:px-8">
<div className="mx-auto flex max-w-6xl flex-col gap-8">
<TestWizardFlow />
</div>
</main>
);
}