chore(strip): fase 0 — verwijder marketing, leads, archive en sitemap

Prototype-ballast verwijderd als eerste stap van de ECD-rebuild:
- app/(marketing) incl. blog, contact, documentatie + lib/mdx, lib/content, content/
- /api/leads en publieke marketing-routes uit middleware
- app/epd/_archive (backup van clients-module)
- sitemap.ts (verwees alleen naar blog), globals.css.backup
- root / redirect naar /login; robots.txt op disallow-all (afgeschermd systeem)

FHIR-routes blijven bewust staan: /api/fhir/Patient is de facto de
patienten-API voor dossier, agenda en Cortex — vervangen volgt in fase 3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
colinislit
2026-07-14 22:21:20 +02:00
parent c0a4fa24f2
commit 10c994b252
109 changed files with 8 additions and 14533 deletions

View File

@@ -1,169 +0,0 @@
/**
* Blog Overview Page
*
* Landing page with series cards and recent posts
*/
import Link from 'next/link'
import { getAllPosts, getSeriesWithCounts, type BlogSeries } from '@/lib/mdx/blog'
export const metadata = {
title: 'Blog - AI Speedrun',
description: 'Artikelen over AI-gestuurde software ontwikkeling, healthcare IT en het bouwen van een EPD.',
openGraph: {
title: 'Blog - AI Speedrun',
description: 'Artikelen over AI-gestuurde software ontwikkeling, healthcare IT en het bouwen van een EPD.',
type: 'website',
siteName: 'AI Speedrun',
locale: 'nl_NL',
},
twitter: {
card: 'summary',
title: 'Blog - AI Speedrun',
description: 'Artikelen over AI-gestuurde software ontwikkeling, healthcare IT en het bouwen van een EPD.',
},
alternates: {
canonical: `${process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'}/blog`,
},
}
export default async function BlogPage() {
const series = await getSeriesWithCounts()
const posts = await getAllPosts()
return (
<div className="min-h-screen bg-slate-50 pb-16">
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8">
{/* Header */}
<div className="mb-8 md:mb-12 pt-20 md:pt-24">
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-slate-900 mb-3 md:mb-4">
Blog
</h1>
<p className="text-lg md:text-xl text-slate-600 max-w-3xl leading-relaxed">
Artikelen over AI-gestuurde ontwikkeling en de reis van het bouwen van een EPD.
</p>
</div>
{/* Series Section */}
{series.length > 0 && (
<section className="mb-12">
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-4">
Series
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{series.map((serie) => (
<SeriesCard key={serie.id} series={serie} />
))}
</div>
</section>
)}
{/* Recent Posts Section */}
<section>
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-4">
Recente Posts
</h2>
{posts.length === 0 ? (
<p className="text-slate-500 text-lg text-center py-12">
Nog geen blogposts gepubliceerd.
</p>
) : (
<div className="space-y-4">
{posts.map((post) => (
<Link
key={`${post.seriesId}-${post.slug}`}
href={`/blog/serie/${post.seriesId}/${post.slug}`}
className="block bg-white rounded-lg border border-slate-200 p-5 md:p-6 hover:border-teal-300 hover:shadow-md transition-all active:scale-[0.99]"
>
<div className="flex flex-wrap items-center gap-2 mb-2 text-sm text-slate-500">
<span className="text-xs font-medium uppercase tracking-wide">
{post.seriesId.replace('-', ' ')}
</span>
<span></span>
<time dateTime={post.frontmatter.date}>
{new Date(post.frontmatter.date).toLocaleDateString('nl-NL', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<span></span>
<span>{post.readingTime} min leestijd</span>
</div>
<h3 className="text-xl md:text-2xl font-bold text-slate-900 mb-2 leading-tight">
{post.frontmatter.title}
</h3>
<p className="text-slate-600 leading-relaxed line-clamp-2">
{post.frontmatter.description}
</p>
{post.frontmatter.tags && post.frontmatter.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4">
{post.frontmatter.tags.map((tag) => (
<span
key={tag}
className="px-2 py-1 bg-slate-100 text-slate-600 text-xs rounded-full"
>
{tag}
</span>
))}
</div>
)}
</Link>
))}
</div>
)}
</section>
</div>
</div>
)
}
function SeriesCard({ series }: { series: BlogSeries & { postCount: number } }) {
const colorStyles = {
teal: {
bg: 'bg-teal-50',
border: 'border-teal-200 hover:border-teal-400',
badge: 'bg-teal-100 text-teal-700',
dot: 'bg-teal-500',
},
amber: {
bg: 'bg-amber-50',
border: 'border-amber-200 hover:border-amber-400',
badge: 'bg-amber-100 text-amber-700',
dot: 'bg-amber-500',
},
slate: {
bg: 'bg-slate-50',
border: 'border-slate-200 hover:border-slate-400',
badge: 'bg-slate-100 text-slate-700',
dot: 'bg-slate-500',
},
}
const styles = colorStyles[series.color] || colorStyles.slate
const statusLabels = {
completed: 'Voltooid',
active: 'Actief',
planned: 'Gepland',
}
return (
<Link
href={`/blog/serie/${series.id}`}
className={`block rounded-lg border-2 p-5 transition-all hover:shadow-md active:scale-[0.98] ${styles.bg} ${styles.border}`}
>
<div className="flex items-center gap-2 mb-2">
<div className={`w-2 h-2 rounded-full ${styles.dot}`} />
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${styles.badge}`}>
{statusLabels[series.status]}
</span>
</div>
<h3 className="text-lg font-bold text-slate-900 mb-1">{series.title}</h3>
<p className="text-sm text-slate-600 mb-3 line-clamp-2">{series.description}</p>
<div className="text-xs text-slate-500">
{series.postCount} {series.postCount === 1 ? 'deel' : 'delen'}
</div>
</Link>
)
}

View File

@@ -1,324 +0,0 @@
/**
* Individual Blog Post Page
*
* Renders MDX content with series navigation
*/
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { MDXRemote } from 'next-mdx-remote/rsc'
import { getPost, getSeries, getPostsBySeries, getSeriesNavigation, getAllPosts } from '@/lib/mdx/blog'
import { mdxComponents } from '../../../../documentatie/components/mdx-components'
interface PostPageProps {
params: Promise<{ serieId: string; slug: string }>
}
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({
serieId: post.seriesId,
slug: post.slug,
}))
}
export async function generateMetadata({ params }: PostPageProps) {
const { serieId, slug } = await params
const post = await getPost(serieId, slug)
const series = await getSeries(serieId)
if (!post || !series) {
return { title: 'Post niet gevonden' }
}
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
const postUrl = `${siteUrl}/blog/serie/${serieId}/${slug}`
// Use post-specific image if provided, otherwise fall back to default
const ogImageUrl = post.frontmatter.image
? `${siteUrl}${post.frontmatter.image.startsWith('/') ? '' : '/'}${post.frontmatter.image}`
: `${siteUrl}/og-blog-default.png`
return {
title: `${post.frontmatter.title} - ${series.title}`,
description: post.frontmatter.description,
authors: [{ name: 'Colin van der Heijden', url: 'https://ikbenlit.nl' }],
keywords: post.frontmatter.tags || [],
openGraph: {
title: post.frontmatter.title,
description: post.frontmatter.description,
type: 'article',
publishedTime: post.frontmatter.date,
authors: ['Colin van der Heijden'],
siteName: 'AI Speedrun',
locale: 'nl_NL',
images: [
{
url: ogImageUrl,
width: 1200,
height: 630,
alt: post.frontmatter.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.frontmatter.title,
description: post.frontmatter.description,
images: [ogImageUrl],
},
alternates: {
canonical: postUrl,
},
}
}
export default async function PostPage({ params }: PostPageProps) {
const { serieId, slug } = await params
const post = await getPost(serieId, slug)
const series = await getSeries(serieId)
if (!post || !series) {
notFound()
}
const navigation = await getSeriesNavigation(serieId, slug)
const { frontmatter, content, readingTime } = post
// Generate Article structured data for SEO
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
const postUrl = `${siteUrl}/blog/serie/${serieId}/${slug}`
// Use post-specific image if provided, otherwise fall back to default
const articleImage = frontmatter.image
? `${siteUrl}${frontmatter.image.startsWith('/') ? '' : '/'}${frontmatter.image}`
: `${siteUrl}/og-blog-default.png`
const articleSchema = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: frontmatter.title,
description: frontmatter.description,
image: articleImage,
datePublished: frontmatter.date,
dateModified: frontmatter.date,
author: {
'@type': 'Person',
name: 'Colin van der Heijden',
url: 'https://ikbenlit.nl',
},
publisher: {
'@type': 'Organization',
name: 'AI Speedrun',
url: siteUrl,
logo: {
'@type': 'ImageObject',
url: `${siteUrl}/images/aispeedrun-logo.webp`,
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': postUrl,
},
articleSection: series.title,
keywords: frontmatter.tags?.join(', ') || '',
wordCount: content.split(/\s+/).length,
timeRequired: `PT${readingTime}M`,
}
// Generate BreadcrumbList structured data
const breadcrumbSchema = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Home',
item: siteUrl,
},
{
'@type': 'ListItem',
position: 2,
name: 'Blog',
item: `${siteUrl}/blog`,
},
{
'@type': 'ListItem',
position: 3,
name: series.title,
item: `${siteUrl}/blog/serie/${serieId}`,
},
{
'@type': 'ListItem',
position: 4,
name: frontmatter.title,
item: postUrl,
},
],
}
const colorStyles = {
teal: {
badge: 'bg-teal-100 text-teal-700 border-teal-200',
link: 'text-teal-600 hover:text-teal-700',
},
amber: {
badge: 'bg-amber-100 text-amber-700 border-amber-200',
link: 'text-amber-600 hover:text-amber-700',
},
slate: {
badge: 'bg-slate-100 text-slate-700 border-slate-200',
link: 'text-slate-600 hover:text-slate-700',
},
}
const styles = colorStyles[series.color] || colorStyles.slate
return (
<div className="min-h-screen bg-white pb-16">
{/* Structured Data for SEO */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
/>
<article className="max-w-3xl mx-auto px-4 md:px-8">
{/* Back link */}
<div className="pt-20 md:pt-24 mb-6">
<Link
href={`/blog/serie/${serieId}`}
className="inline-flex items-center gap-2 text-slate-600 hover:text-teal-600 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span>Terug naar {series.title}</span>
</Link>
</div>
{/* Header */}
<header className="mb-8 pb-8 border-b border-slate-200">
{/* Series badge */}
<div className="flex flex-wrap items-center gap-3 mb-4">
<Link
href={`/blog/serie/${serieId}`}
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium border transition-colors hover:opacity-80 ${styles.badge}`}
>
{series.title}
</Link>
<span className="text-sm text-slate-500">
Deel {navigation.current} van {navigation.total}
</span>
</div>
{/* Title */}
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-slate-900 mb-4 leading-tight">
{frontmatter.title}
</h1>
{/* Description */}
<p className="text-xl text-slate-600 leading-relaxed mb-4">
{frontmatter.description}
</p>
{/* Meta */}
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-500">
<time dateTime={frontmatter.date}>
{new Date(frontmatter.date).toLocaleDateString('nl-NL', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<span></span>
<span>{readingTime} min leestijd</span>
</div>
{/* Tags */}
{frontmatter.tags && frontmatter.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4">
{frontmatter.tags.map((tag) => (
<span
key={tag}
className="px-2.5 py-1 bg-slate-100 text-slate-600 text-xs rounded-full"
>
{tag}
</span>
))}
</div>
)}
</header>
{/* MDX Content */}
<div className="prose prose-slate max-w-none">
<MDXRemote source={content} components={mdxComponents} />
</div>
{/* Series Navigation */}
<nav className="mt-12 pt-8 border-t border-slate-200">
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-4">
{series.title} Serie
</div>
<div className="grid grid-cols-2 gap-4">
{/* Previous */}
<div>
{navigation.previous ? (
<Link
href={`/blog/serie/${serieId}/${navigation.previous.slug}`}
className="block p-4 rounded-lg border border-slate-200 hover:border-teal-300 hover:shadow-sm transition-all group"
>
<div className="flex items-center gap-2 text-sm text-slate-500 mb-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span>Vorige</span>
</div>
<div className="font-medium text-slate-900 group-hover:text-teal-600 transition-colors line-clamp-1">
{navigation.previous.title}
</div>
</Link>
) : (
<div />
)}
</div>
{/* Next */}
<div>
{navigation.next ? (
<Link
href={`/blog/serie/${serieId}/${navigation.next.slug}`}
className="block p-4 rounded-lg border border-slate-200 hover:border-teal-300 hover:shadow-sm transition-all group text-right"
>
<div className="flex items-center justify-end gap-2 text-sm text-slate-500 mb-1">
<span>Volgende</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
<div className="font-medium text-slate-900 group-hover:text-teal-600 transition-colors line-clamp-1">
{navigation.next.title}
</div>
</Link>
) : (
<div />
)}
</div>
</div>
</nav>
{/* Back to overview */}
<footer className="mt-8 text-center">
<Link
href="/blog"
className="text-sm text-slate-600 hover:text-teal-600 transition-colors"
>
Terug naar blog overzicht
</Link>
</footer>
</article>
</div>
)
}

View File

@@ -1,178 +0,0 @@
/**
* Series Overview Page
*
* Shows all posts in a specific series
*/
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { getSeries, getPostsBySeries, getAllSeries } from '@/lib/mdx/blog'
interface SeriesPageProps {
params: Promise<{ serieId: string }>
}
export async function generateStaticParams() {
const series = await getAllSeries()
return series.map((serie) => ({ serieId: serie.id }))
}
export async function generateMetadata({ params }: SeriesPageProps) {
const { serieId } = await params
const series = await getSeries(serieId)
if (!series) {
return { title: 'Serie niet gevonden' }
}
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
const seriesUrl = `${siteUrl}/blog/serie/${serieId}`
return {
title: `${series.title} - Blog | AI Speedrun`,
description: series.description,
openGraph: {
title: series.title,
description: series.description,
type: 'website',
siteName: 'AI Speedrun',
locale: 'nl_NL',
},
twitter: {
card: 'summary',
title: series.title,
description: series.description,
},
alternates: {
canonical: seriesUrl,
},
}
}
export default async function SeriesPage({ params }: SeriesPageProps) {
const { serieId } = await params
const series = await getSeries(serieId)
if (!series) {
notFound()
}
const posts = await getPostsBySeries(serieId)
const colorStyles = {
teal: {
bg: 'bg-teal-50',
badge: 'bg-teal-100 text-teal-700 border-teal-200',
dot: 'bg-teal-500',
number: 'bg-teal-600 text-white',
},
amber: {
bg: 'bg-amber-50',
badge: 'bg-amber-100 text-amber-700 border-amber-200',
dot: 'bg-amber-500',
number: 'bg-amber-600 text-white',
},
slate: {
bg: 'bg-slate-50',
badge: 'bg-slate-100 text-slate-700 border-slate-200',
dot: 'bg-slate-500',
number: 'bg-slate-600 text-white',
},
}
const styles = colorStyles[series.color] || colorStyles.slate
const statusLabels = {
completed: 'Voltooid',
active: 'Actief',
planned: 'Gepland',
}
return (
<div className="min-h-screen bg-slate-50 pb-16">
<div className="max-w-3xl mx-auto px-4 sm:px-6 md:px-8">
{/* Back link */}
<div className="pt-20 md:pt-24 mb-6">
<Link
href="/blog"
className="inline-flex items-center gap-2 text-slate-600 hover:text-teal-600 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span>Terug naar blog</span>
</Link>
</div>
{/* Series Header */}
<header className={`rounded-xl p-6 md:p-8 mb-8 ${styles.bg}`}>
<div className="flex items-center gap-2 mb-3">
<div className={`w-2.5 h-2.5 rounded-full ${styles.dot}`} />
<span className={`text-xs font-medium px-2.5 py-1 rounded-full border ${styles.badge}`}>
{statusLabels[series.status]}
</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold text-slate-900 mb-3">
{series.title}
</h1>
<p className="text-lg text-slate-600 leading-relaxed">
{series.description}
</p>
<div className="mt-4 text-sm text-slate-500">
{posts.length} {posts.length === 1 ? 'deel' : 'delen'}
</div>
</header>
{/* Posts List */}
{posts.length === 0 ? (
<p className="text-slate-500 text-center py-12">
Nog geen posts in deze serie.
</p>
) : (
<div className="space-y-3">
{posts.map((post, index) => (
<Link
key={post.slug}
href={`/blog/serie/${serieId}/${post.slug}`}
className="flex items-start gap-4 bg-white rounded-lg border border-slate-200 p-4 md:p-5 hover:border-teal-300 hover:shadow-md transition-all active:scale-[0.99]"
>
<div
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${styles.number}`}
>
{index + 1}
</div>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-bold text-slate-900 mb-1 leading-tight">
{post.frontmatter.title}
</h2>
<p className="text-sm text-slate-600 mb-2 line-clamp-2">
{post.frontmatter.description}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
<time dateTime={post.frontmatter.date}>
{new Date(post.frontmatter.date).toLocaleDateString('nl-NL', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</time>
<span></span>
<span>{post.readingTime} min</span>
</div>
</div>
<svg
className="w-5 h-5 text-slate-400 flex-shrink-0 mt-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -1,38 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
/**
* Auth Code Handler
*
* Handles auth codes that Supabase redirects to the home page
* Redirects to /auth/callback to process the code exchange
*/
export function AuthCodeHandler() {
const searchParams = useSearchParams()
const router = useRouter()
const code = searchParams.get('code')
const type = searchParams.get('type')
useEffect(() => {
if (code) {
// Build callback URL with all parameters
const callbackUrl = new URL('/auth/callback', window.location.origin)
callbackUrl.searchParams.set('code', code)
if (type) {
callbackUrl.searchParams.set('type', type)
}
// If it's a recovery flow, ensure we redirect to update-password
if (type === 'recovery') {
callbackUrl.searchParams.set('next', '/update-password')
}
// Redirect to callback route
router.replace(callbackUrl.pathname + callbackUrl.search)
}
}, [code, type, router])
return null
}

View File

@@ -1,273 +0,0 @@
"use client";
import { useMotionValueEvent, useScroll, useTransform, motion } from "framer-motion";
import React, { useEffect, useRef, useState } from "react";
import {
Rocket, Database, Sparkles, Palette, Calendar, Layout,
Users, Smartphone, FileText, Brain, Tags, Target,
HelpCircle, Zap, Eye
} from "lucide-react";
// Icon mapping
const iconMap = {
Rocket,
Database,
Sparkles,
Palette,
Calendar,
Layout,
Users,
Smartphone,
FileText,
Brain,
Tags,
Target,
HelpCircle,
Zap,
Eye,
};
interface Feature {
title: string;
description: string;
time?: string;
traditional?: string;
icon: keyof typeof iconMap;
}
interface WeekData {
weekNumber: number;
title: string;
status: "completed" | "in_progress" | "planned";
description: string;
features: Feature[];
metrics: {
hours: string;
cost: string;
linesOfCode: string;
};
achievements: string[];
}
interface TimelineData {
heading: string;
description: string;
weeks: WeekData[];
}
interface BuildTimelineProps {
data: TimelineData;
}
const StatusBadge = ({ status }: { status: WeekData["status"] }) => {
const styles = {
completed: "bg-teal-100 text-teal-700 border-teal-200",
in_progress: "bg-amber-100 text-amber-700 border-amber-200",
planned: "bg-slate-100 text-slate-600 border-slate-200",
};
const labels = {
completed: "Voltooid",
in_progress: "Bezig",
planned: "Gepland",
};
return (
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium border ${styles[status]}`}>
{labels[status]}
</span>
);
};
const FeatureCard = ({ feature }: { feature: Feature }) => {
const Icon = iconMap[feature.icon];
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 hover:border-teal-300 transition-colors">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-10 h-10 bg-teal-100 rounded-lg flex items-center justify-center">
<Icon className="w-5 h-5 text-teal-600" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-slate-900 text-sm mb-1">
{feature.title}
</h4>
<p className="text-slate-600 text-sm leading-relaxed">
{feature.description}
</p>
{feature.time && feature.traditional && (
<div className="mt-2 flex flex-col gap-1 text-xs">
<div className="flex items-center gap-2">
<span className="font-medium text-teal-600">Met AI:</span>
<span className="text-slate-900 font-semibold">{feature.time}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium text-slate-500">Traditioneel:</span>
<span className="text-slate-600 line-through">{feature.traditional}</span>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export const BuildTimeline = ({ data }: BuildTimelineProps) => {
const ref = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
useEffect(() => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
setHeight(rect.height);
}
}, [ref]);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start 10%", "end 50%"],
});
const heightTransform = useTransform(scrollYProgress, [0, 1], [0, height]);
const opacityTransform = useTransform(scrollYProgress, [0, 0.1], [0, 1]);
return (
<div className="w-full bg-slate-50 font-sans" ref={containerRef}>
{/* Header */}
<div className="max-w-7xl mx-auto py-16 px-4 md:px-8 lg:px-10 text-center">
<h2 className="font-serif text-3xl md:text-4xl font-bold text-slate-900 mb-4">
{data.heading}
</h2>
<p className="text-slate-600 text-lg max-w-3xl mx-auto">
{data.description}
</p>
</div>
{/* Timeline */}
<div ref={ref} className="relative max-w-7xl mx-auto pb-20">
{data.weeks.map((week, index) => (
<div
key={index}
className="flex justify-start pt-10 md:pt-20 md:gap-10"
>
{/* Left side - Week title (sticky) */}
<div className="sticky flex flex-col md:flex-row z-40 items-center top-40 self-start max-w-xs lg:max-w-sm md:w-full">
{/* Timeline dot */}
<div className="h-10 absolute left-3 md:left-3 w-10 rounded-full bg-slate-50 flex items-center justify-center">
<div className="h-4 w-4 rounded-full bg-teal-500 border-2 border-white shadow-md" />
</div>
{/* Week title - hidden on mobile */}
<div className="hidden md:block md:pl-20">
<h3 className="text-2xl md:text-3xl font-bold text-slate-800 mb-2">
{week.title}
</h3>
<StatusBadge status={week.status} />
</div>
</div>
{/* Right side - Content */}
<div className="relative pl-20 pr-4 md:pl-4 w-full">
{/* Week title - mobile only */}
<div className="md:hidden mb-4">
<h3 className="text-2xl font-bold text-slate-800 mb-2">
{week.title}
</h3>
<StatusBadge status={week.status} />
</div>
{/* Description */}
<p className="text-slate-700 text-base leading-relaxed mb-6">
{week.description}
</p>
{/* Features */}
{week.features.length > 0 && (
<div className="mb-6">
<h4 className="text-sm font-semibold text-slate-500 uppercase tracking-wide mb-3">
Features
</h4>
<div className="grid gap-3 md:grid-cols-2">
{week.features.map((feature, fIndex) => (
<FeatureCard key={fIndex} feature={feature} />
))}
</div>
</div>
)}
{/* Metrics */}
<div className="bg-white border border-slate-200 rounded-lg p-4 mb-6">
<h4 className="text-sm font-semibold text-slate-500 uppercase tracking-wide mb-3">
Metrics
</h4>
<div className="grid grid-cols-3 gap-4">
<div>
<div className="text-2xl font-bold text-teal-600">
{week.metrics.hours}
</div>
<div className="text-xs text-slate-500">Development</div>
</div>
<div>
<div className="text-2xl font-bold text-teal-600">
{week.metrics.cost}
</div>
<div className="text-xs text-slate-500">Infrastructure</div>
</div>
<div>
<div className="text-2xl font-bold text-teal-600">
{week.metrics.linesOfCode}
</div>
<div className="text-xs text-slate-500">Lines of Code</div>
</div>
</div>
</div>
{/* Achievements */}
{week.achievements.length > 0 && (
<div className="bg-teal-50 border border-teal-100 rounded-lg p-4 mb-4">
<h4 className="text-sm font-semibold text-teal-700 uppercase tracking-wide mb-2">
Achievements
</h4>
<ul className="space-y-1">
{week.achievements.map((achievement, aIndex) => (
<li key={aIndex} className="text-sm text-slate-700">
{achievement}
</li>
))}
</ul>
</div>
)}
{/* Link to detailed documentation */}
<a
href="/documentatie"
className="inline-flex items-center gap-2 text-teal-600 hover:text-teal-700 font-medium text-sm transition-colors"
>
<span>Bekijk uitgebreide documentatie</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
</div>
))}
{/* Animated timeline line */}
<div
style={{ height: height + "px" }}
className="absolute md:left-8 left-8 top-0 overflow-hidden w-[2px] bg-gradient-to-b from-transparent via-slate-300 to-transparent"
>
<motion.div
style={{
height: heightTransform,
opacity: opacityTransform,
}}
className="absolute inset-x-0 top-0 w-[2px] bg-gradient-to-b from-teal-500 via-teal-400 to-transparent rounded-full"
/>
</div>
</div>
</div>
);
};

View File

@@ -1,58 +0,0 @@
/**
* Hero Quote Component
*
* Full-viewport hero section with Jensen Huang quote.
* Displays quote, attribution, and subtitle from content.
*/
import type { HeroContent } from '@/content/schemas/manifesto'
import { MarketingShader } from './marketing-shader'
interface HeroQuoteProps {
content: HeroContent
}
export function HeroQuote({ content }: HeroQuoteProps) {
return (
<section
className="relative min-h-screen flex items-center justify-center bg-slate-900"
style={{ backgroundColor: '#0F172A' }} // Expliciet donkere achtergrond voor maximum contrast
aria-label="Hero quote sectie"
>
{/* Shader Background - zeer subtiel (opacity 0.02) */}
{/* Op mobile: donkere fallback, op desktop: subtiele shader */}
<MarketingShader variant="hero" className="z-0" />
{/* Content Overlay */}
<div className="relative z-10 w-full max-w-4xl px-4 md:px-8 text-center pt-20 md:pt-0">
<blockquote
className="text-white font-serif italic mb-4 md:mb-6"
style={{
fontSize: 'clamp(1.5rem, 5vw, 4rem)', // Mobile: smaller, Desktop: larger
lineHeight: 'var(--line-height-tight)',
textShadow: '0 2px 8px rgba(0, 0, 0, 0.5), 0 1px 2px rgba(0, 0, 0, 0.3)', // Sterkere shadow voor beter contrast
color: '#FFFFFF', // Expliciet wit voor maximum contrast
}}
cite={content.attribution}
>
&ldquo;{content.quote}&rdquo;
</blockquote>
<div className="text-white font-sans text-sm md:text-base" style={{ textShadow: '0 1px 3px rgba(0, 0, 0, 0.4)' }}>
<cite className="not-italic font-semibold text-base md:text-lg text-white">
{content.attribution}
</cite>
{content.attributionContext && (
<span className="text-slate-200 text-sm md:text-base ml-2" aria-hidden="true">
{content.attributionContext}
</span>
)}
</div>
<p className="text-white mt-4 md:mt-6 text-base md:text-xl font-sans" style={{ textShadow: '0 1px 3px rgba(0, 0, 0, 0.4)' }}>
{content.subtitle}
</p>
</div>
</section>
)
}

View File

@@ -1,33 +0,0 @@
'use client'
import { HeroSection } from '@/components/ui/hero-section-2'
interface HeroSectionClientProps {
logo?: {
url: string
alt: string
text?: string
}
slogan?: string
title: React.ReactNode
subtitle: string | React.ReactNode
punchline?: string | React.ReactNode
callToAction: {
text: string
href: string
}
secondaryCallToAction?: {
text: string
href: string
}
backgroundImage: string
contactInfo?: {
website: string
phone: string
address: string
}
}
export function HeroSectionClient(props: HeroSectionClientProps) {
return <HeroSection {...props} />
}

View File

@@ -1,32 +0,0 @@
/**
* Insight Box Component
*
* Visual anchor for key takeaways with yellow left border.
* Used to highlight important insights in the manifesto content.
*/
interface InsightBoxProps {
children: React.ReactNode
variant?: 'default' | 'highlight'
}
export function InsightBox({ children, variant = 'default' }: InsightBoxProps) {
const borderColor = variant === 'highlight' ? '#D97706' : '#F59E0B'
return (
<div
className="bg-white shadow-sm my-6 md:my-8 p-6 border-t-4 md:border-t-0 md:border-l-4"
style={{
borderLeftColor: borderColor,
borderTopColor: borderColor,
borderLeftWidth: '3px',
borderTopWidth: '3px',
}}
>
<p className="text-base md:text-lg font-serif text-slate-900 leading-relaxed">
{children}
</p>
</div>
)
}

View File

@@ -1,83 +0,0 @@
'use client'
/**
* Marketing Shader Component
*
* Wrapper around DotScreenShader for marketing pages.
* Very subtle opacity (0.02) for hero sections.
* Includes mobile fallback and reduced motion support.
*/
import dynamic from 'next/dynamic'
import { useEffect, useState } from 'react'
// Lazy load the shader component (heavy, only load when needed)
const DotScreenShader = dynamic(
() => import('@/components/ui/dot-shader-background').then((mod) => ({ default: mod.DotScreenShader })),
{
ssr: false,
loading: () => null
}
)
interface MarketingShaderProps {
variant?: 'hero' | 'section'
className?: string
}
export function MarketingShader({ variant = 'hero', className = '' }: MarketingShaderProps) {
const [mounted, setMounted] = useState(false)
const [reducedMotion, setReducedMotion] = useState(false)
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
setMounted(true)
// Check for reduced motion preference
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
setReducedMotion(mediaQuery.matches)
const handleChange = (e: MediaQueryListEvent) => setReducedMotion(e.matches)
mediaQuery.addEventListener('change', handleChange)
// Check for mobile (simple check)
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => {
mediaQuery.removeEventListener('change', handleChange)
window.removeEventListener('resize', checkMobile)
}
}, [])
// Fallback voor mobile of reduced motion
// Voor hero variant: donkere achtergrond, voor section variant: lichte achtergrond
if (!mounted || reducedMotion || isMobile) {
const fallbackBg = variant === 'hero'
? 'bg-slate-900' // Donkere achtergrond voor hero
: 'bg-gradient-to-br from-slate-50 to-slate-100' // Lichte achtergrond voor sections
return (
<div
className={`absolute inset-0 ${fallbackBg} ${className}`}
aria-hidden="true"
/>
)
}
// Desktop: shader component met zeer subtiele opacity
// Opacity 0.02 is zeer subtiel - bijna onzichtbaar maar aanwezig
return (
<div
className={`absolute inset-0 overflow-hidden ${className}`}
aria-hidden="true"
style={{ opacity: variant === 'hero' ? 0.02 : 0.03 }}
>
<DotScreenShader />
</div>
)
}

View File

@@ -1,154 +0,0 @@
'use client'
/**
* Minimal Navigation Component
*
* Fixed top navigation with logo and links.
* Mobile-friendly with hamburger menu.
* Adaptive styling based on scroll position (hero vs content).
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import Image from 'next/image'
import { Menu, X } from 'lucide-react'
import type { NavigationContent } from '@/content/schemas/manifesto'
interface MinimalNavProps {
content: NavigationContent
}
export function MinimalNav({ content }: MinimalNavProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [isScrolled, setIsScrolled] = useState(false)
useEffect(() => {
const handleScroll = () => {
// Check if scrolled past hero section (approximately 100vh)
setIsScrolled(window.scrollY > window.innerHeight * 0.8)
}
window.addEventListener('scroll', handleScroll, { passive: true })
handleScroll() // Initial check
return () => {
window.removeEventListener('scroll', handleScroll)
}
}, [])
// Close menu when clicking outside or on link
useEffect(() => {
if (isMenuOpen) {
const handleClickOutside = () => setIsMenuOpen(false)
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}
}, [isMenuOpen])
// Determine nav styling based on scroll position
// On hero: dark nav with light text, on content: light nav with dark text
const navClasses = isScrolled
? 'bg-white/95 backdrop-blur-sm shadow-sm'
: 'bg-slate-900/80 backdrop-blur-sm md:bg-white/80 md:backdrop-blur-sm'
const logoClasses = isScrolled
? 'text-teal-600'
: 'text-white md:text-teal-600'
const linkClasses = isScrolled
? 'text-slate-900 hover:text-slate-700'
: 'text-white hover:text-slate-200 md:mix-blend-difference md:text-white md:hover:opacity-80'
const menuButtonClasses = isScrolled
? 'text-slate-900 focus-visible:outline-slate-900'
: 'text-white focus-visible:outline-white'
return (
<nav
className={`fixed top-0 left-0 right-0 z-[60] transition-colors duration-200 ${navClasses}`}
role="navigation"
aria-label="Hoofdnavigatie"
>
<div className="max-w-7xl mx-auto px-4 md:px-8 py-4 flex justify-between items-center h-16">
{/* Logo */}
<Link
href="/"
className={`flex items-center gap-2 font-black text-xl uppercase font-sans tracking-tight leading-none transition-colors hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:rounded ${logoClasses}`}
aria-label="AI Speedrun Home"
onClick={() => setIsMenuOpen(false)}
>
{content.logo}
<Image
src="/images/aispeedrun-logo.webp"
alt=""
width={40}
height={40}
className="rounded-md"
/>
</Link>
{/* Desktop Navigation Links */}
<div className="hidden md:flex gap-6 items-center" role="list">
{content.links.map((link) => (
<Link
key={link.href}
href={link.href}
className={`font-semibold text-base font-sans transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:rounded ${linkClasses}`}
role="listitem"
>
{link.label}
</Link>
))}
</div>
{/* Mobile Menu Button */}
<button
className={`md:hidden p-2 -mr-2 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:rounded ${menuButtonClasses}`}
onClick={(e) => {
e.stopPropagation()
setIsMenuOpen(!isMenuOpen)
}}
aria-label="Menu"
aria-expanded={isMenuOpen}
aria-controls="mobile-menu"
>
{isMenuOpen ? (
<X className="w-6 h-6" />
) : (
<Menu className="w-6 h-6" />
)}
</button>
</div>
{/* Mobile Menu */}
{isMenuOpen && (
<div
id="mobile-menu"
className={`md:hidden border-t ${
isScrolled
? 'bg-white border-slate-200'
: 'bg-slate-900 border-slate-800'
}`}
onClick={(e) => e.stopPropagation()}
>
<div className="px-4 py-4 space-y-3">
{content.links.map((link) => (
<Link
key={link.href}
href={link.href}
className={`block font-semibold text-base py-2 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:rounded ${
isScrolled
? 'text-slate-900 hover:text-slate-700 focus-visible:outline-slate-900'
: 'text-white hover:text-slate-200 focus-visible:outline-white'
}`}
onClick={() => setIsMenuOpen(false)}
>
{link.label}
</Link>
))}
</div>
</div>
)}
</nav>
)
}

View File

@@ -1,69 +0,0 @@
'use client'
/**
* Reading Progress Bar Component
*
* Fixed top progress indicator that shows scroll progress through the page.
* Smooth animation, 2px height, accent blue color.
* Respects prefers-reduced-motion for accessibility.
*/
import { useEffect, useState } from 'react'
export function ReadingProgress() {
const [progress, setProgress] = useState(0)
const [reducedMotion, setReducedMotion] = useState(false)
useEffect(() => {
// Check for reduced motion preference
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
setReducedMotion(mediaQuery.matches)
const handleChange = (e: MediaQueryListEvent) => setReducedMotion(e.matches)
mediaQuery.addEventListener('change', handleChange)
const updateProgress = () => {
const windowHeight = window.innerHeight
const documentHeight = document.documentElement.scrollHeight
const scrollTop = window.scrollY || document.documentElement.scrollTop
// Calculate scroll progress percentage
const scrollableHeight = documentHeight - windowHeight
const currentProgress = scrollableHeight > 0
? (scrollTop / scrollableHeight) * 100
: 0
setProgress(Math.min(100, Math.max(0, currentProgress)))
}
// Initial calculation
updateProgress()
// Update on scroll
window.addEventListener('scroll', updateProgress, { passive: true })
window.addEventListener('resize', updateProgress, { passive: true })
return () => {
mediaQuery.removeEventListener('change', handleChange)
window.removeEventListener('scroll', updateProgress)
window.removeEventListener('resize', updateProgress)
}
}, [])
return (
<div
className="fixed top-[64px] left-0 right-0 z-50 h-0.5 bg-slate-200"
role="progressbar"
aria-valuenow={Math.round(progress)}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Leesvoortgang"
>
<div
className={`h-full bg-teal-600 ${reducedMotion ? '' : 'transition-all duration-150 ease-out'}`}
style={{ width: `${progress}%` }}
/>
</div>
)
}

View File

@@ -1,51 +0,0 @@
/**
* Statement Section Component
*
* Full-width section with dark background for impactful statements.
* Used to highlight key messages in the manifesto.
*/
import type { StatementSection as StatementSectionType } from '@/content/schemas/manifesto'
interface StatementSectionProps {
section: StatementSectionType
}
export function StatementSection({ section }: StatementSectionProps) {
const isDark = section.variant === 'dark' || !section.variant
return (
<section
className={`w-full ${
isDark ? 'bg-slate-900' : 'bg-slate-50'
}`}
style={{
padding: '4rem 2rem',
}}
aria-labelledby={`statement-${section.id}`}
>
<div className="max-w-4xl mx-auto text-center">
<h2
id={`statement-${section.id}`}
className={`font-bold mb-4 md:mb-6 font-serif ${
isDark ? 'text-white' : 'text-slate-900'
}`}
style={{
fontSize: 'clamp(2rem, 5vw, 4rem)',
lineHeight: 'var(--line-height-tight)',
}}
>
{section.heading}
</h2>
<p
className={`text-lg md:text-xl font-serif leading-relaxed ${
isDark ? 'text-slate-300' : 'text-slate-700'
}`}
>
{section.content}
</p>
</div>
</section>
)
}

View File

@@ -1,326 +0,0 @@
'use client'
import { useState } from 'react'
import { Send, CheckCircle, XCircle } from 'lucide-react'
import type { FormSection } from '@/content/schemas/manifesto'
import type { LeadFormData } from '@/app/api/leads/route'
interface ContactFormProps {
content: FormSection
}
type FormStatus = 'idle' | 'submitting' | 'success' | 'error'
export function ContactForm({ content }: ContactFormProps) {
const [status, setStatus] = useState<FormStatus>('idle')
const [errors, setErrors] = useState<Record<string, string>>({})
const [formData, setFormData] = useState<LeadFormData>({
name: '',
email: '',
company: '',
projectType: '',
budget: '',
message: '',
})
const validateField = (name: keyof LeadFormData, value: string): string | null => {
const field = content.fields[name]
if (field.required && !value.trim()) {
return field.error || `${field.label} is verplicht`
}
if (name === 'email' && value) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(value)) {
return 'Ongeldig email adres'
}
}
if (name === 'message' && value && field.minLength) {
if (value.length < field.minLength) {
return `Minimaal ${field.minLength} karakters vereist`
}
}
return null
}
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
// Clear error on change
if (errors[name]) {
setErrors(prev => {
const newErrors = { ...prev }
delete newErrors[name]
return newErrors
})
}
}
const handleBlur = (
e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const { name, value } = e.target
const error = validateField(name as keyof LeadFormData, value)
if (error) {
setErrors(prev => ({ ...prev, [name]: error }))
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Validate all fields
const newErrors: Record<string, string> = {}
Object.keys(formData).forEach(key => {
const error = validateField(key as keyof LeadFormData, formData[key as keyof LeadFormData] || '')
if (error) {
newErrors[key] = error
}
})
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors)
return
}
// Submit form
setStatus('submitting')
try {
const response = await fetch('/api/leads', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Er ging iets mis')
}
setStatus('success')
// Reset form
setFormData({
name: '',
email: '',
company: '',
projectType: '',
budget: '',
message: '',
})
} catch (error) {
console.error('Form submission error:', error)
setStatus('error')
}
}
// Success state
if (status === 'success') {
return (
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-8 text-center">
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
<h3 className="text-2xl font-bold text-slate-900 mb-2">
{content.success.title}
</h3>
<p className="text-slate-600 mb-6">
{content.success.message}
</p>
<a
href="/"
className="inline-block px-6 py-3 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors"
>
{content.success.cta}
</a>
</div>
)
}
// Error state
if (status === 'error') {
return (
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
<XCircle className="w-16 h-16 text-red-600 mx-auto mb-4" />
<h3 className="text-2xl font-bold text-slate-900 mb-2">
{content.error.title}
</h3>
<p className="text-slate-600 mb-6">
{content.error.message}
</p>
<button
onClick={() => setStatus('idle')}
className="inline-block px-6 py-3 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors"
>
{content.error.retry}
</button>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Name */}
<div>
<label htmlFor="name" className="block text-sm font-medium text-slate-700 mb-2">
{content.fields.name.label} {content.fields.name.required && <span className="text-red-500">*</span>}
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
onBlur={handleBlur}
placeholder={content.fields.name.placeholder}
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors ${
errors.name ? 'border-red-500' : 'border-slate-300'
}`}
disabled={status === 'submitting'}
/>
{errors.name && (
<p className="mt-1 text-sm text-red-600">{errors.name}</p>
)}
</div>
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-700 mb-2">
{content.fields.email.label} {content.fields.email.required && <span className="text-red-500">*</span>}
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
placeholder={content.fields.email.placeholder}
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors ${
errors.email ? 'border-red-500' : 'border-slate-300'
}`}
disabled={status === 'submitting'}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
)}
</div>
{/* Company */}
<div>
<label htmlFor="company" className="block text-sm font-medium text-slate-700 mb-2">
{content.fields.company.label}
</label>
<input
type="text"
id="company"
name="company"
value={formData.company}
onChange={handleChange}
placeholder={content.fields.company.placeholder}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors"
disabled={status === 'submitting'}
/>
</div>
{/* Project Type */}
<div>
<label htmlFor="projectType" className="block text-sm font-medium text-slate-700 mb-2">
{content.fields.projectType.label} {content.fields.projectType.required && <span className="text-red-500">*</span>}
</label>
<select
id="projectType"
name="projectType"
value={formData.projectType}
onChange={handleChange}
onBlur={handleBlur}
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors ${
errors.projectType ? 'border-red-500' : 'border-slate-300'
}`}
disabled={status === 'submitting'}
>
<option value="">{content.fields.projectType.placeholder}</option>
{content.fields.projectType.options?.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
{errors.projectType && (
<p className="mt-1 text-sm text-red-600">{errors.projectType}</p>
)}
</div>
{/* Budget */}
<div>
<label htmlFor="budget" className="block text-sm font-medium text-slate-700 mb-2">
{content.fields.budget.label}
</label>
<select
id="budget"
name="budget"
value={formData.budget}
onChange={handleChange}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors"
disabled={status === 'submitting'}
>
<option value="">{content.fields.budget.placeholder}</option>
{content.fields.budget.options?.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
{/* Message */}
<div>
<label htmlFor="message" className="block text-sm font-medium text-slate-700 mb-2">
{content.fields.message.label} {content.fields.message.required && <span className="text-red-500">*</span>}
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
onBlur={handleBlur}
placeholder={content.fields.message.placeholder}
rows={6}
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors ${
errors.message ? 'border-red-500' : 'border-slate-300'
}`}
disabled={status === 'submitting'}
/>
{errors.message && (
<p className="mt-1 text-sm text-red-600">{errors.message}</p>
)}
<p className="mt-1 text-sm text-slate-500">
{formData.message.length} / {content.fields.message.minLength} minimum
</p>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={status === 'submitting'}
className="w-full px-8 py-4 bg-green-600 hover:bg-green-700 disabled:bg-slate-400 text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
>
{status === 'submitting' ? (
<>
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
{content.buttons.submitting}
</>
) : (
<>
<Send className="w-5 h-5" />
{content.buttons.submit}
</>
)}
</button>
</form>
)
}

View File

@@ -1,55 +0,0 @@
/**
* Contact Page - Under Construction
*/
import type { Metadata } from 'next'
import { Construction } from 'lucide-react'
import Link from 'next/link'
// Generate metadata for SEO
export async function generateMetadata(): Promise<Metadata> {
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
return {
title: 'Contact - AI Speedrun',
description: 'Deze pagina is momenteel in ontwikkeling.',
openGraph: {
type: 'website',
title: 'Contact - AI Speedrun',
description: 'Deze pagina is momenteel in ontwikkeling',
images: [`${siteUrl}/og-image.png`],
siteName: 'AI Speedrun',
locale: 'nl_NL',
},
alternates: {
canonical: `${siteUrl}/contact`,
},
}
}
export default function ContactPage() {
return (
<section className="relative min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-white px-4">
<div className="max-w-2xl mx-auto text-center">
<div className="inline-flex items-center justify-center w-24 h-24 bg-orange-100 rounded-full mb-8">
<Construction className="w-12 h-12 text-orange-600" />
</div>
<h1 className="text-4xl md:text-5xl font-bold text-slate-900 mb-4">
Pagina in ontwikkeling
</h1>
<p className="text-xl text-slate-600 mb-8">
We zijn bezig met het bouwen van deze pagina. Kom binnenkort terug!
</p>
<Link
href="/"
className="inline-flex items-center justify-center px-6 py-3 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors"
>
Terug naar home
</Link>
</div>
</section>
)
}

View File

@@ -1,202 +0,0 @@
/**
* Release Detail Page
*
* Renders individual release note from MDX file
*/
import { notFound } from 'next/navigation'
import { MDXRemote } from 'next-mdx-remote/rsc'
import { getRelease, getAllReleases } from '@/lib/mdx/documentatie'
import { mdxComponents } from '../components/mdx-components'
interface ReleasePageProps {
params: Promise<{
category: string
}>
}
// Generate static params for all releases
export async function generateStaticParams() {
const releases = await getAllReleases()
return releases.map((release) => ({
category: release.slug,
}))
}
// Generate metadata for SEO
export async function generateMetadata({ params }: ReleasePageProps) {
const { category } = await params
const release = await getRelease(category)
if (!release) {
return {
title: 'Release Not Found',
}
}
return {
title: `${release.frontmatter.title} - Documentatie`,
description: release.frontmatter.description,
}
}
function ArticleJsonLd({
title,
description,
releaseDate,
slug,
}: {
title: string
description: string
releaseDate: string
slug: string
}) {
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
const jsonLd = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'Article',
'@id': `${siteUrl}/documentatie/${slug}#article`,
headline: title,
description: description,
datePublished: releaseDate,
dateModified: releaseDate,
author: {
'@type': 'Person',
name: 'Colin van der Heijden',
url: 'https://ikbenlit.nl',
},
publisher: { '@id': `${siteUrl}/#organization` },
mainEntityOfPage: `${siteUrl}/documentatie/${slug}`,
inLanguage: 'nl-NL',
},
{
'@type': 'BreadcrumbList',
'@id': `${siteUrl}/documentatie/${slug}#breadcrumb`,
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Home',
item: siteUrl,
},
{
'@type': 'ListItem',
position: 2,
name: 'Documentatie',
item: `${siteUrl}/documentatie`,
},
{
'@type': 'ListItem',
position: 3,
name: title,
},
],
},
],
}
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
)
}
export default async function ReleasePage({ params }: ReleasePageProps) {
const { category } = await params
const release = await getRelease(category)
if (!release) {
notFound()
}
const { frontmatter, content } = release
return (
<div className="min-h-screen bg-white pb-16">
<ArticleJsonLd
title={frontmatter.title}
description={frontmatter.description}
releaseDate={frontmatter.releaseDate}
slug={category}
/>
<article className="max-w-4xl mx-auto px-4 md:px-8 pt-20 md:pt-20">
{/* Header */}
<header className="mb-8 pb-8 border-b border-slate-200">
<div className="flex items-center gap-3 mb-4">
<StatusBadge status={frontmatter.status} />
<span className="text-sm text-slate-500">v{frontmatter.version}</span>
<span className="text-sm text-slate-500"></span>
<time className="text-sm text-slate-500">
{new Date(frontmatter.releaseDate).toLocaleDateString('nl-NL', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
</div>
<h1 className="text-4xl md:text-5xl font-bold text-slate-900 mb-4">
{frontmatter.title}
</h1>
<p className="text-xl text-slate-600">
{frontmatter.description}
</p>
</header>
{/* MDX Content */}
<div className="prose prose-slate max-w-none">
<MDXRemote source={content} components={mdxComponents} />
</div>
{/* Footer Navigation */}
<footer className="mt-12 pt-8 border-slate-200">
<div className="flex justify-between items-center">
<a
href="/documentatie"
className="text-teal-600 hover:text-teal-700 font-medium flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Terug naar overzicht
</a>
<a
href="/#timeline"
className="text-slate-600 hover:text-slate-700 font-medium"
>
Bekijk timeline
</a>
</div>
</footer>
</article>
</div>
)
}
function StatusBadge({ status }: { status: 'completed' | 'in_progress' | 'planned' }) {
const styles = {
completed: 'bg-teal-100 text-teal-700 border-teal-200',
in_progress: 'bg-amber-100 text-amber-700 border-amber-200',
planned: 'bg-slate-100 text-slate-600 border-slate-200',
}
const labels = {
completed: 'Voltooid',
in_progress: 'Bezig',
planned: 'Gepland',
}
return (
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium border ${styles[status]}`}>
{labels[status]}
</span>
)
}

View File

@@ -1,191 +0,0 @@
/**
* Custom MDX Components
*
* Styled components for rendering MDX content
*/
import Image from 'next/image'
import Link from 'next/link'
import type { MDXComponents } from 'mdx/types'
/**
* Generate slug from heading text for anchor links
*/
function slugify(text: string): string {
return text
.toString()
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w\-]+/g, '')
.replace(/\-\-+/g, '-')
}
/**
* Extract text content from React children
*/
function getTextContent(children: React.ReactNode): string {
if (typeof children === 'string') return children
if (Array.isArray(children)) return children.map(getTextContent).join('')
if (children && typeof children === 'object' && 'props' in children) {
return getTextContent(children.props.children)
}
return ''
}
export const mdxComponents: MDXComponents = {
// Headings with anchor links
h1: ({ children, ...props }) => {
const text = getTextContent(children)
const id = slugify(text)
return (
<h1 id={id} className="text-4xl font-bold text-slate-900 mt-8 mb-4 scroll-mt-20" {...props}>
{children}
</h1>
)
},
h2: ({ children, ...props }) => {
const text = getTextContent(children)
const id = slugify(text)
return (
<h2 id={id} className="text-3xl font-bold text-slate-900 mt-8 mb-4 border-b border-slate-200 pb-2 scroll-mt-20" {...props}>
{children}
</h2>
)
},
h3: ({ children, ...props }) => {
const text = getTextContent(children)
const id = slugify(text)
return (
<h3 id={id} className="text-2xl font-semibold text-slate-900 mt-6 mb-3 scroll-mt-20" {...props}>
{children}
</h3>
)
},
h4: ({ children, ...props }) => {
const text = getTextContent(children)
const id = slugify(text)
return (
<h4 id={id} className="text-xl font-semibold text-slate-900 mt-4 mb-2 scroll-mt-20" {...props}>
{children}
</h4>
)
},
// Paragraphs
p: ({ children, ...props }) => (
<div className="text-slate-700 leading-relaxed mb-4" {...props}>
{children}
</div>
),
// Lists
ul: ({ children, ...props }) => (
<ul className="list-disc list-inside space-y-2 mb-4 text-slate-700" {...props}>
{children}
</ul>
),
ol: ({ children, ...props }) => (
<ol className="list-decimal list-inside space-y-2 mb-4 text-slate-700" {...props}>
{children}
</ol>
),
li: ({ children, ...props }) => (
<li className="ml-4" {...props}>
{children}
</li>
),
// Links
a: ({ href, children, ...props }) => (
<Link
href={href || '#'}
className="text-teal-600 hover:text-teal-700 underline underline-offset-2"
{...props}
>
{children}
</Link>
),
// Images
img: ({ src, alt, width, height, ...props }) => {
if (!src) return null
return (
<figure className="my-8">
<div className="relative rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
<Image
src={src}
alt={alt || ''}
width={Number(width) || 1200}
height={Number(height) || 675}
className="w-full h-auto"
{...props}
/>
</div>
{alt && (
<figcaption className="text-sm text-slate-500 italic mt-2 text-center">
{alt}
</figcaption>
)}
</figure>
)
},
// Code blocks
pre: ({ children, ...props }) => (
<pre className="bg-slate-900 text-slate-50 rounded-lg p-4 overflow-x-auto mb-4 text-sm" {...props}>
{children}
</pre>
),
code: ({ children, ...props }) => (
<code className="bg-slate-100 text-slate-800 px-1.5 py-0.5 rounded text-sm font-mono" {...props}>
{children}
</code>
),
// Blockquote
blockquote: ({ children, ...props }) => (
<blockquote className="border-l-4 border-teal-500 pl-4 py-2 my-4 bg-teal-50 text-slate-700 italic" {...props}>
{children}
</blockquote>
),
// Horizontal rule
hr: (props) => (
<hr className="my-8 border-slate-200" {...props} />
),
// Table
table: ({ children, ...props }) => (
<div className="overflow-x-auto my-6">
<table className="min-w-full divide-y divide-slate-200" {...props}>
{children}
</table>
</div>
),
th: ({ children, ...props }) => (
<th className="px-4 py-3 bg-slate-50 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider" {...props}>
{children}
</th>
),
td: ({ children, ...props }) => (
<td className="px-4 py-3 text-sm text-slate-700 border-t border-slate-200" {...props}>
{children}
</td>
),
// Strong/Bold
strong: ({ children, ...props }) => (
<strong className="font-semibold text-slate-900" {...props}>
{children}
</strong>
),
// Emphasis/Italic
em: ({ children, ...props }) => (
<em className="italic" {...props}>
{children}
</em>
),
}

View File

@@ -1,23 +0,0 @@
'use client'
/**
* Release Sidebar Wrapper
*
* Simple wrapper to avoid webpack module resolution issues
*/
import { ReleaseSidebar } from './release-sidebar'
import type { ReleaseNote, GroupMetadata, CategoryMetadata, TocItem } from '@/lib/mdx/documentatie'
interface ReleaseSidebarWrapperProps {
releases: ReleaseNote[]
metadata: {
groups: GroupMetadata[]
categories: CategoryMetadata[]
}
tocMap: Record<string, TocItem[]>
}
export default function ReleaseSidebarWrapper(props: ReleaseSidebarWrapperProps) {
return <ReleaseSidebar {...props} />
}

View File

@@ -1,304 +0,0 @@
'use client'
/**
* Release Sidebar Navigation
*
* Fixed sidebar with grouped list of all releases
* Auto-generated from MDX files
*/
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { ChevronRight, ChevronDown, ChevronLeft, X } from 'lucide-react'
import { useState, useEffect } from 'react'
import type { ReleaseNote, GroupMetadata, CategoryMetadata, TocItem } from '@/lib/mdx/documentatie'
interface ReleaseSidebarProps {
releases: ReleaseNote[]
metadata: {
groups: GroupMetadata[]
categories: CategoryMetadata[]
}
tocMap: Record<string, TocItem[]>
}
export function ReleaseSidebar({ releases, metadata, tocMap }: ReleaseSidebarProps) {
const pathname = usePathname()
const [isExpanded, setIsExpanded] = useState(false)
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({
foundation: true,
architecture: true,
features: true,
infrastructure: true,
bugs: true,
})
// Auto-close sidebar on navigation
useEffect(() => {
setIsExpanded(false)
}, [pathname])
const toggleGroup = (groupId: string) => {
setExpandedGroups(prev => ({
...prev,
[groupId]: !prev[groupId],
}))
}
// Group releases by their group
const releasesByGroup = releases.reduce((acc, release) => {
const group = release.frontmatter.group
if (!acc[group]) acc[group] = []
acc[group].push(release)
return acc
}, {} as Record<string, ReleaseNote[]>)
// Check if we're on a specific release page
const isReleaseDetail = pathname.startsWith('/documentatie/') && pathname !== '/documentatie'
return (
<>
{/* MOBILE: Overlay when expanded */}
{isExpanded && (
<div
className="lg:hidden fixed inset-0 bg-black/40 backdrop-blur-sm z-30 top-16"
onClick={() => setIsExpanded(false)}
aria-hidden="true"
/>
)}
{/* MOBILE: Collapsible sidebar from left */}
<aside
className={`
lg:hidden
fixed top-16 bottom-0 left-0 z-40
bg-white border-r border-slate-200
transition-all duration-300 ease-in-out
${isExpanded ? 'w-80 shadow-2xl' : 'w-12'}
`}
>
{/* Collapsed state: Icon bar */}
{!isExpanded && (
<div className="flex flex-col items-center pt-6">
<button
onClick={() => setIsExpanded(true)}
className="p-2 rounded-lg hover:bg-teal-50 transition-colors group"
aria-label="Open documentatie menu"
>
<ChevronRight className="w-6 h-6 text-slate-600 group-hover:text-teal-600 transition-colors" />
</button>
</div>
)}
{/* Expanded state: Full navigation */}
{isExpanded && (
<div className="p-6 overflow-y-auto h-full">
{/* Header met close button */}
<div className="flex items-center justify-between mb-6 pb-4 border-b border-slate-200">
<h2 className="text-lg font-bold text-slate-900">Documentatie</h2>
<button
onClick={() => setIsExpanded(false)}
className="p-1.5 rounded-lg hover:bg-slate-100 transition-colors group"
aria-label="Sluit menu"
>
<ChevronLeft className="w-5 h-5 text-slate-500 group-hover:text-slate-700 transition-colors" />
</button>
</div>
<nav className="space-y-1">
{/* Overzicht link */}
<Link
href="/documentatie"
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
pathname === '/documentatie' && !isReleaseDetail
? 'bg-teal-50 text-teal-700'
: 'text-slate-700 hover:bg-slate-50'
}`}
>
<span>Overzicht</span>
</Link>
{/* Grouped releases */}
{metadata?.groups && metadata.groups
.sort((a, b) => a.order - b.order)
.map((group) => {
const groupReleases = releasesByGroup[group.id] || []
const isGroupExpanded = expandedGroups[group.id]
return (
<div key={group.id} className="space-y-1">
{/* Group header */}
<button
onClick={() => toggleGroup(group.id)}
className="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wide hover:text-slate-700 transition-colors"
>
<span>{group.title}</span>
{isGroupExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
{/* Group items */}
{isGroupExpanded && (
<div className="space-y-1 ml-2">
{groupReleases.map((release) => {
const isActive = pathname === `/documentatie/${release.slug}`
const toc = tocMap[release.slug] || []
return (
<div key={release.slug}>
<Link
href={`/documentatie/${release.slug}`}
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors group ${
isActive
? 'bg-teal-50 text-teal-700'
: 'text-slate-700 hover:bg-slate-50'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<StatusDot status={release.frontmatter.status} />
<span className="whitespace-normal">{release.frontmatter.title}</span>
</div>
<ChevronRight className={`w-4 h-4 flex-shrink-0 transition-opacity ${
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-50'
}`} />
</Link>
{/* Table of Contents for active page */}
{isActive && toc.length > 0 && (
<div className="ml-4 mt-1 space-y-1 border-l-2 border-teal-200">
{toc.map((item) => (
<a
key={item.id}
href={`#${item.id}`}
className={`block py-1.5 text-xs text-slate-600 hover:text-teal-600 transition-colors ${
item.level === 2 ? 'pl-3 font-medium' : 'pl-6'
}`}
>
{item.text}
</a>
))}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
})}
</nav>
</div>
)}
</aside>
{/* DESKTOP: Fixed sidebar */}
<aside className="hidden lg:block w-80 fixed top-16 bottom-0 bg-white border-r border-slate-200 overflow-y-auto">
<div className="p-6">
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-4">
Documentatie
</h2>
<nav className="space-y-1">
{/* Overview link */}
<Link
href="/documentatie"
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors ${pathname === '/documentatie' && !isReleaseDetail
? 'bg-teal-50 text-teal-700'
: 'text-slate-700 hover:bg-slate-50'
}`}
>
<span>Overzicht</span>
</Link>
{/* Grouped releases */}
{metadata?.groups && metadata.groups
.sort((a, b) => a.order - b.order)
.map((group) => {
const groupReleases = releasesByGroup[group.id] || []
const isExpanded = expandedGroups[group.id]
return (
<div key={group.id} className="space-y-1">
{/* Group header */}
<button
onClick={() => toggleGroup(group.id)}
className="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wide hover:text-slate-700 transition-colors"
>
<span>{group.title}</span>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
{/* Group items */}
{isExpanded && (
<div className="space-y-1 ml-2">
{groupReleases.map((release) => {
const isActive = pathname === `/documentatie/${release.slug}`
const toc = tocMap[release.slug] || []
return (
<div key={release.slug}>
<Link
href={`/documentatie/${release.slug}`}
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium transition-colors group ${isActive
? 'bg-teal-50 text-teal-700'
: 'text-slate-700 hover:bg-slate-50'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<StatusDot status={release.frontmatter.status} />
<span className="whitespace-normal">{release.frontmatter.title}</span>
</div>
<ChevronRight className={`w-4 h-4 flex-shrink-0 transition-opacity ${isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-50'
}`} />
</Link>
{/* Table of Contents for active page */}
{isActive && toc.length > 0 && (
<div className="ml-4 mt-1 space-y-1 border-l-2 border-teal-200">
{toc.map((item) => (
<a
key={item.id}
href={`#${item.id}`}
className={`block py-1.5 text-xs text-slate-600 hover:text-teal-600 transition-colors ${
item.level === 2 ? 'pl-3 font-medium' : 'pl-6'
}`}
>
{item.text}
</a>
))}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
})}
</nav>
</div>
</aside>
</>
)
}
function StatusDot({ status }: { status: 'completed' | 'in_progress' | 'planned' }) {
const colors = {
completed: 'bg-teal-500',
in_progress: 'bg-amber-500',
planned: 'bg-slate-300',
}
return (
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${colors[status]}`} />
)
}

View File

@@ -1,51 +0,0 @@
/**
* Releases Layout
*
* Layout for release notes pages with sidebar navigation
*/
import type { ReactNode } from 'react'
import { getAllReleases, getCategoryMetadata, extractHeadings, type ReleaseNote, type TocItem } from '@/lib/mdx/documentatie'
import ReleaseSidebarWrapper from './components/release-sidebar-wrapper'
interface ReleasesLayoutProps {
children: ReactNode
}
export default async function ReleasesLayout({ children }: ReleasesLayoutProps) {
let releases: ReleaseNote[] = []
let metadata = { groups: [], categories: [] } as Awaited<ReturnType<typeof getCategoryMetadata>>
let tocMap: Record<string, TocItem[]> = {}
try {
releases = await getAllReleases()
metadata = await getCategoryMetadata()
// Extract headings for each release
tocMap = releases.reduce((acc, release) => {
acc[release.slug] = extractHeadings(release.content)
return acc
}, {} as Record<string, TocItem[]>)
} catch (error) {
console.error('Error loading releases or metadata:', error)
}
return (
<div className="min-h-screen bg-slate-50">
{/* Mobile sidebar is sticky, so we need padding for header only */}
<div className="pt-16 lg:pt-0">
<div className="max-w-[1600px] mx-auto">
<div className="flex">
{/* Sidebar - collapsible on mobile (48px collapsed), fixed on desktop (320px) */}
<ReleaseSidebarWrapper releases={releases} metadata={metadata} tocMap={tocMap} />
{/* Main Content - margin for collapsed sidebar on mobile, fixed sidebar on desktop */}
<div className="flex-1 ml-12 lg:ml-80">
{children}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,155 +0,0 @@
/**
* Releases Overview Page
*
* Landing page for all release notes grouped by category
*/
import Link from 'next/link'
import { getAllReleases } from '@/lib/mdx/documentatie'
export const metadata = {
title: 'Documentatie - AI Speedrun',
description: 'Feature documentatie van het Mini-ECD prototype. Transparant build-in-public overzicht van gebouwde functionaliteit.',
}
export default async function ReleasesPage() {
const releases = await getAllReleases()
// Group by status for better overview
const completed = releases.filter(r => r.frontmatter.status === 'completed')
const inProgress = releases.filter(r => r.frontmatter.status === 'in_progress')
const planned = releases.filter(r => r.frontmatter.status === 'planned')
return (
<div className="min-h-screen bg-slate-50 pb-16">
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8">
{/* Header */}
<div className="mb-8 md:mb-12 pt-20 md:pt-20">
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-slate-900 mb-3 md:mb-4">
Feature Documentatie
</h1>
<p className="text-lg md:text-xl text-slate-600 max-w-3xl leading-relaxed">
Uitgebreide documentatie van alle gebouwde functionaliteit. Build in public met transparantie over features, implementatie en kosten.
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-12">
<div className="bg-white rounded-lg border border-slate-200 p-6 text-center shadow-sm">
<div className="text-4xl md:text-5xl font-bold text-teal-600 mb-2">{completed.length}</div>
<div className="text-sm font-medium text-slate-600">Voltooid</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-6 text-center shadow-sm">
<div className="text-4xl md:text-5xl font-bold text-amber-600 mb-2">{inProgress.length}</div>
<div className="text-sm font-medium text-slate-600">In Progress</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-6 text-center shadow-sm">
<div className="text-4xl md:text-5xl font-bold text-slate-400 mb-2">{planned.length}</div>
<div className="text-sm font-medium text-slate-600">Gepland</div>
</div>
</div>
{/* Completed Releases */}
{completed.length > 0 && (
<section className="mb-10 md:mb-12">
<h2 className="text-xl md:text-2xl font-bold text-slate-900 mb-4 md:mb-6">
Voltooid
</h2>
<div className="space-y-4">
{completed.map((release) => (
<ReleaseCard key={release.slug} release={release} />
))}
</div>
</section>
)}
{/* In Progress Releases */}
{inProgress.length > 0 && (
<section className="mb-10 md:mb-12">
<h2 className="text-xl md:text-2xl font-bold text-slate-900 mb-4 md:mb-6">
🔄 In Progress
</h2>
<div className="space-y-4">
{inProgress.map((release) => (
<ReleaseCard key={release.slug} release={release} />
))}
</div>
</section>
)}
{/* Planned Releases */}
{planned.length > 0 && (
<section className="mb-10 md:mb-12">
<h2 className="text-xl md:text-2xl font-bold text-slate-900 mb-4 md:mb-6">
Gepland
</h2>
<div className="space-y-4">
{planned.map((release) => (
<ReleaseCard key={release.slug} release={release} />
))}
</div>
</section>
)}
</div>
</div>
)
}
function ReleaseCard({ release }: { release: any }) {
return (
<Link
href={`/documentatie/${release.slug}`}
className="block bg-white rounded-lg border border-slate-200 p-5 md:p-6 hover:border-teal-300 hover:shadow-md transition-all active:scale-[0.98]"
>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4">
<div className="flex-1 min-w-0">
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 mb-2">
<h3 className="text-lg md:text-xl font-bold text-slate-900 leading-tight">
{release.frontmatter.title}
</h3>
<StatusBadge status={release.frontmatter.status} />
</div>
<p className="text-slate-600 mb-3 text-sm md:text-base leading-relaxed">
{release.frontmatter.description}
</p>
<div className="flex flex-wrap items-center gap-2 md:gap-4 text-xs md:text-sm text-slate-500">
<span className="capitalize">{release.frontmatter.group.replace('-', ' ')}</span>
<span className="hidden sm:inline"></span>
<span>v{release.frontmatter.version}</span>
<span className="hidden sm:inline"></span>
<span className="whitespace-nowrap">{new Date(release.frontmatter.releaseDate).toLocaleDateString('nl-NL', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}</span>
</div>
</div>
<div className="text-slate-400 flex-shrink-0 self-start sm:self-center">
<svg className="w-5 h-5 md:w-6 md:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</Link>
)
}
function StatusBadge({ status }: { status: 'completed' | 'in_progress' | 'planned' }) {
const styles = {
completed: 'bg-teal-100 text-teal-700 border-teal-200',
in_progress: 'bg-amber-100 text-amber-700 border-amber-200',
planned: 'bg-slate-100 text-slate-600 border-slate-200',
}
const labels = {
completed: 'Voltooid',
in_progress: 'Bezig',
planned: 'Gepland',
}
return (
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium border ${styles[status]}`}>
{labels[status]}
</span>
)
}

View File

@@ -1,45 +0,0 @@
/**
* Marketing Layout
*
* Full-width layout without sidebar for marketing pages.
* Includes minimal navigation and reading progress bar.
*
* Performance optimizations:
* - ReadingProgress is a client component, automatically code-split by Next.js
*/
import type { ReactNode } from 'react'
import { getContent } from '@/lib/content/loader'
import type { NavigationContent } from '@/content/schemas/manifesto'
import { MinimalNav } from './components/minimal-nav'
import { ReadingProgress } from './components/reading-progress'
interface MarketingLayoutProps {
children: ReactNode
}
export default async function MarketingLayout({ children }: MarketingLayoutProps) {
// Load navigation content
const navigationContent = await getContent<NavigationContent>('nl', 'navigation')
return (
<div className="min-h-screen bg-white">
{/* Skip to main content link for accessibility */}
<a href="#main-content" className="skip-to-main">
Spring naar hoofdinhoud
</a>
{/* Minimal Navigation - fixed top */}
<MinimalNav content={navigationContent} />
{/* Reading progress bar - fixed top, below nav */}
{/* Client component, automatically code-split by Next.js */}
<ReadingProgress />
<main id="main-content" tabIndex={-1}>
{children}
</main>
</div>
)
}

View File

@@ -1,239 +0,0 @@
/**
* Homepage - AI Speedrun v2.1
*
* Vereenvoudigde landing page met:
* - Hero quote (Jensen Huang)
* - Statement section (Software on Demand concept)
* - Timeline (coming in E1.S3)
* - CTA naar /login
*
* Performance optimizations:
* - Critical content (Hero, Statement) loads immediately
* - Timeline will be lazy loaded when added
*/
import type { Metadata } from 'next'
import { Suspense } from 'react'
import { getContent } from '@/lib/content/loader'
import type { MetadataContent } from '@/content/schemas/manifesto'
import { HeroSectionClient } from './components/hero-section-client'
import { BuildTimeline } from './components/build-timeline'
import { WhyMe } from '@/components/ui/why-me'
import { AuthCodeHandler } from './components/auth-code-handler'
// Generate metadata for SEO
export async function generateMetadata(): Promise<Metadata> {
const metadataContent = await getContent<MetadataContent>('nl', 'metadata')
const meta = metadataContent.manifesto
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
const ogImageUrl = `${siteUrl}${meta.ogImage}`
return {
title: meta.title,
description: meta.description,
keywords: meta.keywords,
authors: [{ name: 'Colin van der Heijden' }],
openGraph: {
type: 'article',
title: meta.ogTitle,
description: meta.ogDescription,
images: [
{
url: ogImageUrl,
width: 1200,
height: 630,
alt: meta.ogTitle,
},
],
siteName: 'AI Speedrun',
locale: 'nl_NL',
},
twitter: {
card: 'summary_large_image',
title: meta.ogTitle,
description: meta.ogDescription,
images: [ogImageUrl],
},
alternates: {
canonical: siteUrl,
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
}
}
// Content interfaces
interface HeroContent {
title: {
main: string
accent: string
}
subtitle: string
punchline: {
question: string
answer: string
}
callToAction: {
primary: {
text: string
href: string
}
secondary: {
text: string
href: string
}
}
backgroundImage: string
}
interface StatementContent {
hero: HeroContent
}
interface Feature {
title: string
description: string
time?: string
traditional?: string
icon: "Rocket" | "Database" | "Sparkles" | "Palette" | "Calendar" | "Layout" | "Users" | "Smartphone" | "FileText" | "Brain" | "Tags" | "Target" | "HelpCircle" | "Zap" | "Eye"
}
interface WeekData {
weekNumber: number
title: string
status: "completed" | "in_progress" | "planned"
description: string
features: Feature[]
metrics: {
hours: string
cost: string
linesOfCode: string
}
achievements: string[]
}
interface TimelineContent {
heading: string
description: string
weeks: WeekData[]
}
export default async function HomePage() {
// Load content
const manifestoContent = await getContent<StatementContent>('nl', 'manifesto')
const timelineContent = await getContent<TimelineContent>('nl', 'timeline')
const heroContent = manifestoContent.hero
return (
<>
{/* Handle auth codes that Supabase redirects to home page */}
<Suspense fallback={null}>
<AuthCodeHandler />
</Suspense>
{/* Hero Section */}
<HeroSectionClient
title={
<>
{heroContent.title.main}{' '}
<span className="text-teal-600 block mt-2">
{heroContent.title.accent}
</span>
</>
}
subtitle={heroContent.subtitle}
punchline={
<>
{heroContent.punchline.question}
<br />
<br />
<strong>{heroContent.punchline.answer}</strong>
</>
}
callToAction={heroContent.callToAction.primary}
secondaryCallToAction={heroContent.callToAction.secondary}
backgroundImage={heroContent.backgroundImage}
/>
{/* Statement Section - Software on Demand */}
<section className="w-full md:max-w-[750px] mx-auto px-4 md:px-16 py-16 md:py-24">
<div className="prose prose-slate max-w-none">
<h2 className="font-serif text-3xl md:text-4xl font-bold text-slate-900 mb-8">
Software on Demand: Van 100k naar 200
</h2>
<div className="space-y-6 text-lg leading-relaxed text-slate-700">
<p>
<strong className="text-slate-900">Het probleem:</strong> Enterprise software kost 100.000+ per jaar
en implementaties duren 12-24 maanden. Je betaalt voor potentieel, niet voor werkelijke waarde.
Vendors optimaliseren voor meer seats, meer modules, meer lock-in.
</p>
<p>
<strong className="text-slate-900">De oplossing:</strong> AI-powered development verkort dit naar
4 weken en 200 totale kosten. Niet omdat AI magisch is, maar omdat je 80% van de standaard-code
niet meer hoeft te schrijven. De kostenbasis verschuift compleet - geen armies van consultants,
geen jarenlange developmenttrajecten.
</p>
<p>
<strong className="text-slate-900">Het bewijs:</strong> Dit EPD prototype is het levende bewijs.
Gebouwd in 4 weken, volledig transparant, build in public. Infrastructuur die schaalt met gebruik,
AI die de heavy lifting doet. En het belangrijkste: je bezit de code, je controleert de roadmap.
</p>
<p className="text-teal-700 font-medium text-xl pt-4">
Volg de voortgang hieronder en zie hoe elk onderdeel tot leven komt
</p>
</div>
</div>
</section>
{/* Timeline Section */}
<section id="timeline">
<BuildTimeline data={timelineContent} />
</section>
{/* About Me Section */}
<WhyMe />
{/* CTA Section */}
<section className="py-24 px-4 text-center bg-gradient-to-br from-slate-50 to-white">
<div className="max-w-3xl mx-auto">
<h2 className="text-3xl md:text-4xl font-bold text-slate-900 mb-4">
Klaar om het prototype te proberen?
</h2>
<p className="text-lg text-slate-600 mb-8 max-w-2xl mx-auto">
Login om toegang te krijgen tot de EPD applicatie en zie AI-gestuurde workflows in actie
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="/login"
className="inline-block px-8 py-3 bg-teal-600 hover:bg-teal-700 text-white font-medium rounded-lg transition-colors"
>
Probeer het prototype
</a>
<a
href="#timeline"
className="inline-block px-8 py-3 bg-white hover:bg-slate-50 text-slate-700 font-medium rounded-lg border-2 border-slate-300 transition-colors"
>
Bekijk voortgang
</a>
</div>
</div>
</section>
</>
)
}

View File

@@ -1,116 +0,0 @@
/**
* Leads API Endpoint
*
* POST /api/leads - Submit contact form lead
*
* Security:
* - Rate limiting via headers
* - Input validation
* - Server-side Supabase client
*/
import { NextRequest, NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase/server'
import { z } from 'zod'
// Validation schema
const leadSchema = z.object({
name: z.string().min(2, 'Naam moet minimaal 2 karakters zijn'),
email: z.string().email('Ongeldig email adres'),
company: z.string().optional(),
projectType: z.string().min(1, 'Selecteer een project type'),
budget: z.string().optional(),
message: z.string().min(20, 'Beschrijving moet minimaal 20 karakters zijn'),
})
export type LeadFormData = z.infer<typeof leadSchema>
export async function POST(request: NextRequest) {
try {
// Parse request body
const body = await request.json()
// Validate input
const validatedData = leadSchema.parse(body)
// Get client IP and user agent for tracking
const ip = request.headers.get('x-forwarded-for') ||
request.headers.get('x-real-ip') ||
'unknown'
const userAgent = request.headers.get('user-agent') || 'unknown'
const referrer = request.headers.get('referer') || 'direct'
// Insert lead into database (using admin client for server-side insert)
const { data, error } = await supabaseAdmin
.from('leads')
.insert({
name: validatedData.name,
email: validatedData.email,
company: validatedData.company || null,
project_type: validatedData.projectType,
budget: validatedData.budget || null,
message: validatedData.message,
status: 'new',
ip_address: ip,
user_agent: userAgent,
referrer: referrer,
source: 'website',
})
.select()
.single()
if (error) {
console.error('Supabase error:', error)
return NextResponse.json(
{ error: 'Database error', details: error.message },
{ status: 500 }
)
}
// Return success
return NextResponse.json(
{
success: true,
message: 'Lead ontvangen',
leadId: data.id
},
{ status: 201 }
)
} catch (error) {
// Validation error
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'Validatie fout',
issues: error.issues.map(e => ({
field: e.path.join('.'),
message: e.message
}))
},
{ status: 400 }
)
}
// Generic error
console.error('Unexpected error:', error)
return NextResponse.json(
{ error: 'Interne server fout' },
{ status: 500 }
)
}
}
// OPTIONS for CORS (if needed)
export async function OPTIONS() {
return NextResponse.json(
{},
{
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
}
)
}

View File

@@ -1,31 +0,0 @@
import { redirect } from 'next/navigation';
import { NextRequest } from 'next/server';
/**
* Catch-all Redirect Route for /epd/clients/
*
* Redirects all /epd/clients/* routes to /epd/patients/* for backward compatibility
* This ensures that old bookmarks and links continue to work after migration.
*
* Preserves query parameters (e.g., ?tab=intake&search=test)
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params;
const searchParams = request.nextUrl.searchParams;
// Build new path with query parameters
const newPath = `/epd/patients/${path.join('/')}`;
const newUrl = new URL(newPath, request.url);
// Preserve all query parameters
searchParams.forEach((value, key) => {
newUrl.searchParams.set(key, value);
});
redirect(newUrl.toString());
}

View File

@@ -1,91 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { FileText, User, Target } from 'lucide-react';
import { IntakeTab } from './intake-tab';
import { ProfileTab } from './profile-tab';
import { PlanTab } from './plan-tab';
interface ClientTabsProps {
clientId: string;
activeTab?: string;
}
type TabId = 'intake' | 'profile' | 'plan';
const tabs = [
{
id: 'intake' as TabId,
label: 'Intake',
icon: FileText,
description: 'Intake gesprekken en notities',
},
{
id: 'profile' as TabId,
label: 'Profiel',
icon: User,
description: 'Probleemprofielen en DSM categorieën',
},
{
id: 'plan' as TabId,
label: 'Behandelplan',
icon: Target,
description: 'Behandeldoelen en interventies',
},
];
export function ClientTabs({ clientId, activeTab }: ClientTabsProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [currentTab, setCurrentTab] = useState<TabId>(
(activeTab as TabId) || 'intake'
);
const handleTabChange = (tabId: TabId) => {
setCurrentTab(tabId);
const params = new URLSearchParams(searchParams.toString());
params.set('tab', tabId);
router.push(`/epd/clients/${clientId}?${params.toString()}`);
};
return (
<div className="space-y-6">
{/* Tab Navigation */}
<div className="bg-white rounded-lg border border-slate-200 p-1">
<div className="flex gap-1">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = currentTab === tab.id;
return (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={`
flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-md text-sm font-medium transition-all
${
isActive
? 'bg-teal-50 text-teal-700 shadow-sm'
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
}
`}
>
<Icon className="h-4 w-4" />
<span className="hidden sm:inline">{tab.label}</span>
<span className="sm:hidden">{tab.label}</span>
</button>
);
})}
</div>
</div>
{/* Tab Content */}
<div className="bg-white rounded-lg border border-slate-200">
{currentTab === 'intake' && <IntakeTab clientId={clientId} />}
{currentTab === 'profile' && <ProfileTab clientId={clientId} />}
{currentTab === 'plan' && <PlanTab clientId={clientId} />}
</div>
</div>
);
}

View File

@@ -1,56 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { Plus } from 'lucide-react';
import Link from 'next/link';
import { getIntakesByClientId, Intake } from '../intakes/actions';
import { IntakeList } from '../intakes/components/intake-list';
interface IntakeTabProps {
clientId: string;
}
export function IntakeTab({ clientId }: IntakeTabProps) {
const [intakes, setIntakes] = useState<Intake[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function fetchIntakes() {
try {
const data = await getIntakesByClientId(clientId);
setIntakes(data);
} catch (error) {
console.error('Failed to fetch intakes:', error);
} finally {
setIsLoading(false);
}
}
fetchIntakes();
}, [clientId]);
return (
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Intakes
</h2>
<p className="text-sm text-slate-600 mt-1">
Overzicht van alle intakes
</p>
</div>
<Link
href={`/epd/clients/${clientId}/intakes/new`}
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 text-white font-medium rounded-lg hover:bg-teal-700 transition-colors"
>
<Plus className="h-4 w-4" />
<span>Nieuwe Intake</span>
</Link>
</div>
<IntakeList intakes={intakes} clientId={clientId} isLoading={isLoading} />
</div>
);
}

View File

@@ -1,143 +0,0 @@
'use client';
import { Target, Sparkles, Clock } from 'lucide-react';
interface PlanTabProps {
clientId: string;
}
export function PlanTab({ clientId }: PlanTabProps) {
return (
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Behandelplan
</h2>
<p className="text-sm text-slate-600 mt-1">
SMART doelen en interventies
</p>
</div>
<button
disabled
className="inline-flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-400 font-medium rounded-lg cursor-not-allowed"
title="Beschikbaar in Week 3"
>
<Sparkles className="h-4 w-4" />
<span>Genereer Plan</span>
</button>
</div>
{/* Coming Soon State */}
<div className="py-12 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-amber-50 mb-4">
<Target className="h-8 w-8 text-amber-500" />
</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">
Coming Soon - Week 3
</h3>
<p className="text-sm text-slate-600 max-w-md mx-auto mb-6">
AI-gegenereerde behandelplannen met SMART doelen en evidence-based
interventies worden toegevoegd in Week 3.
</p>
{/* Feature Preview */}
<div className="max-w-2xl mx-auto">
<div className="bg-slate-50 rounded-lg border border-slate-200 p-6 text-left">
<h4 className="font-medium text-slate-900 mb-3">
🎯 Plan Structuur:
</h4>
<div className="space-y-4">
{/* SMART Doelen */}
<div className="p-4 bg-white rounded-lg border border-slate-200">
<h5 className="font-medium text-slate-800 mb-2">
1. SMART Doelen
</h5>
<p className="text-sm text-slate-600">
Specifieke, Meetbare, Acceptabele, Realistische en
Tijdgebonden behandeldoelen
</p>
</div>
{/* Interventies */}
<div className="p-4 bg-white rounded-lg border border-slate-200">
<h5 className="font-medium text-slate-800 mb-2">
2. Interventies
</h5>
<p className="text-sm text-slate-600">
Evidence-based behandelmethoden (CGT, ACT, EMDR, etc.)
</p>
</div>
{/* Frequentie */}
<div className="p-4 bg-white rounded-lg border border-slate-200">
<h5 className="font-medium text-slate-800 mb-2">
3. Frequentie
</h5>
<p className="text-sm text-slate-600">
Sessie planning en behandelintensiteit
</p>
</div>
{/* Meetmomenten */}
<div className="p-4 bg-white rounded-lg border border-slate-200">
<h5 className="font-medium text-slate-800 mb-2">
4. Meetmomenten
</h5>
<p className="text-sm text-slate-600">
Evaluatie en voortgangsmetingen
</p>
</div>
</div>
<h4 className="font-medium text-slate-900 mb-3 mt-6">
Geplande Features:
</h4>
<ul className="space-y-2 text-sm text-slate-700">
<li className="flex items-start gap-2">
<span className="text-teal-600 font-bold"></span>
<span>
<strong>AI Plan Generator</strong> - Gebaseerd op intake +
profiel
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-teal-600 font-bold"></span>
<span>
<strong>Versioning</strong> - Meerdere versies per cliënt
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-teal-600 font-bold"></span>
<span>
<strong>Status Tracking</strong> - Concept vs. Gepubliceerd
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-teal-600 font-bold"></span>
<span>
<strong>JSONB Opslag</strong> - Flexibele datastructuur
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-teal-600 font-bold"></span>
<span>
<strong>Export Functie</strong> - PDF generatie voor dossier
</span>
</li>
</ul>
</div>
</div>
{/* Timeline Preview */}
<div className="mt-8 inline-flex items-center gap-2 px-4 py-2 bg-teal-50 border border-teal-200 rounded-full text-sm">
<Clock className="h-4 w-4 text-teal-600" />
<span className="text-teal-800">
<strong>Week 3:</strong> 18-24 November 2024
</span>
</div>
</div>
</div>
);
}

View File

@@ -1,122 +0,0 @@
'use client';
import { User, Brain, Clock } from 'lucide-react';
interface ProfileTabProps {
clientId: string;
}
export function ProfileTab({ clientId }: ProfileTabProps) {
return (
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Probleemprofiel
</h2>
<p className="text-sm text-slate-600 mt-1">
DSM-light categorisatie en ernst indicatie
</p>
</div>
<button
disabled
className="inline-flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-400 font-medium rounded-lg cursor-not-allowed"
title="Beschikbaar in Week 3"
>
<Brain className="h-4 w-4" />
<span>AI Analyse</span>
</button>
</div>
{/* Coming Soon State */}
<div className="py-12 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-amber-50 mb-4">
<User className="h-8 w-8 text-amber-500" />
</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">
Coming Soon - Week 3
</h3>
<p className="text-sm text-slate-600 max-w-md mx-auto mb-6">
AI-gestuurde probleemclassificatie met DSM-light categorieën wordt
toegevoegd in Week 3.
</p>
{/* Feature Preview */}
<div className="max-w-2xl mx-auto">
<div className="bg-slate-50 rounded-lg border border-slate-200 p-6 text-left">
<h4 className="font-medium text-slate-900 mb-3">
🎯 DSM-light Categorieën:
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2 p-3 bg-white rounded-lg border border-slate-200">
<div className="h-2 w-2 rounded-full bg-blue-500" />
<span className="text-slate-700">Stemming & Depressie</span>
</div>
<div className="flex items-center gap-2 p-3 bg-white rounded-lg border border-slate-200">
<div className="h-2 w-2 rounded-full bg-purple-500" />
<span className="text-slate-700">Angst</span>
</div>
<div className="flex items-center gap-2 p-3 bg-white rounded-lg border border-slate-200">
<div className="h-2 w-2 rounded-full bg-red-500" />
<span className="text-slate-700">Gedrag & Impuls</span>
</div>
<div className="flex items-center gap-2 p-3 bg-white rounded-lg border border-slate-200">
<div className="h-2 w-2 rounded-full bg-orange-500" />
<span className="text-slate-700">Middelengebruik</span>
</div>
<div className="flex items-center gap-2 p-3 bg-white rounded-lg border border-slate-200">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-slate-700">Cognitief</span>
</div>
<div className="flex items-center gap-2 p-3 bg-white rounded-lg border border-slate-200">
<div className="h-2 w-2 rounded-full bg-teal-500" />
<span className="text-slate-700">Context & Psychosociaal</span>
</div>
</div>
<h4 className="font-medium text-slate-900 mb-3 mt-6">
📊 Geplande Features:
</h4>
<ul className="space-y-2 text-sm text-slate-700">
<li className="flex items-start gap-2">
<span className="text-teal-600 font-bold"></span>
<span>
<strong>AI Categorisatie</strong> - Automatische DSM-light
classificatie
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-teal-600 font-bold"></span>
<span>
<strong>Ernst Indicatie</strong> - Laag, Middel, Hoog scoring
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-teal-600 font-bold"></span>
<span>
<strong>Visuele Dashboard</strong> - Overzichtelijke
weergave per categorie
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-teal-600 font-bold"></span>
<span>
<strong>Bronverwijzing</strong> - Link naar intake notities
</span>
</li>
</ul>
</div>
</div>
{/* Timeline Preview */}
<div className="mt-8 inline-flex items-center gap-2 px-4 py-2 bg-teal-50 border border-teal-200 rounded-full text-sm">
<Clock className="h-4 w-4 text-teal-600" />
<span className="text-teal-800">
<strong>Week 3:</strong> 18-24 November 2024
</span>
</div>
</div>
</div>
);
}

View File

@@ -1,101 +0,0 @@
/**
* Client Dashboard - Level 2 (Client Dossier)
*
* Overzicht van client voortgang, recente activiteit en belangrijke metrics.
* Dit is de hoofd-dashboard pagina die opent wanneer je een client selecteert.
*/
import { notFound } from 'next/navigation';
import { ChevronLeft } from 'lucide-react';
import Link from 'next/link';
import { getClient } from '../../actions';
import { transformClient } from '@/lib/types/client';
import { ClientTabs } from '../components/client-tabs';
interface ClientDashboardPageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ tab?: string }>;
}
export default async function ClientDashboardPage({
params,
searchParams,
}: ClientDashboardPageProps) {
const { id } = await params;
const { tab } = await searchParams;
// Fetch client data
let client;
try {
client = await getClient(id);
} catch (error) {
notFound();
}
if (!client) {
notFound();
}
const clientWithAge = transformClient(client);
return (
<div className="min-h-full bg-slate-50">
{/* Header */}
<div className="bg-white border-b border-slate-200">
<div className="px-4 sm:px-6 lg:px-8 py-6">
{/* Breadcrumb */}
<Link
href="/epd/clients"
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900 mb-4 transition-colors"
>
<ChevronLeft className="h-4 w-4" />
<span>Terug naar cliënten</span>
</Link>
{/* Client Info */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
{/* Avatar */}
<div className="h-16 w-16 rounded-full bg-gradient-to-br from-teal-500 to-teal-600 flex items-center justify-center shadow-md">
<span className="text-white font-semibold text-xl">
{client.first_name[0]}
{client.last_name[0]}
</span>
</div>
{/* Name & Info */}
<div>
<h1 className="text-2xl font-bold text-slate-900">
{clientWithAge.full_name}
</h1>
<div className="flex items-center gap-4 mt-1 text-sm text-slate-600">
<span>{clientWithAge.age} jaar</span>
<span></span>
<span>
Geboren:{' '}
{new Date(client.birth_date).toLocaleDateString('nl-NL')}
</span>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Link
href={`/epd/clients/${client.id}/edit`}
className="inline-flex items-center gap-2 px-4 py-2 border border-slate-300 text-slate-700 font-medium rounded-lg hover:bg-slate-50 transition-colors"
>
Bewerken
</Link>
</div>
</div>
</div>
</div>
{/* Tab Content */}
<div className="px-4 sm:px-6 lg:px-8 py-6">
<ClientTabs clientId={client.id} activeTab={tab} />
</div>
</div>
);
}

View File

@@ -1,27 +0,0 @@
/**
* Diagnose & Probleemprofiel - Level 2 (Client Dossier)
*
* DSM-light categorieën met severity tracking.
*/
export default function DiagnosePage({ params }: { params: Promise<{ id: string }> }) {
return (
<div>
<h1 className="text-2xl font-bold text-slate-900 mb-6">
Diagnose & Probleemprofiel
</h1>
<div className="bg-slate-100 border-2 border-dashed border-slate-300 rounded-lg p-12 text-center">
<p className="text-slate-600 text-lg mb-2">
DSM-light Categorieën
</p>
<p className="text-slate-500 text-sm">
6 categorieën met severity indicators (Laag/Middel/Hoog)
</p>
<p className="text-xs text-slate-400 mt-4">
Epic 4 - Placeholder for future content
</p>
</div>
</div>
);
}

View File

@@ -1,49 +0,0 @@
import { notFound } from 'next/navigation';
import { ChevronLeft } from 'lucide-react';
import Link from 'next/link';
import { getClient } from '../../actions';
import { ClientForm } from '../../components/client-form';
interface EditClientPageProps {
params: Promise<{ id: string }>;
}
export default async function EditClientPage({ params }: EditClientPageProps) {
const { id } = await params;
// Fetch client data
let client;
try {
client = await getClient(id);
} catch (error) {
notFound();
}
if (!client) {
notFound();
}
return (
<div className="px-4 sm:px-6 lg:px-8 py-8 max-w-2xl mx-auto">
{/* Back Button */}
<Link
href={`/epd/clients/${id}`}
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900 mb-6 transition-colors"
>
<ChevronLeft className="h-4 w-4" />
<span>Terug naar cliënt</span>
</Link>
{/* Page Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-slate-900">Cliënt bewerken</h1>
<p className="text-sm text-slate-600 mt-1">
Wijzig de gegevens van {client.first_name} {client.last_name}
</p>
</div>
{/* Form */}
<ClientForm client={client} />
</div>
);
}

View File

@@ -1,27 +0,0 @@
/**
* Intake Sectie - Level 2 (Client Dossier)
*
* TipTap editor voor intake notities met CRUD functionaliteit.
*/
export default function IntakePage({ params }: { params: Promise<{ id: string }> }) {
return (
<div>
<h1 className="text-2xl font-bold text-slate-900 mb-6">
Intakes
</h1>
<div className="bg-slate-100 border-2 border-dashed border-slate-300 rounded-lg p-12 text-center">
<p className="text-slate-600 text-lg mb-2">
Intake Notities
</p>
<p className="text-slate-500 text-sm">
TipTap editor, CRUD voor intakes, slide-in detail panel
</p>
<p className="text-xs text-slate-400 mt-4">
Epic 3 - Placeholder for future content
</p>
</div>
</div>
);
}

View File

@@ -1,65 +0,0 @@
import { Intake } from '../../actions';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
import { Calendar, Clock, FileText } from 'lucide-react';
interface IntakeHeaderProps {
intake: Intake;
}
export function IntakeHeader({ intake }: IntakeHeaderProps) {
const statusColors = {
Open: 'bg-blue-50 text-blue-700 border-blue-200',
Completed: 'bg-green-50 text-green-700 border-green-200',
Cancelled: 'bg-red-50 text-red-700 border-red-200',
Draft: 'bg-slate-50 text-slate-700 border-slate-200',
};
const status = intake.status || 'Draft';
const statusClass = statusColors[status as keyof typeof statusColors] || statusColors.Draft;
return (
<div className="bg-white border-b border-slate-200 px-6 py-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="p-3 bg-teal-50 rounded-lg text-teal-600">
<FileText className="h-6 w-6" />
</div>
<div>
<div className="flex items-center gap-3 mb-1">
<h1 className="text-xl font-bold text-slate-900">{intake.title}</h1>
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${statusClass}`}>
{status}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-slate-500">
<span className="font-medium text-slate-700">{intake.department}</span>
<span className="text-slate-300">|</span>
<div className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" />
<span>
Start: {format(new Date(intake.start_date), 'd MMM yyyy', { locale: nl })}
</span>
</div>
{intake.end_date && (
<div className="flex items-center gap-1.5">
<Clock className="h-4 w-4" />
<span>
Eind: {format(new Date(intake.end_date), 'd MMM yyyy', { locale: nl })}
</span>
</div>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{/* Placeholder for actions like Edit, Close, etc. */}
<button className="px-3 py-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-md transition-colors">
Bewerken
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,52 +0,0 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
interface IntakeTabsProps {
clientId: string;
intakeId: string;
}
export function IntakeTabs({ clientId, intakeId }: IntakeTabsProps) {
const pathname = usePathname();
const baseUrl = `/epd/clients/${clientId}/intakes/${intakeId}`;
const tabs = [
{ name: 'Algemeen', href: baseUrl, exact: true },
{ name: 'Contactmomenten', href: `${baseUrl}/contacts` },
{ name: 'Kindcheck', href: `${baseUrl}/kindcheck` },
{ name: 'Risicotaxatie', href: `${baseUrl}/risk` },
{ name: 'Anamnese', href: `${baseUrl}/anamnese` },
{ name: 'Onderzoek', href: `${baseUrl}/examination` },
{ name: 'Diagnose & Advies', href: `${baseUrl}/diagnosis` },
];
return (
<div className="border-b border-slate-200 bg-white px-6">
<nav className="-mb-px flex space-x-6 overflow-x-auto">
{tabs.map((tab) => {
const isActive = tab.exact
? pathname === tab.href
: pathname.startsWith(tab.href);
return (
<Link
key={tab.name}
href={tab.href}
className={cn(
'whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium transition-colors',
isActive
? 'border-teal-500 text-teal-600'
: 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700'
)}
>
{tab.name}
</Link>
);
})}
</nav>
</div>
);
}

View File

@@ -1,29 +0,0 @@
import { getIntakeById } from '../actions';
import { IntakeHeader } from './components/intake-header';
import { IntakeTabs } from './components/intake-tabs';
import { notFound } from 'next/navigation';
import { ReactNode } from 'react';
interface IntakeLayoutProps {
children: ReactNode;
params: Promise<{ id: string; intakeId: string }>;
}
export default async function IntakeLayout({ children, params }: IntakeLayoutProps) {
const { id, intakeId } = await params;
const intake = await getIntakeById(intakeId);
if (!intake) {
notFound();
}
return (
<div className="flex flex-col h-full bg-slate-50">
<IntakeHeader intake={intake} />
<IntakeTabs clientId={id} intakeId={intakeId} />
<div className="flex-1 p-6 overflow-auto">
{children}
</div>
</div>
);
}

View File

@@ -1,52 +0,0 @@
import { getIntakeById } from '../actions';
import { notFound } from 'next/navigation';
interface IntakePageProps {
params: Promise<{ intakeId: string }>;
}
export default async function IntakePage({ params }: IntakePageProps) {
const { intakeId } = await params;
const intake = await getIntakeById(intakeId);
if (!intake) {
notFound();
}
return (
<div className="max-w-3xl">
<div className="bg-white rounded-lg border border-slate-200 p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Algemene Informatie</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Titel</label>
<p className="text-slate-900">{intake.title}</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Afdeling</label>
<p className="text-slate-900">{intake.department}</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Status</label>
<p className="text-slate-900">{intake.status}</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-500 mb-1">Startdatum</label>
<p className="text-slate-900">{intake.start_date}</p>
</div>
</div>
<div className="mt-6 pt-6 border-t border-slate-100">
<label className="block text-sm font-medium text-slate-500 mb-2">Notities</label>
<div className="bg-slate-50 rounded-md p-4 text-slate-600 text-sm min-h-[100px]">
{intake.notes || 'Geen notities beschikbaar.'}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,83 +0,0 @@
'use server';
import { createClient } from '@/lib/supabase/server';
import { Database } from '@/lib/supabase/database.types';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
export type Intake = Database['public']['Tables']['intakes']['Row'];
const CreateIntakeSchema = z.object({
title: z.string().min(1, 'Titel is verplicht'),
department: z.enum(['Volwassenen', 'Jeugd', 'Ouderen']),
start_date: z.string().min(1, 'Startdatum is verplicht'),
patient_id: z.string().uuid(),
});
export type CreateIntakeInput = z.infer<typeof CreateIntakeSchema>;
export async function getIntakesByClientId(clientId: string) {
const supabase = await createClient();
const { data, error } = await supabase
.from('intakes')
.select('*')
.eq('patient_id', clientId)
.order('start_date', { ascending: false });
if (error) {
console.error('Error fetching intakes:', error);
throw new Error('Failed to fetch intakes');
}
return data as Intake[];
}
export async function createIntake(input: CreateIntakeInput) {
const result = CreateIntakeSchema.safeParse(input);
if (!result.success) {
throw new Error('Invalid input data');
}
const { title, department, start_date, patient_id } = result.data;
const supabase = await createClient();
const { data, error } = await supabase
.from('intakes')
.insert({
title,
department,
start_date,
patient_id,
status: 'Open', // Default status
})
.select()
.single();
if (error) {
console.error('Error creating intake:', error);
throw new Error('Failed to create intake');
}
revalidatePath(`/epd/clients/${patient_id}`);
redirect(`/epd/clients/${patient_id}?tab=intake`);
}
export async function getIntakeById(intakeId: string) {
const supabase = await createClient();
const { data, error } = await supabase
.from('intakes')
.select('*')
.eq('id', intakeId)
.single();
if (error) {
console.error('Error fetching intake:', error);
return null;
}
return data as Intake;
}

View File

@@ -1,70 +0,0 @@
import { Calendar, ChevronRight, FileText } from 'lucide-react';
import { Intake } from '../actions';
import Link from 'next/link';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
interface IntakeCardProps {
intake: Intake;
clientId: string;
}
export function IntakeCard({ intake, clientId }: IntakeCardProps) {
const statusColors = {
Open: 'bg-blue-50 text-blue-700 border-blue-200',
Completed: 'bg-green-50 text-green-700 border-green-200',
Cancelled: 'bg-red-50 text-red-700 border-red-200',
Draft: 'bg-slate-50 text-slate-700 border-slate-200',
};
const status = intake.status || 'Draft';
const statusClass = statusColors[status as keyof typeof statusColors] || statusColors.Draft;
return (
<Link
href={`/epd/clients/${clientId}/intakes/${intake.id}`}
className="block group"
>
<div className="bg-white border border-slate-200 rounded-lg p-4 hover:border-teal-500 hover:shadow-sm transition-all">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className="p-2 bg-teal-50 rounded-md text-teal-600 group-hover:bg-teal-100 transition-colors">
<FileText className="h-5 w-5" />
</div>
<div>
<h3 className="font-medium text-slate-900 group-hover:text-teal-700 transition-colors">
{intake.title}
</h3>
<p className="text-sm text-slate-500">{intake.department}</p>
</div>
</div>
<span
className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${statusClass}`}
>
{status}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-slate-500 mt-4 pt-4 border-t border-slate-100">
<div className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" />
<span>
{format(new Date(intake.start_date), 'd MMM yyyy', { locale: nl })}
</span>
</div>
{intake.end_date && (
<>
<span>&rarr;</span>
<span>
{format(new Date(intake.end_date), 'd MMM yyyy', { locale: nl })}
</span>
</>
)}
<div className="ml-auto">
<ChevronRight className="h-4 w-4 text-slate-300 group-hover:text-teal-500 transition-colors" />
</div>
</div>
</div>
</Link>
);
}

View File

@@ -1,50 +0,0 @@
'use client';
import { Intake } from '../actions';
import { IntakeCard } from './intake-card';
import { FileText } from 'lucide-react';
interface IntakeListProps {
intakes: Intake[];
clientId: string;
isLoading?: boolean;
}
export function IntakeList({ intakes, clientId, isLoading }: IntakeListProps) {
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1, 2].map((i) => (
<div
key={i}
className="h-32 bg-slate-50 rounded-lg border border-slate-200 animate-pulse"
/>
))}
</div>
);
}
if (intakes.length === 0) {
return (
<div className="text-center py-12 bg-slate-50 rounded-lg border border-dashed border-slate-300">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-slate-100 mb-4">
<FileText className="h-6 w-6 text-slate-400" />
</div>
<h3 className="text-sm font-medium text-slate-900 mb-1">
Geen intakes gevonden
</h3>
<p className="text-sm text-slate-500">
Start een nieuwe intake om te beginnen.
</p>
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{intakes.map((intake) => (
<IntakeCard key={intake.id} intake={intake} clientId={clientId} />
))}
</div>
);
}

View File

@@ -1,137 +0,0 @@
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { createIntake } from '../actions';
import { useState, useTransition } from 'react';
import { CalendarIcon, Loader2 } from 'lucide-react';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
import { cn } from '@/lib/utils';
const formSchema = z.object({
title: z.string().min(1, 'Titel is verplicht'),
department: z.enum(['Volwassenen', 'Jeugd', 'Ouderen']),
start_date: z.string().min(1, 'Startdatum is verplicht'),
});
type FormData = z.infer<typeof formSchema>;
interface NewIntakeFormProps {
clientId: string;
}
export function NewIntakeForm({ clientId }: NewIntakeFormProps) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
department: 'Volwassenen',
start_date: format(new Date(), 'yyyy-MM-dd'),
},
});
const onSubmit = (data: FormData) => {
setError(null);
startTransition(async () => {
try {
await createIntake({
...data,
patient_id: clientId,
});
} catch (e) {
setError('Er is een fout opgetreden bij het aanmaken van de intake.');
console.error(e);
}
});
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 max-w-md">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<label htmlFor="title" className="text-sm font-medium text-slate-900">
Titel Intake
</label>
<input
id="title"
type="text"
{...register('title')}
placeholder="Bijv. Intake Depressie"
className={cn(
"flex h-10 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50",
errors.title && "border-red-500 focus:ring-red-500"
)}
disabled={isPending}
/>
{errors.title && (
<p className="text-xs text-red-500">{errors.title.message}</p>
)}
</div>
<div className="space-y-2">
<label htmlFor="department" className="text-sm font-medium text-slate-900">
Afdeling
</label>
<select
id="department"
{...register('department')}
className="flex h-10 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50"
disabled={isPending}
>
<option value="Volwassenen">Volwassenen</option>
<option value="Jeugd">Jeugd</option>
<option value="Ouderen">Ouderen</option>
</select>
{errors.department && (
<p className="text-xs text-red-500">{errors.department.message}</p>
)}
</div>
<div className="space-y-2">
<label htmlFor="start_date" className="text-sm font-medium text-slate-900">
Startdatum
</label>
<div className="relative">
<input
id="start_date"
type="date"
{...register('start_date')}
className={cn(
"flex h-10 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50",
errors.start_date && "border-red-500 focus:ring-red-500"
)}
disabled={isPending}
/>
</div>
{errors.start_date && (
<p className="text-xs text-red-500">{errors.start_date.message}</p>
)}
</div>
<div className="pt-4">
<button
type="submit"
disabled={isPending}
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2 bg-teal-600 text-white font-medium rounded-lg hover:bg-teal-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{isPending ? 'Aanmaken...' : 'Start Intake'}
</button>
</div>
</form>
);
}

View File

@@ -1,33 +0,0 @@
import { NewIntakeForm } from '../../components/new-intake-form';
import Link from 'next/link';
import { ChevronLeft } from 'lucide-react';
interface NewIntakePageProps {
params: Promise<{ id: string }>;
}
export default async function NewIntakePage({ params }: NewIntakePageProps) {
const { id } = await params;
return (
<div className="max-w-2xl mx-auto py-8">
<div className="mb-8">
<Link
href={`/epd/clients/${id}?tab=intake`}
className="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-900 mb-4 transition-colors"
>
<ChevronLeft className="h-4 w-4" />
Terug naar overzicht
</Link>
<h1 className="text-2xl font-bold text-slate-900">Nieuwe Intake Starten</h1>
<p className="text-slate-600 mt-2">
Vul de basisgegevens in om een nieuwe intake te starten.
</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-6 shadow-sm">
<NewIntakeForm clientId={id} />
</div>
</div>
);
}

View File

@@ -1,18 +0,0 @@
import { redirect } from 'next/navigation';
interface ClientDetailPageProps {
params: Promise<{ id: string }>;
}
/**
* Redirect route: /epd/clients/[id] -> /epd/clients/[id]/dashboard
*
* Wanneer een gebruiker direct naar /epd/clients/[id] navigeert,
* wordt deze automatisch doorgestuurd naar het dashboard.
*/
export default async function ClientRedirect({
params,
}: ClientDetailPageProps) {
const { id } = await params;
redirect(`/epd/clients/${id}/dashboard`);
}

View File

@@ -1,27 +0,0 @@
/**
* Behandelplan - Level 2 (Client Dossier)
*
* SMART doelen tracking met interventies en versioning.
*/
export default function PlanPage({ params }: { params: Promise<{ id: string }> }) {
return (
<div>
<h1 className="text-2xl font-bold text-slate-900 mb-6">
Behandelplan
</h1>
<div className="bg-slate-100 border-2 border-dashed border-slate-300 rounded-lg p-12 text-center">
<p className="text-slate-600 text-lg mb-2">
SMART Doelen & Interventies
</p>
<p className="text-slate-500 text-sm">
Treatment plan met versioning (v1, v2, concept/gepubliceerd)
</p>
<p className="text-xs text-slate-400 mt-4">
Epic 5 - Placeholder for future content
</p>
</div>
</div>
);
}

View File

@@ -1,27 +0,0 @@
/**
* Client Rapportage - Level 2 (Client Dossier)
*
* Client-specifieke voortgang en metrics.
*/
export default function ClientReportsPage({ params }: { params: Promise<{ id: string }> }) {
return (
<div>
<h1 className="text-2xl font-bold text-slate-900 mb-6">
Rapportage & Voortgang
</h1>
<div className="bg-slate-100 border-2 border-dashed border-slate-300 rounded-lg p-12 text-center">
<p className="text-slate-600 text-lg mb-2">
Client Voortgang
</p>
<p className="text-slate-500 text-sm">
Behandelduur, sessies, doelvoortgang tracking
</p>
<p className="text-xs text-slate-400 mt-4">
Placeholder - Not designed yet
</p>
</div>
</div>
);
}

View File

@@ -1,146 +0,0 @@
'use server';
/**
* Client CRUD Server Actions
*
* Server-side actions for client management
*/
import { revalidatePath } from 'next/cache';
import { createClient as createSupabaseClient } from '@/lib/auth/server';
import type { ClientFormData, ClientFilters } from '@/lib/types/client';
/**
* Get all clients with optional filtering
*/
export async function getClients(filters?: ClientFilters) {
const supabase = await createSupabaseClient();
let query = supabase
.from('clients')
.select('*');
// Apply search filter
if (filters?.search) {
const searchTerm = filters.search.trim();
// Use PostgREST or() syntax: column.operator.value,column.operator.value
// Format: column.operator.value,column.operator.value (no quotes needed for ilike)
const searchPattern = `%${searchTerm}%`;
query = query.or(`first_name.ilike.${searchPattern},last_name.ilike.${searchPattern}`);
}
// Apply sorting
const sortBy = filters?.sortBy || 'created_at';
const sortOrder = filters?.sortOrder || 'desc';
if (sortBy === 'name') {
query = query.order('last_name', { ascending: sortOrder === 'asc' });
query = query.order('first_name', { ascending: sortOrder === 'asc' });
} else if (sortBy === 'created_at') {
query = query.order('created_at', { ascending: sortOrder === 'asc' });
} else if (sortBy === 'age') {
// Sort by birth_date (newest birth = youngest age)
query = query.order('birth_date', { ascending: sortOrder === 'desc' });
}
const { data, error } = await query;
if (error) {
console.error('Error fetching clients:', error);
throw new Error('Failed to fetch clients');
}
return data || [];
}
/**
* Get single client by ID
*/
export async function getClient(id: string) {
const supabase = await createSupabaseClient();
const { data, error } = await supabase
.from('clients')
.select('*')
.eq('id', id)
.single();
if (error) {
console.error('Error fetching client:', error);
throw new Error('Failed to fetch client');
}
return data;
}
/**
* Create new client
*/
export async function createClient(formData: ClientFormData) {
const supabase = await createSupabaseClient();
const { data, error } = await supabase
.from('clients')
.insert({
first_name: formData.first_name.trim(),
last_name: formData.last_name.trim(),
birth_date: formData.birth_date,
})
.select()
.single();
if (error) {
console.error('Error creating client:', error);
throw new Error('Failed to create client');
}
revalidatePath('/epd/clients');
return data;
}
/**
* Update existing client
*/
export async function updateClient(id: string, formData: ClientFormData) {
const supabase = await createSupabaseClient();
const { data, error } = await supabase
.from('clients')
.update({
first_name: formData.first_name.trim(),
last_name: formData.last_name.trim(),
birth_date: formData.birth_date,
})
.eq('id', id)
.select()
.single();
if (error) {
console.error('Error updating client:', error);
throw new Error('Failed to update client');
}
revalidatePath('/epd/clients');
revalidatePath(`/epd/clients/${id}`);
return data;
}
/**
* Delete client
*/
export async function deleteClient(id: string) {
const supabase = await createSupabaseClient();
const { error } = await supabase
.from('clients')
.delete()
.eq('id', id);
if (error) {
console.error('Error deleting client:', error);
throw new Error('Failed to delete client');
}
revalidatePath('/epd/clients');
return { success: true };
}

View File

@@ -1,191 +0,0 @@
import { CheckCircle2, Circle, Clock, Rocket } from "lucide-react"
export default function ComingSoonPage() {
// Roadmap items from bouwplan v2.1
const roadmapItems = [
{
week: "Week 1",
status: "in-progress",
title: "Foundation & Marketing",
items: [
{ done: true, text: "Project Setup - Next.js + Supabase" },
{ done: true, text: "Design System - Teal-first kleuren" },
{ done: true, text: "App Layout - Header + Sidebar" },
{ done: false, text: "Marketing Website - Timeline + Features" },
],
},
{
week: "Week 2",
status: "upcoming",
title: "EPD Core",
items: [
{ done: false, text: "Database Schema + RLS Policies" },
{ done: false, text: "Client Module - CRUD Operations" },
{ done: false, text: "Client Detail Page" },
],
},
{
week: "Week 3",
status: "upcoming",
title: "AI Magic",
items: [
{ done: false, text: "TipTap Rich Text Editor" },
{ done: false, text: "Claude API - Intake Samenvatting" },
{ done: false, text: "AI Profiel + Behandelplan Generator" },
],
},
{
week: "Week 4",
status: "upcoming",
title: "Polish & Launch",
items: [
{ done: false, text: "Onboarding System" },
{ done: false, text: "Performance Optimization" },
{ done: false, text: "Demo Preparation + LinkedIn Launch" },
],
},
]
return (
<div className="px-4 sm:px-6 lg:px-8 py-8 max-w-5xl mx-auto">
{/* Hero Section */}
<div className="text-center mb-16">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gradient-to-br from-amber-500 to-amber-600 mb-6 shadow-lg">
<Rocket className="h-8 w-8 text-white" />
</div>
<h1 className="text-4xl sm:text-5xl font-bold text-slate-900 mb-4">
Coming Soon
</h1>
<p className="text-xl text-slate-600 max-w-2xl mx-auto mb-2">
Het EPD wordt gebouwd in <span className="font-semibold text-teal-700">4 weken</span>
</p>
<p className="text-sm text-slate-500 mb-6">
Van 100.000+ en 12-24 maanden <span className="font-mono font-semibold text-amber-700">200 + 4 weken</span>
</p>
{/* Build in Public Badge */}
<div className="inline-flex items-center gap-2 px-4 py-2 bg-teal-50 border border-teal-200 rounded-full">
<div className="h-2 w-2 rounded-full bg-teal-500 animate-pulse" />
<span className="text-sm font-medium text-teal-800">
Building in Public
</span>
</div>
</div>
{/* Roadmap */}
<div className="space-y-8">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-slate-900 mb-2">
4-Weken Roadmap
</h2>
<p className="text-slate-600">
Volg de voortgang van dit experiment
</p>
</div>
{roadmapItems.map((week, idx) => (
<div
key={week.week}
className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm hover:shadow-md transition-shadow"
>
{/* Week Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div
className={`h-10 w-10 rounded-lg flex items-center justify-center font-mono text-sm font-semibold ${
week.status === "in-progress"
? "bg-gradient-to-br from-amber-500 to-amber-600 text-white"
: week.status === "upcoming"
? "bg-slate-100 text-slate-400"
: "bg-gradient-to-br from-teal-600 to-teal-700 text-white"
}`}
>
W{idx + 1}
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900">
{week.week}
</h3>
<p className="text-sm text-slate-600">{week.title}</p>
</div>
</div>
<StatusBadge status={week.status} />
</div>
{/* Items Checklist */}
<ul className="space-y-2">
{week.items.map((item, itemIdx) => (
<li key={itemIdx} className="flex items-start gap-3">
{item.done ? (
<CheckCircle2 className="h-5 w-5 text-teal-600 flex-shrink-0 mt-0.5" />
) : (
<Circle className="h-5 w-5 text-slate-300 flex-shrink-0 mt-0.5" />
)}
<span
className={`text-sm ${
item.done
? "text-slate-700 line-through"
: "text-slate-600"
}`}
>
{item.text}
</span>
</li>
))}
</ul>
</div>
))}
</div>
{/* Footer CTA */}
<div className="mt-12 text-center">
<p className="text-slate-600 mb-4">
Volg de build op LinkedIn voor real-time updates
</p>
<a
href="https://linkedin.com/in/colinlit"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-teal-600 to-teal-700 hover:from-teal-700 hover:to-teal-800 text-white font-medium rounded-lg shadow-md hover:shadow-lg transition-all"
>
<span>Volg op LinkedIn</span>
<span></span>
</a>
</div>
</div>
)
}
function StatusBadge({ status }: { status: string }) {
const config = {
"in-progress": {
bg: "bg-amber-50 border-amber-200",
text: "text-amber-700",
label: "In Progress",
icon: Clock,
},
upcoming: {
bg: "bg-slate-50 border-slate-200",
text: "text-slate-600",
label: "Upcoming",
icon: Circle,
},
completed: {
bg: "bg-teal-50 border-teal-200",
text: "text-teal-700",
label: "Completed",
icon: CheckCircle2,
},
}
const { bg, text, label, icon: Icon } = config[status as keyof typeof config]
return (
<div
className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full border text-xs font-medium ${bg} ${text}`}
>
<Icon className="h-3.5 w-3.5" />
{label}
</div>
)
}

View File

@@ -1,149 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Save, Loader2 } from 'lucide-react';
import type { ClientFormData } from '@/lib/types/client';
import type { Client } from '@/lib/types/client';
import { createClient, updateClient } from '../actions';
interface ClientFormProps {
client?: Client;
}
export function ClientForm({ client }: ClientFormProps) {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState<ClientFormData>({
first_name: client?.first_name || '',
last_name: client?.last_name || '',
birth_date: client?.birth_date || '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsSubmitting(true);
try {
if (client) {
await updateClient(client.id, formData);
router.push(`/epd/clients/${client.id}`);
} else {
const newClient = await createClient(formData);
router.push(`/epd/clients/${newClient.id}`);
}
router.refresh();
} catch (err) {
console.error('Form submission error:', err);
setError('Er is een fout opgetreden. Probeer het opnieuw.');
} finally {
setIsSubmitting(false);
}
};
const handleChange = (field: keyof ClientFormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
{/* Form Card */}
<div className="bg-white rounded-lg border border-slate-200 p-6 space-y-6">
{/* First Name */}
<div>
<label
htmlFor="first_name"
className="block text-sm font-medium text-slate-700 mb-2"
>
Voornaam <span className="text-red-500">*</span>
</label>
<input
type="text"
id="first_name"
required
value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
placeholder="bijv. Jan"
/>
</div>
{/* Last Name */}
<div>
<label
htmlFor="last_name"
className="block text-sm font-medium text-slate-700 mb-2"
>
Achternaam <span className="text-red-500">*</span>
</label>
<input
type="text"
id="last_name"
required
value={formData.last_name}
onChange={(e) => handleChange('last_name', e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
placeholder="bijv. de Vries"
/>
</div>
{/* Birth Date */}
<div>
<label
htmlFor="birth_date"
className="block text-sm font-medium text-slate-700 mb-2"
>
Geboortedatum <span className="text-red-500">*</span>
</label>
<input
type="date"
id="birth_date"
required
value={formData.birth_date}
onChange={(e) => handleChange('birth_date', e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
/>
</div>
</div>
{/* Form Actions */}
<div className="flex items-center justify-end gap-3">
<button
type="button"
onClick={() => router.back()}
disabled={isSubmitting}
className="px-6 py-2 border border-slate-300 text-slate-700 font-medium rounded-lg hover:bg-slate-50 transition-colors disabled:opacity-50"
>
Annuleren
</button>
<button
type="submit"
disabled={isSubmitting}
className="inline-flex items-center gap-2 px-6 py-2 bg-gradient-to-r from-teal-600 to-teal-700 hover:from-teal-700 hover:to-teal-800 text-white font-medium rounded-lg shadow-sm hover:shadow-md transition-all disabled:opacity-50"
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span>Bezig...</span>
</>
) : (
<>
<Save className="h-4 w-4" />
<span>{client ? 'Bijwerken' : 'Opslaan'}</span>
</>
)}
</button>
</div>
</form>
);
}

View File

@@ -1,56 +0,0 @@
'use client'
export function ClientListSkeleton() {
return (
<div className="space-y-4">
{/* Search Bar Skeleton */}
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="h-10 bg-slate-100 rounded-lg animate-pulse" />
</div>
{/* Table Skeleton - Desktop */}
<div className="hidden md:block bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="p-4 space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-4">
<div className="h-10 w-10 rounded-full bg-slate-100 animate-pulse" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-slate-100 rounded w-1/4 animate-pulse" />
<div className="h-3 bg-slate-100 rounded w-1/6 animate-pulse" />
</div>
<div className="flex gap-2">
<div className="h-8 w-8 bg-slate-100 rounded animate-pulse" />
<div className="h-8 w-8 bg-slate-100 rounded animate-pulse" />
<div className="h-8 w-8 bg-slate-100 rounded animate-pulse" />
</div>
</div>
))}
</div>
</div>
{/* Card Skeleton - Mobile */}
<div className="md:hidden space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-lg border border-slate-200 p-4">
<div className="flex items-center gap-3 mb-3">
<div className="h-12 w-12 rounded-full bg-slate-100 animate-pulse" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-slate-100 rounded w-3/4 animate-pulse" />
<div className="h-3 bg-slate-100 rounded w-1/2 animate-pulse" />
</div>
</div>
<div className="space-y-2 mb-3">
<div className="h-3 bg-slate-100 rounded animate-pulse" />
<div className="h-3 bg-slate-100 rounded animate-pulse" />
</div>
<div className="flex gap-2">
<div className="flex-1 h-10 bg-slate-100 rounded-lg animate-pulse" />
<div className="flex-1 h-10 bg-slate-100 rounded-lg animate-pulse" />
<div className="h-10 w-10 bg-slate-100 rounded-lg animate-pulse" />
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,260 +0,0 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Search, ArrowUpDown, Eye, Edit2, Trash2, Users } from 'lucide-react';
import type { Client } from '@/lib/types/client';
import { transformClient } from '@/lib/types/client';
import { deleteClient } from '../actions';
interface ClientListProps {
initialClients: Client[];
}
export function ClientList({ initialClients }: ClientListProps) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const [isDeleting, setIsDeleting] = useState<string | null>(null);
// Transform clients with computed fields
const clients = initialClients.map(transformClient);
// Client-side filtering (for instant feedback)
const filteredClients = clients.filter((client) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
client.first_name.toLowerCase().includes(query) ||
client.last_name.toLowerCase().includes(query) ||
client.full_name.toLowerCase().includes(query)
);
});
const handleSearch = (value: string) => {
setSearchQuery(value);
// Update URL with search param
const params = new URLSearchParams();
if (value) params.set('search', value);
router.push(`/epd/clients?${params.toString()}`);
};
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Weet u zeker dat u ${name} wilt verwijderen?`)) {
return;
}
setIsDeleting(id);
try {
await deleteClient(id);
router.refresh();
} catch (error) {
console.error('Error deleting client:', error);
alert('Fout bij verwijderen van cliënt');
} finally {
setIsDeleting(null);
}
};
return (
<div className="space-y-4">
{/* Search Bar */}
<div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-slate-400" />
<input
type="text"
placeholder="Zoek op naam..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
/>
</div>
</div>
{/* Empty State */}
{filteredClients.length === 0 && (
<div className="bg-white rounded-lg border border-slate-200 p-12 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 mb-4">
<Users className="h-8 w-8 text-slate-400" />
</div>
<h3 className="text-lg font-semibold text-slate-900 mb-1">
{searchQuery ? 'Geen resultaten' : 'Geen cliënten'}
</h3>
<p className="text-sm text-slate-600 mb-4">
{searchQuery
? 'Probeer een andere zoekopdracht'
: 'Voeg uw eerste cliënt toe om te beginnen'}
</p>
{!searchQuery && (
<Link
href="/epd/clients/new"
className="inline-flex items-center gap-2 px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white font-medium rounded-lg transition-colors"
>
<span>Nieuwe cliënt toevoegen</span>
</Link>
)}
</div>
)}
{/* Desktop Table View */}
{filteredClients.length > 0 && (
<div className="hidden md:block bg-white rounded-lg border border-slate-200 overflow-hidden">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Naam
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Leeftijd
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Geboortedatum
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
Toegevoegd
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase tracking-wider">
Acties
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-slate-200">
{filteredClients.map((client) => (
<tr
key={client.id}
onClick={() => router.push(`/epd/clients/${client.id}`)}
className="hover:bg-slate-50 transition-colors cursor-pointer"
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="h-10 w-10 flex-shrink-0">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-teal-500 to-teal-600 flex items-center justify-center">
<span className="text-white font-medium text-sm">
{client.first_name[0]}
{client.last_name[0]}
</span>
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-slate-900">
{client.full_name}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-600">
{client.age} jaar
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-600">
{new Date(client.birth_date).toLocaleDateString('nl-NL')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-600">
{new Date(client.created_at).toLocaleDateString('nl-NL')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div
className="flex items-center justify-end gap-2"
onClick={(e) => e.stopPropagation()}
>
<Link
href={`/epd/clients/${client.id}`}
className="text-teal-600 hover:text-teal-900 p-1 rounded hover:bg-teal-50 transition-colors"
title="Bekijken"
>
<Eye className="h-4 w-4" />
</Link>
<Link
href={`/epd/clients/${client.id}/edit`}
className="text-slate-600 hover:text-slate-900 p-1 rounded hover:bg-slate-50 transition-colors"
title="Bewerken"
>
<Edit2 className="h-4 w-4" />
</Link>
<button
onClick={() => handleDelete(client.id, client.full_name)}
disabled={isDeleting === client.id}
className="text-red-600 hover:text-red-900 p-1 rounded hover:bg-red-50 transition-colors disabled:opacity-50"
title="Verwijderen"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Mobile Card View */}
{filteredClients.length > 0 && (
<div className="md:hidden space-y-3">
{filteredClients.map((client) => (
<div
key={client.id}
className="bg-white rounded-lg border border-slate-200 p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-gradient-to-br from-teal-500 to-teal-600 flex items-center justify-center">
<span className="text-white font-medium">
{client.first_name[0]}
{client.last_name[0]}
</span>
</div>
<div>
<h3 className="font-medium text-slate-900">
{client.full_name}
</h3>
<p className="text-sm text-slate-600">
{client.age} jaar
</p>
</div>
</div>
</div>
<div className="space-y-2 mb-3 text-sm text-slate-600">
<div className="flex justify-between">
<span>Geboortedatum:</span>
<span>{new Date(client.birth_date).toLocaleDateString('nl-NL')}</span>
</div>
<div className="flex justify-between">
<span>Toegevoegd:</span>
<span>{new Date(client.created_at).toLocaleDateString('nl-NL')}</span>
</div>
</div>
<div className="flex items-center gap-2 pt-3 border-t border-slate-200">
<Link
href={`/epd/clients/${client.id}`}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-teal-50 text-teal-700 font-medium rounded-lg hover:bg-teal-100 transition-colors"
>
<Eye className="h-4 w-4" />
<span>Bekijken</span>
</Link>
<Link
href={`/epd/clients/${client.id}/edit`}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2 bg-slate-50 text-slate-700 font-medium rounded-lg hover:bg-slate-100 transition-colors"
>
<Edit2 className="h-4 w-4" />
<span>Bewerken</span>
</Link>
<button
onClick={() => handleDelete(client.id, client.full_name)}
disabled={isDeleting === client.id}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50"
title="Verwijderen"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,29 +0,0 @@
import { ChevronLeft } from 'lucide-react';
import Link from 'next/link';
import { ClientForm } from '../components/client-form';
export default function NewClientPage() {
return (
<div className="px-4 sm:px-6 lg:px-8 py-8 max-w-2xl mx-auto">
{/* Back Button */}
<Link
href="/epd/clients"
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900 mb-6 transition-colors"
>
<ChevronLeft className="h-4 w-4" />
<span>Terug naar overzicht</span>
</Link>
{/* Page Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-slate-900">Nieuwe cliënt</h1>
<p className="text-sm text-slate-600 mt-1">
Voeg een nieuwe cliënt toe aan het systeem
</p>
</div>
{/* Form */}
<ClientForm />
</div>
);
}

View File

@@ -1,10 +0,0 @@
import { redirect } from 'next/navigation';
/**
* Clients Root Redirect
* Redirects /epd/clients to /epd/patients for backward compatibility
*/
export default function ClientsRedirect() {
redirect('/epd/patients');
}

View File

@@ -1,248 +0,0 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
/* =============================================================================
Mini-ECD Design System
Gebaseerd op ux-stylesheet.md (v2, UX-geoptimaliseerd)
============================================================================= */
:root {
/* Basiskleuren */
--color-bg: #F8FAFC;
--color-surface: #FFFFFF;
--color-surface-secondary: #F1F5F9;
--color-text: #0F172A;
--color-text-secondary: #475569;
--color-border: #E2E8F0;
/* Brand & Primary */
--color-brand: #3B82F6;
--color-brand-hover: #2563EB;
--color-brand-active: #1D4ED8;
--color-brand-subtle: #EFF6FF;
/* Neutral CTA */
--color-neutral: #334155;
--color-neutral-hover: #1F2937;
/* Module-accenten */
--color-module-appointments: #16A34A;
--color-module-appointments-bg: #E8F8EF;
--color-module-appointments-border: #CDECDC;
--color-module-meds: #F59E0B;
--color-module-meds-bg: #FEF6DC;
--color-module-meds-border: #F6E7B6;
--color-module-labs: #F97316;
--color-module-labs-bg: #FFEBDC;
--color-module-labs-border: #FFD2B8;
/* Status & Feedback */
--color-success: #16A34A;
--color-success-subtle: #ECFDF5;
--color-warning: #EAB308;
--color-warning-subtle: #FEFCE8;
--color-error: #DC2626;
--color-error-subtle: #FEF2F2;
--color-info: #3B82F6;
--color-info-subtle: #EFF6FF;
/* Badges (Severity DSM-light) */
--color-badge-low-bg: #E5E7EB;
--color-badge-low-text: #374151;
--color-badge-medium-bg: #FEF3C7;
--color-badge-medium-text: #92400E;
--color-badge-high-bg: #FEE2E2;
--color-badge-high-text: #991B1B;
/* Formulieren */
--color-input-bg: #FFFFFF;
--color-input-text: #0F172A;
--color-input-placeholder: #94A3B8;
--color-input-border: #CBD5E1;
--color-input-border-hover: #94A3B8;
--color-input-focus: #3B82F6;
--color-input-focus-border: #2563EB;
--color-input-disabled-bg: #F1F5F9;
--color-input-disabled-text: #94A3B8;
--color-input-invalid-border: #DC2626;
--color-input-invalid-text: #B91C1C;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
--shadow-md: 0 2px 6px rgba(15, 23, 42, 0.08);
--shadow-lg: 0 8px 20px rgba(15, 23, 42, 0.10);
/* Fonts */
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
@theme inline {
/* Basiskleuren */
--color-bg: var(--color-bg);
--color-surface: var(--color-surface);
--color-text: var(--color-text);
--color-border: var(--color-border);
/* Brand */
--color-brand: var(--color-brand);
--color-success: var(--color-success);
--color-warning: var(--color-warning);
--color-error: var(--color-error);
--color-info: var(--color-info);
/* Shadows */
--shadow-sm: var(--shadow-sm);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
/* Fonts */
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--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-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) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
/* Dark mode (optioneel, MVP gebruikt licht thema) */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0a0a0a;
--color-surface: #1a1a1a;
--color-text: #ededed;
--color-text-secondary: #a1a1a1;
--color-border: #333333;
}
}
/* Global styles */
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans), system-ui, -apple-system, sans-serif;
}
/* Focus styles (toegankelijkheid) */
*:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
/* AI source highlighting (gebruikt in Intake editor) */
.ai-source-highlight {
background-color: #fef08a; /* lichtgeel */
border-radius: 2px;
padding: 1px 0;
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

5
app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function RootPage() {
redirect('/login')
}

View File

@@ -1,36 +1,18 @@
/**
* Robots.txt Generator
*
* Generates robots.txt for SEO.
* Next.js will automatically serve this at /robots.txt
*
* ECD is een afgeschermd systeem — zoekmachines mogen niets indexeren.
*/
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: [
'/api/',
'/_next/',
'/admin/',
],
},
{
userAgent: 'Googlebot',
allow: '/',
disallow: [
'/api/',
'/_next/',
],
disallow: '/',
},
],
sitemap: `${baseUrl}/sitemap.xml`,
}
}

View File

@@ -1,84 +0,0 @@
/**
* Sitemap Generator
*
* Generates sitemap.xml for SEO.
* Next.js will automatically serve this at /sitemap.xml
*/
import type { MetadataRoute } from 'next'
import { getAllReleases } from '@/lib/mdx/documentatie'
import { getAllPosts, getAllSeries } from '@/lib/mdx/blog'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
const currentDate = new Date()
// Fetch all documentation releases dynamically
const releases = await getAllReleases()
const releaseUrls: MetadataRoute.Sitemap = releases.map((release) => {
const releaseDate = new Date(release.frontmatter.releaseDate)
const isValidDate = !isNaN(releaseDate.getTime())
return {
url: `${baseUrl}/documentatie/${release.slug}`,
lastModified: isValidDate ? releaseDate : currentDate,
changeFrequency: 'monthly',
priority: 0.8,
}
})
// Fetch all blog posts dynamically
const posts = await getAllPosts()
const postUrls: MetadataRoute.Sitemap = posts.map((post) => {
const postDate = new Date(post.frontmatter.date)
const isValidDate = !isNaN(postDate.getTime())
return {
url: `${baseUrl}/blog/serie/${post.seriesId}/${post.slug}`,
lastModified: isValidDate ? postDate : currentDate,
changeFrequency: 'monthly',
priority: 0.7,
}
})
// Fetch all blog series
const series = await getAllSeries()
const seriesUrls: MetadataRoute.Sitemap = series.map((serie) => ({
url: `${baseUrl}/blog/serie/${serie.id}`,
lastModified: currentDate,
changeFrequency: 'weekly',
priority: 0.8,
}))
return [
{
url: baseUrl,
lastModified: currentDate,
changeFrequency: 'weekly',
priority: 1.0,
},
{
url: `${baseUrl}/blog`,
lastModified: currentDate,
changeFrequency: 'weekly',
priority: 0.9,
},
{
url: `${baseUrl}/documentatie`,
lastModified: currentDate,
changeFrequency: 'weekly',
priority: 0.9,
},
...seriesUrls,
...postUrls,
...releaseUrls,
{
url: `${baseUrl}/contact`,
lastModified: currentDate,
changeFrequency: 'monthly',
priority: 0.7,
},
]
}