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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue