feat: initial commit

This commit is contained in:
Janpeter Visser 2026-04-18 14:18:26 +02:00
commit 7d443a004a
76 changed files with 15704 additions and 0 deletions

101
app/auth-actions.ts Normal file
View file

@ -0,0 +1,101 @@
"use server";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import {
buildPathWithQuery,
getRequestOrigin,
sanitizeNextPath,
} from "@/lib/auth/navigation";
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() : "";
}
export async function signInAction(formData: FormData) {
const next = sanitizeNextPath(getString(formData, "next"));
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,
password,
});
if (error) {
const normalizedMessage = error.message.toLowerCase();
const errorParam =
error.code === "email_not_confirmed" ||
normalizedMessage.includes("email not confirmed")
? "email-not-confirmed"
: "invalid-credentials";
redirect(buildPathWithQuery("/login", { error: errorParam, next }));
}
redirect(next);
}
export async function signUpAction(formData: FormData) {
const next = sanitizeNextPath(getString(formData, "next"));
if (!hasSupabaseEnv()) {
redirect(
buildPathWithQuery("/sign-up", { error: "auth-not-configured", next }),
);
}
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);
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${origin}/auth/confirm?next=${encodeURIComponent(next)}`,
},
});
if (error) {
const normalizedMessage = error.message.toLowerCase();
const errorParam =
error.code === "over_email_send_rate_limit" ||
normalizedMessage.includes("rate limit")
? "signup-rate-limited"
: "signup-failed";
redirect(buildPathWithQuery("/sign-up", { error: errorParam, next }));
}
redirect(buildPathWithQuery("/sign-up", { status: "check-email", next }));
}
export async function signOutAction() {
if (hasSupabaseEnv()) {
const supabase = await createClient();
await supabase.auth.signOut();
}
redirect(buildPathWithQuery("/", { status: "signed-out" }));
}

34
app/auth/confirm/route.ts Normal file
View file

@ -0,0 +1,34 @@
import { type EmailOtpType } from "@supabase/supabase-js";
import { type NextRequest, NextResponse } from "next/server";
import { buildPathWithQuery, sanitizeNextPath } from "@/lib/auth/navigation";
import { hasSupabaseEnv } from "@/lib/supabase/config";
import { createClient } from "@/lib/supabase/server";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const tokenHash = searchParams.get("token_hash");
const type = searchParams.get("type") as EmailOtpType | null;
const next = sanitizeNextPath(searchParams.get("next"));
if (!hasSupabaseEnv()) {
return NextResponse.redirect(
new URL(buildPathWithQuery("/login", { error: "auth-not-configured" }), request.url),
);
}
if (tokenHash && type) {
const supabase = await createClient();
const { error } = await supabase.auth.verifyOtp({
type,
token_hash: tokenHash,
});
if (!error) {
return NextResponse.redirect(new URL(next, request.url));
}
}
return NextResponse.redirect(
new URL(buildPathWithQuery("/login", { error: "verification-failed" }), request.url),
);
}

204
app/dashboard/page.tsx Normal file
View file

@ -0,0 +1,204 @@
import { redirect } from "next/navigation";
import { signOutAction } from "@/app/auth-actions";
import { sanitizeNextPath } from "@/lib/auth/navigation";
import { getAuthState } from "@/lib/auth/session";
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
import Link from "next/link";
export const dynamic = "force-dynamic";
type DashboardPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
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;
}
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;
if (!authState.isConfigured) {
redirect("/login?error=auth-not-configured");
}
if (!authState.isAuthenticated) {
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/dashboard"))}`);
}
const profileBundle = await getProfileBundleForCurrentUser();
if (!profileBundle) {
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/dashboard"))}`);
}
const { profile, settings } = profileBundle;
const notice = getDashboardNotice(getParamValue(resolvedSearchParams, "status"));
if (!profile.onboardingSeen) {
redirect("/onboarding");
}
const profileTitle = profile.displayName ?? profile.email ?? authState.email ?? "Ingelogde gebruiker";
const onboardingState = profile.onboardingCompleted ? "Afgerond" : "Nog niet afgerond";
const morningReminderState = settings.morningReminderEnabled
? `Aan om ${formatReminderTime(settings.morningReminderTime)}`
: "Uit";
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 ? (
<div className="rounded-[1.5rem] border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm leading-7 text-emerald-900">
{notice}
</div>
) : null}
<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>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
Protected route
</p>
<h1 className="mt-3 font-[family-name:var(--font-display)] text-4xl leading-tight">
Dashboard placeholder voor release 1
</h1>
<p className="mt-4 max-w-2xl text-base leading-8 text-slate-700">
Je sessie is server-side gevalideerd en het minimale profielbundle is
nu beschikbaar. Daarmee staat de fundering voor onboarding, settings
en de eerste energieflows klaar.
</p>
</div>
<form action={signOutAction}>
<div className="flex flex-wrap items-center gap-3">
<Link
href="/settings"
className="inline-flex rounded-full border border-black/10 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:-translate-y-0.5 hover:text-slate-950"
>
Instellingen
</Link>
<button
type="submit"
className="inline-flex rounded-full border border-emerald-900/15 bg-emerald-950 px-5 py-3 text-sm font-semibold text-emerald-50 transition hover:-translate-y-0.5 hover:bg-emerald-900"
>
Uitloggen
</button>
</div>
</form>
</header>
<section className="grid gap-5 md:grid-cols-3">
<article className="rounded-[1.75rem] border border-black/10 bg-white/75 p-6 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
Auth
</p>
<p className="mt-3 text-lg font-semibold text-slate-900">
Cookie-based sessie actief
</p>
<p className="mt-3 text-sm leading-7 text-slate-700">
Gebruiker-ID `{authState.userId}` is server-side gevalideerd via Supabase SSR-auth.
</p>
</article>
<article className="rounded-[1.75rem] border border-black/10 bg-white/75 p-6 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
Profiel
</p>
<p className="mt-3 text-lg font-semibold text-slate-900">
{profileTitle}
</p>
<p className="mt-3 text-sm leading-7 text-slate-700">
Taal `{profile.locale}` en timezone `{profile.timezone}` staan nu per
gebruiker opgeslagen.
</p>
</article>
<article className="rounded-[1.75rem] border border-black/10 bg-white/75 p-6 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
Onboarding
</p>
<p className="mt-3 text-lg font-semibold text-slate-900">
{onboardingState}
</p>
<p className="mt-3 text-sm leading-7 text-slate-700">
Nieuwe accounts starten bewust zonder afgeronde onboarding, zodat
`ST-103` straks een duidelijke eerste flow kan aansturen.
</p>
</article>
<article className="rounded-[1.75rem] border border-black/10 bg-white/75 p-6 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
Instellingen
</p>
<p className="mt-3 text-lg font-semibold text-slate-900">
Punten {formatToggleState(settings.showEnergyPoints, "zichtbaar", "verborgen")}
</p>
<p className="mt-3 text-sm leading-7 text-slate-700">
Ochtendreminder: {morningReminderState}. Reflectieprompts:{" "}
{formatToggleState(settings.reflectionReminderEnabled)}.
</p>
</article>
</section>
{!profile.onboardingCompleted ? (
<section className="flex flex-col gap-4 rounded-[1.75rem] border border-amber-900/15 bg-amber-50 px-6 py-5 text-sm leading-7 text-amber-950 shadow-[0_12px_40px_rgba(146,64,14,0.08)] sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-semibold">Je onboarding is nog niet afgerond.</p>
<p className="mt-1 max-w-2xl text-amber-900">
Je kunt de korte flow later alsnog afronden om je basisinstellingen
en eerste voorkeuren vast te leggen.
</p>
</div>
<Link
href="/onboarding"
className="inline-flex shrink-0 rounded-full bg-amber-950 px-5 py-3 text-sm font-semibold text-amber-50 transition hover:-translate-y-0.5 hover:bg-amber-900"
>
Rond onboarding af
</Link>
</section>
) : (
<section className="flex flex-col gap-4 rounded-[1.75rem] border border-emerald-950/10 bg-emerald-950 px-6 py-5 text-sm leading-7 text-emerald-50 shadow-[0_12px_40px_rgba(6,78,59,0.18)] sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-semibold">Je instellingen kun je nu ook los beheren.</p>
<p className="mt-1 max-w-2xl text-emerald-100/85">
`ST-104` staat nu klaar als aparte route, zodat je reminders,
timezone en zichtbaarheid van punten later zelfstandig kunt aanpassen.
</p>
</div>
<Link
href="/settings"
className="inline-flex shrink-0 rounded-full bg-white px-5 py-3 text-sm font-semibold text-emerald-950 transition hover:-translate-y-0.5 hover:bg-emerald-50"
>
Open instellingen
</Link>
</section>
)}
</div>
</main>
);
}

153
app/globals.css Normal file
View file

@ -0,0 +1,153 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
:root {
--font-display: "Iowan Old Style", "Palatino Linotype", "URW Palladio L",
Palatino, Georgia, serif;
--font-body: "Inter", "Aptos", "Segoe UI", "Helvetica Neue", Arial,
sans-serif;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
* {
box-sizing: border-box;
}
html {
background: var(--background);
color: var(--foreground);
}
body {
margin: 0;
min-height: 100vh;
font-family: var(--font-body), sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
@theme inline {
--font-heading: var(--font-sans);
--font-sans: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

24
app/layout.tsx Normal file
View file

@ -0,0 +1,24 @@
import type { Metadata } from "next";
import "./globals.css";
import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
export const metadata: Metadata = {
title: "Inspannings Monitor",
description:
"Wellness-first app voor energieplanning, zelfreflectie en een rustige plan-doe-evalueer flow.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="nl" className={cn("font-sans", geist.variable)}>
<body>{children}</body>
</html>
);
}

96
app/login/page.tsx Normal file
View file

@ -0,0 +1,96 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { AuthNotice } from "@/components/auth/auth-notice";
import { AuthPanel } from "@/components/auth/auth-panel";
import { signInAction } from "@/app/auth-actions";
import { getAuthNotice } from "@/lib/auth/messages";
import { buildPathWithQuery, sanitizeNextPath } from "@/lib/auth/navigation";
import { getAuthState } from "@/lib/auth/session";
export const dynamic = "force-dynamic";
type LoginPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
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;
const next = sanitizeNextPath(getParamValue(resolvedSearchParams, "next"));
if (authState.isAuthenticated) {
redirect(next);
}
const notice = getAuthNotice(
getParamValue(resolvedSearchParams, "error"),
getParamValue(resolvedSearchParams, "status"),
);
const signUpHref = buildPathWithQuery("/sign-up", { next });
return (
<AuthPanel
eyebrow="Inloggen"
title="Welkom terug"
description="Log in om je dashboard te openen en je wellness-first flow verder op te bouwen."
footer={
<p>
Nog geen account?{" "}
<Link href={signUpHref} className="font-semibold text-emerald-900">
Maak er een aan
</Link>
</p>
}
>
<AuthNotice notice={notice} />
{!authState.isConfigured ? (
<div className="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-4 text-sm leading-7 text-sky-900">
Voeg eerst je Supabase-gegevens toe in `.env.local` op basis van `.env.example`.
</div>
) : (
<form action={signInAction} className="space-y-4">
<input type="hidden" name="next" value={next} />
<label className="block text-sm font-medium text-slate-800">
E-mailadres
<input
className="mt-2 w-full rounded-2xl border border-black/10 bg-stone-50 px-4 py-3 text-base outline-none transition focus:border-emerald-600 focus:bg-white"
type="email"
name="email"
autoComplete="email"
required
/>
</label>
<label className="block text-sm font-medium text-slate-800">
Wachtwoord
<input
className="mt-2 w-full rounded-2xl border border-black/10 bg-stone-50 px-4 py-3 text-base outline-none transition focus:border-emerald-600 focus:bg-white"
type="password"
name="password"
autoComplete="current-password"
minLength={8}
required
/>
</label>
<button
type="submit"
className="inline-flex w-full items-center justify-center rounded-2xl bg-emerald-950 px-5 py-3 text-sm font-semibold text-emerald-50 transition hover:-translate-y-0.5 hover:bg-emerald-900"
>
Inloggen
</button>
</form>
)}
</AuthPanel>
);
}

39
app/onboarding/actions.ts Normal file
View file

@ -0,0 +1,39 @@
"use server";
import { redirect } from "next/navigation";
import { buildPathWithQuery } from "@/lib/auth/navigation";
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";
}
function buildOnboardingSubmission(formData: FormData): OnboardingSubmission {
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"),
};
}
export async function completeOnboardingAction(formData: FormData) {
await completeOnboardingForCurrentUser(buildOnboardingSubmission(formData));
redirect(buildPathWithQuery("/dashboard", { status: "onboarding-completed" }));
}
export async function skipOnboardingAction() {
await markOnboardingSeenForCurrentUser();
redirect(buildPathWithQuery("/dashboard", { status: "onboarding-skipped" }));
}

37
app/onboarding/page.tsx Normal file
View file

@ -0,0 +1,37 @@
import { redirect } from "next/navigation";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
import { sanitizeNextPath } from "@/lib/auth/navigation";
import { getAuthState } from "@/lib/auth/session";
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
export const dynamic = "force-dynamic";
export default async function OnboardingPage() {
const authState = await getAuthState();
if (!authState.isConfigured) {
redirect("/login?error=auth-not-configured");
}
if (!authState.isAuthenticated) {
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/onboarding"))}`);
}
const profileBundle = await getProfileBundleForCurrentUser();
if (!profileBundle) {
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/onboarding"))}`);
}
if (profileBundle.profile.onboardingCompleted) {
redirect("/dashboard");
}
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">
<OnboardingFlow profileBundle={profileBundle} />
</div>
</main>
);
}

196
app/page.tsx Normal file
View file

@ -0,0 +1,196 @@
import Link from "next/link";
import { signOutAction } from "@/app/auth-actions";
import { getAuthNotice } from "@/lib/auth/messages";
import { getAuthState } from "@/lib/auth/session";
export const dynamic = "force-dynamic";
const headerActionClassName =
"shrink-0 whitespace-nowrap rounded-full border border-black/10 bg-white/70 px-4 py-2 text-sm font-medium text-slate-900 shadow-sm transition hover:-translate-y-0.5";
const loopSteps = [
{
title: "Check-in",
copy: "Start de dag met een korte energiescore en slaapkwaliteit, zonder overbodige frictie.",
},
{
title: "Plannen",
copy: "Verdeel activiteiten over de dag met een licht energiebudget en duidelijke prioriteiten.",
},
{
title: "Evalueren",
copy: "Kijk rustig terug op wat wel, niet of aangepast is gelukt, zonder medische claims of oordeel.",
},
];
const releaseFocus = [
"Alleen individuele gebruikers in release 1",
"Wellness/self-management positionering",
"Geen sharing, AI of medische workflows in de MVP",
"Vercel + Supabase als technische basis",
];
type HomePageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
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(
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%)] text-slate-900">
<div className="mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-10 sm:px-8 lg:px-10">
<header className="mb-10 flex items-center justify-between border-b border-black/10 pb-5">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-600">
Inspannings Monitor
</p>
<h1 className="font-[family-name:var(--font-display)] text-3xl leading-tight sm:text-5xl">
Rustige basis voor een wellness-first MVP
</h1>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
{authState.isConfigured ? (
authState.isAuthenticated ? (
<>
<Link
href="/dashboard"
className={headerActionClassName}
>
Naar dashboard
</Link>
<form action={signOutAction}>
<button
type="submit"
className={headerActionClassName}
>
Uitloggen
</button>
</form>
</>
) : (
<>
<Link
href="/login"
className={headerActionClassName}
>
Inloggen
</Link>
<Link
href="/sign-up"
className={headerActionClassName}
>
Account aanmaken
</Link>
</>
)
) : (
<span className="rounded-full border border-amber-900/15 bg-amber-50 px-4 py-2 text-sm font-medium text-amber-900 shadow-sm">
Supabase nog niet geconfigureerd
</span>
)}
</div>
</header>
{notice ? (
<div className="mb-6 rounded-[1.5rem] border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm leading-7 text-emerald-900">
{notice.text}
</div>
) : null}
<section className="grid gap-6 lg:grid-cols-[1.35fr_0.95fr]">
<article className="rounded-[2rem] border border-black/10 bg-white/70 p-6 shadow-[0_18px_60px_rgba(71,85,105,0.12)] backdrop-blur sm:p-8">
<p className="mb-4 max-w-2xl text-lg leading-8 text-slate-700">
De projectbasis staat nu, inclusief de eerste auth-laag via Supabase.
Release 1 blijft bewust smal: publieke landing, aparte login/signup
routes en een eerste protected dashboard als basis voor de volgende stories.
</p>
<div className="grid gap-4 md:grid-cols-3">
{loopSteps.map((step, index) => (
<section
key={step.title}
className="rounded-[1.5rem] border border-black/8 bg-stone-50 p-5"
>
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.22em] text-slate-500">
Stap {index + 1}
</p>
<h2 className="mb-2 font-[family-name:var(--font-display)] text-2xl">
{step.title}
</h2>
<p className="text-sm leading-7 text-slate-700">{step.copy}</p>
</section>
))}
</div>
</article>
<aside className="rounded-[2rem] border border-emerald-950/10 bg-emerald-950 px-6 py-7 text-emerald-50 shadow-[0_18px_60px_rgba(6,78,59,0.18)] sm:px-8">
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.24em] text-emerald-200/80">
Release 1 focus
</p>
<ul className="space-y-3">
{releaseFocus.map((item) => (
<li
key={item}
className="rounded-2xl border border-white/10 bg-white/8 px-4 py-3 text-sm leading-7"
>
{item}
</li>
))}
</ul>
{authState.isConfigured ? (
<p className="mt-5 text-sm leading-7 text-emerald-100/80">
Auth is ingericht met e-mail, wachtwoord en verplichte e-mailverificatie.
</p>
) : (
<p className="mt-5 text-sm leading-7 text-emerald-100/80">
Voeg `.env.local` toe om login, signup en protected routes lokaal te activeren.
</p>
)}
</aside>
</section>
<section className="mt-8 grid gap-5 rounded-[2rem] border border-black/10 bg-white/60 p-6 shadow-[0_10px_45px_rgba(71,85,105,0.08)] backdrop-blur sm:grid-cols-2 lg:grid-cols-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
Volgende story
</p>
<p className="mt-2 font-semibold text-slate-900">
ST-201 Ochtendcheck-in
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
Doelgroep
</p>
<p className="mt-2 font-semibold text-slate-900">Volwassen individuele gebruikers</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
Positionering
</p>
<p className="mt-2 font-semibold text-slate-900">Wellness / self-management</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
Status
</p>
<p className="mt-2 font-semibold text-slate-900">Auth, onboarding en settings actief</p>
</div>
</section>
</div>
</main>
);
}

31
app/settings/actions.ts Normal file
View file

@ -0,0 +1,31 @@
"use server";
import { redirect } from "next/navigation";
import { buildPathWithQuery } from "@/lib/auth/navigation";
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";
}
function buildSettingsSubmission(formData: FormData): SettingsSubmission {
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"),
};
}
export async function saveSettingsAction(formData: FormData) {
await saveSettingsForCurrentUser(buildSettingsSubmission(formData));
redirect(buildPathWithQuery("/settings", { status: "saved" }));
}

138
app/settings/page.tsx Normal file
View file

@ -0,0 +1,138 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { signOutAction } from "@/app/auth-actions";
import { SettingsForm } from "@/components/settings/settings-form";
import { sanitizeNextPath } from "@/lib/auth/navigation";
import { getAuthState } from "@/lib/auth/session";
import { getProfileBundleForCurrentUser } from "@/lib/profile/service";
export const dynamic = "force-dynamic";
type SettingsPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
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;
if (!authState.isConfigured) {
redirect("/login?error=auth-not-configured");
}
if (!authState.isAuthenticated) {
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/settings"))}`);
}
const profileBundle = await getProfileBundleForCurrentUser();
if (!profileBundle) {
redirect(`/login?next=${encodeURIComponent(sanitizeNextPath("/settings"))}`);
}
if (!profileBundle.profile.onboardingSeen) {
redirect("/onboarding");
}
const notice = getSettingsNotice(getParamValue(resolvedSearchParams, "status"));
const profileTitle =
profileBundle.profile.displayName ??
profileBundle.profile.email ??
authState.email ??
"Ingelogde gebruiker";
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">
<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">
<Link href="/dashboard" className="transition hover:text-slate-900">
Dashboard
</Link>
<span>/</span>
<span>Instellingen</span>
</div>
<h1 className="mt-3 font-[family-name:var(--font-display)] text-4xl leading-tight">
Instellingen
</h1>
<p className="mt-4 max-w-2xl text-base leading-8 text-slate-700">
Pas je basisvoorkeuren rustig aan. Alles blijft beperkt tot jouw eigen
account en de wellness-first scope van release 1.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<Link
href="/dashboard"
className="rounded-full border border-black/10 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:-translate-y-0.5 hover:text-slate-950"
>
Terug naar dashboard
</Link>
<form action={signOutAction}>
<button
type="submit"
className="rounded-full bg-emerald-950 px-5 py-3 text-sm font-semibold text-emerald-50 transition hover:-translate-y-0.5 hover:bg-emerald-900"
>
Uitloggen
</button>
</form>
</div>
</header>
{notice ? (
<div className="rounded-[1.5rem] border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm leading-7 text-emerald-900">
{notice}
</div>
) : null}
<section className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
<SettingsForm profileBundle={profileBundle} />
<aside className="space-y-5">
<article className="rounded-[1.75rem] border border-black/10 bg-white/75 p-6 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
Account
</p>
<p className="mt-3 text-lg font-semibold text-slate-900">
{profileTitle}
</p>
<p className="mt-3 text-sm leading-7 text-slate-700">
E-mailadres: {profileBundle.profile.email ?? authState.email ?? "Onbekend"}
</p>
</article>
<article className="rounded-[1.75rem] border border-black/10 bg-white/75 p-6 shadow-[0_12px_40px_rgba(71,85,105,0.08)]">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
Huidige status
</p>
<p className="mt-3 text-lg font-semibold text-slate-900">
Onboarding {profileBundle.profile.onboardingCompleted ? "afgerond" : "later afronden"}
</p>
<p className="mt-3 text-sm leading-7 text-slate-700">
Je kunt later altijd terug naar onboarding of direct verder bouwen op
deze voorkeuren in de dagflow.
</p>
</article>
</aside>
</section>
</div>
</main>
);
}

96
app/sign-up/page.tsx Normal file
View file

@ -0,0 +1,96 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { AuthNotice } from "@/components/auth/auth-notice";
import { AuthPanel } from "@/components/auth/auth-panel";
import { signUpAction } from "@/app/auth-actions";
import { getAuthNotice } from "@/lib/auth/messages";
import { buildPathWithQuery, sanitizeNextPath } from "@/lib/auth/navigation";
import { getAuthState } from "@/lib/auth/session";
export const dynamic = "force-dynamic";
type SignUpPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
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;
const next = sanitizeNextPath(getParamValue(resolvedSearchParams, "next"));
if (authState.isAuthenticated) {
redirect(next);
}
const notice = getAuthNotice(
getParamValue(resolvedSearchParams, "error"),
getParamValue(resolvedSearchParams, "status"),
);
const loginHref = buildPathWithQuery("/login", { next });
return (
<AuthPanel
eyebrow="Account aanmaken"
title="Maak je eerste account aan"
description="Release 1 gebruikt e-mail en wachtwoord als eenvoudige basis. Na registratie bevestig je eerst je e-mailadres."
footer={
<p>
Heb je al een account?{" "}
<Link href={loginHref} className="font-semibold text-emerald-900">
Log dan in
</Link>
</p>
}
>
<AuthNotice notice={notice} />
{!authState.isConfigured ? (
<div className="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-4 text-sm leading-7 text-sky-900">
Voeg eerst je Supabase-gegevens toe in `.env.local` op basis van `.env.example`.
</div>
) : (
<form action={signUpAction} className="space-y-4">
<input type="hidden" name="next" value={next} />
<label className="block text-sm font-medium text-slate-800">
E-mailadres
<input
className="mt-2 w-full rounded-2xl border border-black/10 bg-stone-50 px-4 py-3 text-base outline-none transition focus:border-emerald-600 focus:bg-white"
type="email"
name="email"
autoComplete="email"
required
/>
</label>
<label className="block text-sm font-medium text-slate-800">
Wachtwoord
<input
className="mt-2 w-full rounded-2xl border border-black/10 bg-stone-50 px-4 py-3 text-base outline-none transition focus:border-emerald-600 focus:bg-white"
type="password"
name="password"
autoComplete="new-password"
minLength={8}
required
/>
</label>
<button
type="submit"
className="inline-flex w-full items-center justify-center rounded-2xl bg-emerald-950 px-5 py-3 text-sm font-semibold text-emerald-50 transition hover:-translate-y-0.5 hover:bg-emerald-900"
>
Account aanmaken
</button>
</form>
)}
</AuthPanel>
);
}