Add nav avatar, product icon, spec page and about page updates
- Show profile avatar and formatted name in top nav - Add product icon to left of brand name in top nav - Add blue border to ProfileAvatar, add xs size - Add protected /specificatie page with 5 chapter summaries - Replace planning button with CV link on about page - Add Specificatie button on about page - Show app version + git SHA on about page Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f9f04a9f2d
commit
682305267a
9 changed files with 209 additions and 32 deletions
14
app/page.tsx
14
app/page.tsx
|
|
@ -64,12 +64,22 @@ export default async function Home({ searchParams }: HomePageProps) {
|
||||||
title="Over de maker en de app"
|
title="Over de maker en de app"
|
||||||
description="Inspannings Monitor is een rustige wellness-first webapp voor volwassenen die hun energie willen plannen, uitvoeren en evalueren zonder medische claims of overmatige frictie."
|
description="Inspannings Monitor is een rustige wellness-first webapp voor volwassenen die hun energie willen plannen, uitvoeren en evalueren zonder medische claims of overmatige frictie."
|
||||||
aside={
|
aside={
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
<Link
|
<Link
|
||||||
href="/planning"
|
href="https://jp-visser.nl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center rounded-full border border-border/80 bg-card/84 px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-colors hover:bg-secondary"
|
className="inline-flex items-center rounded-full border border-border/80 bg-card/84 px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-colors hover:bg-secondary"
|
||||||
>
|
>
|
||||||
Bekijk planning
|
Curriculum vitae
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/specificatie"
|
||||||
|
className="inline-flex items-center rounded-full border border-border/80 bg-card/84 px-4 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-1)] transition-colors hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Specificatie
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
138
app/specificatie/page.tsx
Normal file
138
app/specificatie/page.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getAuthState } from "@/lib/auth/session";
|
||||||
|
import { AppShell } from "@/components/navigation/app-shell";
|
||||||
|
import { PageIntro } from "@/components/navigation/page-intro";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const chapters = [
|
||||||
|
{
|
||||||
|
number: "01",
|
||||||
|
title: "Productkader en positionering",
|
||||||
|
version: "v06",
|
||||||
|
summary:
|
||||||
|
"Legt vast wat Inspannings Monitor wel en niet is. De app positioneert zich expliciet als wellness/self-management tool, zonder medische claims of zorgverlenerrollen. De intended use is energieplanning en dagstructuur voor volwassenen. Non-intended use — zoals diagnostiek, behandeling of klinische besluitvorming — is bewust uitgesloten en wordt bewaakt via claim-guardrails in tekst, UI en onboarding.",
|
||||||
|
points: [
|
||||||
|
"Productnaam: Inspannings Monitor",
|
||||||
|
"Positionering: wellness / self-management",
|
||||||
|
"Doelgroep: volwassen individuele gebruikers",
|
||||||
|
"Voertaal release 1: Nederlands (nl-NL)",
|
||||||
|
"Geen medische claims, geen zorgverlenerrollen",
|
||||||
|
"Expliciete non-intended use vastgelegd als guardrail",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "02",
|
||||||
|
title: "Functionele specificatie MVP",
|
||||||
|
version: "v06",
|
||||||
|
summary:
|
||||||
|
"Beschrijft de MVP in toetsbare functionele requirements. De kern is een plan-doe-evalueer-dagflow: ochtendcheck-in met energiescore en slaapkwaliteit, activiteitenplanning met een licht energiebudget, en een dagafsluiting. Elke stap is bewust klein gehouden om de cognitieve belasting laag te houden. Release 1 bevat geen AI, geen deelfunctionaliteit en geen medische workflows.",
|
||||||
|
points: [
|
||||||
|
"Ochtendcheck-in: energiescore (1–10) en slaapkwaliteit",
|
||||||
|
"Dagbudget afgeleid van energiescore",
|
||||||
|
"Activiteitenplanning met duur, impact en prioriteit",
|
||||||
|
"Statussen: gepland → uitgevoerd / aangepast / overgeslagen",
|
||||||
|
"Dashboard met dag-overzicht en energiemeter",
|
||||||
|
"Geen sharing, AI of medische workflows in de MVP",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "03",
|
||||||
|
title: "Privacy, security en safety baseline",
|
||||||
|
version: "v02",
|
||||||
|
summary:
|
||||||
|
"Bundelt de minimale randvoorwaarden voor privacy, informatiebeveiliging en productveiligheid. Gebruikersdata blijft strikt gescheiden via Row Level Security in Supabase. Authenticatie vereist e-mailverificatie. Gevoelige sleutels worden nooit in de frontend geplaatst. De baseline is ontworpen als minimumset die uitbreidbaar is als het product richting een medisch spoor beweegt.",
|
||||||
|
points: [
|
||||||
|
"Row Level Security: gebruikers zien alleen eigen data",
|
||||||
|
"Supabase Auth met verplichte e-mailverificatie",
|
||||||
|
"Service-role key alleen server-side, nooit in de frontend",
|
||||||
|
"HTTPS-only via Vercel, geen gevoelige data in URL-params",
|
||||||
|
"Geen opslag van medische of klinische gegevens",
|
||||||
|
"Baseline uitbreidbaar richting NEN 7510 / MDR bij medisch spoor",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "04",
|
||||||
|
title: "Roadmap wellness naar medisch",
|
||||||
|
version: "v02",
|
||||||
|
summary:
|
||||||
|
"Laat zien hoe Inspannings Monitor gecontroleerd kan doorgroeien naar een medisch product, zonder dat dit ongemerkt in de wellness-release binnensluipt. De roadmap onderscheidt drie fasen: verankerde wellness-MVP, optionele zorgverlenerlaag en een apart medisch productspoor. Elke fase kent eigen vereisten voor regelgeving, documentatie en technische architectuur.",
|
||||||
|
points: [
|
||||||
|
"Fase 1: wellness MVP — volledig buiten MDR-scope",
|
||||||
|
"Fase 2: optionele zorgverlenerlaag met expliciete scope-bewaking",
|
||||||
|
"Fase 3: apart medisch productspoor met eigen regulatoir traject",
|
||||||
|
"Faseovergangen vereisen bewuste go/no-go beslissing",
|
||||||
|
"Geen sluipende scope-uitbreiding zonder documentatieverandering",
|
||||||
|
"Pad opengehouden zonder verplichting om het te bewandelen",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: "05",
|
||||||
|
title: "Technische architectuur en implementatie",
|
||||||
|
version: "v01",
|
||||||
|
summary:
|
||||||
|
"Beschrijft de technische implementatielaag van de wellness-first MVP als zelfstandig architectuurdocument. De stack is bewust compact gehouden: Next.js App Router met Server Actions, Supabase voor auth en data, en Vercel voor hosting en CI/CD. De architectuur is opgezet met duidelijke laagscheiding zodat uitbreiding naar een medisch spoor later geïsoleerd kan plaatsvinden.",
|
||||||
|
points: [
|
||||||
|
"Stack: Next.js 16 (App Router) + React 19 + TypeScript",
|
||||||
|
"Database en auth: Supabase (PostgreSQL + Row Level Security)",
|
||||||
|
"UI: shadcn/ui + Tailwind CSS, Dusk dark-mode thema",
|
||||||
|
"Hosting en deploys: Vercel met GitHub Actions CI",
|
||||||
|
"Server Actions voor alle formmutaties, geen aparte API-laag",
|
||||||
|
"Migraties via supabase/migrations, seeding via npm-script",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default async function SpecificatiePage() {
|
||||||
|
const authState = await getAuthState();
|
||||||
|
|
||||||
|
if (!authState.isAuthenticated) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell contentClassName="space-y-8">
|
||||||
|
<PageIntro
|
||||||
|
eyebrow="Documentatie"
|
||||||
|
title="Productspecificatie"
|
||||||
|
description="Samenvatting van de vijf kerndocumenten die de scope, vereisten en architectuur van Inspannings Monitor vastleggen."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{chapters.map((chapter) => (
|
||||||
|
<Card key={chapter.number} elevation="raised" className="pb-0">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground">
|
||||||
|
Hoofdstuk {chapter.number} · {chapter.version}
|
||||||
|
</p>
|
||||||
|
<CardTitle className="text-2xl text-foreground">
|
||||||
|
{chapter.title}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="max-w-3xl text-sm leading-7 text-muted-foreground">
|
||||||
|
{chapter.summary}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pb-6">
|
||||||
|
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
||||||
|
{chapter.points.map((point) => (
|
||||||
|
<Card key={point} tone="subtle" className="pb-0 shadow-none">
|
||||||
|
<CardContent className="px-4 py-3 text-sm leading-6 text-muted-foreground">
|
||||||
|
{point}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,14 @@ import {
|
||||||
import { signOutAction } from "@/app/auth-actions";
|
import { signOutAction } from "@/app/auth-actions";
|
||||||
import type { AuthState } from "@/lib/auth/session";
|
import type { AuthState } from "@/lib/auth/session";
|
||||||
import { ProfileAvatar } from "@/components/profile/profile-avatar";
|
import { ProfileAvatar } from "@/components/profile/profile-avatar";
|
||||||
|
import type { NavProfile } from "@/lib/profile/service";
|
||||||
|
|
||||||
|
function formatNavDisplayName(displayName: string | null): string | null {
|
||||||
|
if (!displayName) return null;
|
||||||
|
const parts = displayName.trim().split(/\s+/);
|
||||||
|
if (parts.length < 2) return displayName;
|
||||||
|
return `${parts[0][0]}. ${parts[parts.length - 1]}`;
|
||||||
|
}
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -22,26 +30,27 @@ import {
|
||||||
|
|
||||||
type AccountMenuProps = {
|
type AccountMenuProps = {
|
||||||
authState: AuthState;
|
authState: AuthState;
|
||||||
navAvatarUrl: string | null;
|
navProfile: NavProfile | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AccountMenu({ authState, navAvatarUrl }: AccountMenuProps) {
|
export function AccountMenu({ authState, navProfile }: AccountMenuProps) {
|
||||||
const showAvatar = authState.isAuthenticated && navAvatarUrl;
|
const showAvatar = authState.isAuthenticated && navProfile?.avatarUrl;
|
||||||
|
const navLabel = formatNavDisplayName(navProfile?.displayName ?? null) ?? "Account";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger aria-label="Account menu">
|
<DropdownMenuTrigger aria-label="Account menu">
|
||||||
{showAvatar ? (
|
{showAvatar ? (
|
||||||
<ProfileAvatar
|
<ProfileAvatar
|
||||||
avatarUrl={navAvatarUrl}
|
avatarUrl={navProfile!.avatarUrl}
|
||||||
displayName={null}
|
displayName={navProfile!.displayName}
|
||||||
email={authState.email}
|
email={authState.email}
|
||||||
size="xs"
|
size="xs"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CircleUserRoundIcon className="size-4" />
|
<CircleUserRoundIcon className="size-4" />
|
||||||
)}
|
)}
|
||||||
<span className="hidden sm:inline">Account</span>
|
<span className="hidden sm:inline">{navLabel}</span>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
{authState.isConfigured ? (
|
{authState.isConfigured ? (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { getAuthState } from "@/lib/auth/session";
|
import { getAuthState } from "@/lib/auth/session";
|
||||||
import { getNavAvatarUrlForCurrentUser } from "@/lib/profile/service";
|
import { getNavProfileForCurrentUser } from "@/lib/profile/service";
|
||||||
import { BottomNav } from "@/components/navigation/bottom-nav";
|
import { BottomNav } from "@/components/navigation/bottom-nav";
|
||||||
import { TopNav } from "@/components/navigation/top-nav";
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -15,14 +15,14 @@ export async function AppShell({
|
||||||
contentClassName,
|
contentClassName,
|
||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
const authState = await getAuthState();
|
const authState = await getAuthState();
|
||||||
const navAvatarUrl = authState.userId
|
const navProfile = authState.userId
|
||||||
? await getNavAvatarUrlForCurrentUser(authState.userId)
|
? await getNavProfileForCurrentUser(authState.userId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app-page">
|
<main className="app-page">
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-6xl flex-col gap-8">
|
<div className="mx-auto flex min-h-screen w-full max-w-6xl flex-col gap-8">
|
||||||
<TopNav authState={authState} navAvatarUrl={navAvatarUrl} />
|
<TopNav authState={authState} navProfile={navProfile} />
|
||||||
<div className={cn("flex-1", contentClassName)}>{children}</div>
|
<div className={cn("flex-1", contentClassName)}>{children}</div>
|
||||||
<BottomNav />
|
<BottomNav />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import type { AuthState } from "@/lib/auth/session";
|
import type { AuthState } from "@/lib/auth/session";
|
||||||
|
import type { NavProfile } from "@/lib/profile/service";
|
||||||
import { AccountMenu } from "@/components/navigation/account-menu";
|
import { AccountMenu } from "@/components/navigation/account-menu";
|
||||||
import {
|
import {
|
||||||
isActivePath,
|
isActivePath,
|
||||||
|
|
@ -14,23 +16,32 @@ import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type TopNavProps = {
|
type TopNavProps = {
|
||||||
authState: AuthState;
|
authState: AuthState;
|
||||||
navAvatarUrl: string | null;
|
navProfile: NavProfile | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TopNav({ authState, navAvatarUrl }: TopNavProps) {
|
export function TopNav({ authState, navProfile }: TopNavProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const useCompactBottomNav = shouldUseBottomNav(pathname);
|
const useCompactBottomNav = shouldUseBottomNav(pathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-4 z-40">
|
<header className="sticky top-4 z-40">
|
||||||
<div className="flex flex-wrap items-center gap-4 rounded-[var(--radius-4xl)] border border-border/70 bg-card/86 px-5 py-4 shadow-[var(--shadow-2)] backdrop-blur">
|
<div className="flex flex-wrap items-center gap-4 rounded-[var(--radius-4xl)] border border-border/70 bg-card/86 px-5 py-4 shadow-[var(--shadow-2)] backdrop-blur">
|
||||||
<Link href="/" className="shrink-0">
|
<Link href="/" className="flex shrink-0 items-center gap-3">
|
||||||
|
<Image
|
||||||
|
src="/icon.svg"
|
||||||
|
alt="Inspannings Monitor icoon"
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
className="shrink-0 rounded-xl"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
<span className="block text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
<span className="block text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
Inspannings Monitor
|
Inspannings Monitor
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 block text-base font-semibold tracking-[-0.02em] text-foreground">
|
<span className="mt-1 block text-base font-semibold tracking-[-0.02em] text-foreground">
|
||||||
Wellness-first dagflow
|
Wellness-first dagflow
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
|
|
@ -65,7 +76,7 @@ export function TopNav({ authState, navAvatarUrl }: TopNavProps) {
|
||||||
|
|
||||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||||
<ThemeMenu />
|
<ThemeMenu />
|
||||||
<AccountMenu authState={authState} navAvatarUrl={navAvatarUrl} />
|
<AccountMenu authState={authState} navProfile={navProfile} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export function ProfileAvatar({
|
||||||
<div
|
<div
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/70 bg-muted/70 font-semibold tracking-[0.08em] text-foreground shadow-[var(--shadow-1)]",
|
"relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-primary/50 bg-muted/70 font-semibold tracking-[0.08em] text-foreground shadow-[var(--shadow-1)]",
|
||||||
avatarSizeClasses[size],
|
avatarSizeClasses[size],
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -501,13 +501,22 @@ export async function getProfileBundleForCurrentUser(): Promise<ProfileBundle |
|
||||||
return ensureProfileBundleForCurrentUser();
|
return ensureProfileBundleForCurrentUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getNavAvatarUrlForCurrentUser(userId: string): Promise<string | null> {
|
export type NavProfile = {
|
||||||
|
avatarUrl: string | null;
|
||||||
|
displayName: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getNavProfileForCurrentUser(userId: string): Promise<NavProfile> {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from("profiles")
|
.from("profiles")
|
||||||
.select("avatar_path")
|
.select("avatar_path, display_name")
|
||||||
.eq("id", userId)
|
.eq("id", userId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
return getProfileAvatarUrl(data?.avatar_path ?? null);
|
return {
|
||||||
|
avatarUrl: await getProfileAvatarUrl(data?.avatar_path ?? null),
|
||||||
|
displayName: data?.display_name ?? null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "inspannings-monitor",
|
"name": "inspannings-monitor",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue