feat: initial FitCoach Pro lead management app

Next.js 16 app with full CRUD lead management:
- REST API (/api/leads) with GET (pagination), POST, PUT, DELETE (soft delete)
- Zod validation (server + client), Supabase (PostgreSQL)
- Responsive UI: leads table (desktop) + cards (mobile), modal form, toast notifications
- 43 tests (Vitest + React Testing Library): validation, components, pagination
- Docker setup (multi-stage build, health check)
- Supabase migration + 15 seed leads
- Documentation (CLAUDE.md, README, API docs)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 14:20:51 +02:00
commit eedb049bab
39 changed files with 7997 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
node_modules
.next
.git
.gitignore
temp
*.md
.env
.env.*
!.env.example
.claude
coverage
.vscode
.idea
+7
View File
@@ -0,0 +1,7 @@
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here
# App
NEXT_PUBLIC_APP_URL=http://localhost:3000
+68
View File
@@ -0,0 +1,68 @@
# Dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# Testing
/coverage
# Next.js
/.next/
/out/
# Production
/build
# Secrets & Credentials (NIEMALS COMMITTEN!)
.env
.env.*
!.env.example
*.pem
*.key
*.p12
*.pfx
credentials.json
secrets.json
service-account*.json
# temp/ - Lokale Entwicklungsnotizen
temp/
tmp/
*.tmp
*.bak
*.swp
*.swo
*~
# Claude Code
.claude/commands/
.claude/settings.local.json
.claude/worktrees/
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# OS
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
*.sublime-*
*.code-workspace
# TypeScript
*.tsbuildinfo
next-env.d.ts
# Vercel
.vercel
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+123
View File
@@ -0,0 +1,123 @@
@AGENTS.md
# FitCoach Pro
> Dieses Dokument ist die zentrale Referenz fuer Claude Code in diesem Projekt.
> Alle hier definierten Regeln und Konventionen sind verbindlich.
## Projekt-Uebersicht
- **Name**: FitCoach Pro
- **Beschreibung**: Lead-Erfassung und Verwaltung fuer ein Online-Fitness-Coaching-Business
- **Typ**: WEB_APP
- **Status**: IN_ENTWICKLUNG
## Tech Stack
| Komponente | Technologie | Version |
|-----------|------------|---------|
| Frontend | Next.js (App Router) | 16.x |
| UI | Tailwind CSS | 4.x |
| Sprache | TypeScript (strict) | 5.x |
| Datenbank | Supabase (PostgreSQL) | - |
| Validierung | Zod | 4.x |
| Tests | Vitest + React Testing Library | 4.x |
| Container | Docker Compose | latest |
| Package Manager | pnpm | 10.x |
### KRITISCH: Versionen
- **NIEMALS** veraltete Versionen oder deprecated APIs verwenden
- **IMMER** vor der Implementierung die aktuellste stabile Version pruefen
- Bei Unsicherheit: `pnpm info <package> version` / offizielle Docs pruefen
## Architektur
```
fitcoachpro/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── api/leads/ # REST API Endpoints
│ │ ├── layout.tsx # Root Layout
│ │ └── page.tsx # Lead-Verwaltung (Startseite)
│ ├── components/ # React Components
│ ├── lib/
│ │ ├── supabase/ # Supabase Client
│ │ ├── types/ # TypeScript Types
│ │ └── validations/ # Zod Schemas
│ └── test/ # Test Setup
├── supabase/
│ ├── migrations/ # SQL Migrationen
│ └── seed.sql # Testdaten (15 Leads)
├── docs/ # Dokumentation
├── temp/ # Lokale Notizen (GITIGNORED!)
├── Dockerfile # Multi-Stage Build
└── docker-compose.yml
```
## Kritische Regeln
### Allgemein
1. **Deutsche UI-Texte**: Echte Umlaute verwenden, kein AI-Sprech
2. **Code-Sprache**: Englisch (Variablen, Funktionen, Kommentare)
3. **Kommunikation**: Deutsch, direkt, ohne Floskeln
4. **temp/ Ordner**: NUR fuer lokale Notizen, NIEMALS committen
### Sicherheit
- **Keine Credentials** im Code oder Git
- **Secrets**: Ueber .env (gitignored)
- **.env.example**: Template ohne echte Werte committen
- **SUPABASE_SERVICE_ROLE_KEY**: Nur serverseitig, niemals NEXT_PUBLIC_
- **Input-Validierung**: Immer serverseitig mit Zod
### Tests
- **Nichts ungetestet committen**
- **Coverage-Minimum**: 80%
- **Vitest + React Testing Library**
- **cleanup()** in afterEach (React 19 + jsdom Pflicht)
## Development Commands
```bash
# Entwicklung starten
pnpm dev
# Tests
pnpm test
pnpm test:watch
pnpm test:coverage
# Lint
pnpm lint
# Build
pnpm build
# Docker
docker compose up --build
```
## Datenbank
| Tabelle | Beschreibung | Besonderheiten |
|---------|-------------|----------------|
| leads | Lead-Erfassung | Soft Delete (deleted_at), UUID PK, unique email (aktive) |
## API Endpunkte
| Method | Endpoint | Beschreibung |
|--------|----------|-------------|
| GET | /api/leads?page=1&limit=10 | Leads mit Pagination |
| POST | /api/leads | Neuen Lead erstellen |
| GET | /api/leads/[id] | Einzelnen Lead abrufen |
| PUT | /api/leads/[id] | Lead aktualisieren |
| DELETE | /api/leads/[id] | Lead soft-deleten |
Details: [docs/API.md](docs/API.md)
## Git Workflow
- **Branch-Strategie**: `main` fuer Deployments, Feature-Branches fuer Entwicklung
- **Commit-Format**: Conventional Commits (`feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`)
- **Vor jedem Commit**: Tests laufen lassen
- **Merge**: Squash-Merge in main
+48
View File
@@ -0,0 +1,48 @@
# Stage 1: Install dependencies
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# Stage 2: Build the application
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# Stage 3: Production runner
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
CMD ["node", "server.js"]
+125
View File
@@ -0,0 +1,125 @@
# FitCoach Pro - Lead-Verwaltung
Lead-Erfassung und Verwaltung fuer FitCoach Pro, ein Online-Fitness-Coaching-Business.
## Features
- Lead-Liste mit Pagination (Desktop-Tabelle + Mobile-Cards)
- Lead erstellen, bearbeiten und loeschen (Soft Delete)
- Formular-Validierung (Client + Server)
- Responsive Design (Mobile First)
- REST API mit vollstaendigem CRUD
- Dark Mode Support
## Tech Stack
- **Next.js 16** (App Router, TypeScript strict)
- **Tailwind CSS 4**
- **Supabase** (PostgreSQL)
- **Zod 4** (Validierung)
- **Vitest + React Testing Library** (43 Tests)
- **Docker** (Multi-Stage Build)
## Setup
### Voraussetzungen
- Node.js 22+
- pnpm 10+
- Docker Desktop (optional)
- Supabase Account (kostenlos)
### 1. Repository klonen
```bash
git clone <repo-url>
cd fitcoachpro
```
### 2. Dependencies installieren
```bash
pnpm install
```
### 3. Umgebungsvariablen konfigurieren
```bash
cp .env.example .env
```
Trage deine Supabase-Credentials in `.env` ein:
- `NEXT_PUBLIC_SUPABASE_URL` - Supabase Project URL
- `NEXT_PUBLIC_SUPABASE_ANON_KEY` - Supabase Anon Key
- `SUPABASE_SERVICE_ROLE_KEY` - Supabase Service Role Key (nur serverseitig!)
### 4. Datenbank einrichten
Fuehre die SQL-Migration in deinem Supabase SQL Editor aus:
```
supabase/migrations/001_create_leads_table.sql
```
Optional Seed-Daten laden:
```
supabase/seed.sql
```
### 5. Starten
```bash
# Entwicklungsserver
pnpm dev
# Oder via Docker
docker compose up --build
```
App laeuft auf [http://localhost:3000](http://localhost:3000)
## Befehle
| Befehl | Beschreibung |
| --- | --- |
| `pnpm dev` | Entwicklungsserver starten |
| `pnpm build` | Production Build |
| `pnpm test` | Tests ausfuehren |
| `pnpm test:watch` | Tests im Watch-Modus |
| `pnpm test:coverage` | Tests mit Coverage-Report |
| `pnpm lint` | ESLint ausfuehren |
| `docker compose up --build` | Docker-Container starten |
## API
Vollstaendige API-Dokumentation: [docs/API.md](docs/API.md)
| Method | Endpoint | Beschreibung |
| --- | --- | --- |
| GET | `/api/leads?page=1&limit=10` | Leads mit Pagination |
| POST | `/api/leads` | Neuen Lead erstellen |
| GET | `/api/leads/:id` | Einzelnen Lead abrufen |
| PUT | `/api/leads/:id` | Lead aktualisieren |
| DELETE | `/api/leads/:id` | Lead soft-deleten |
## Projektstruktur
```text
src/
app/
api/leads/ # REST API (Route Handlers)
layout.tsx # Root Layout mit Header
page.tsx # Leads-Verwaltung (Startseite)
components/ # UI-Komponenten
lib/
supabase/ # Supabase Client
types/ # TypeScript Types
validations/ # Zod Schemas
test/ # Test Setup
supabase/
migrations/ # SQL Migrationen
seed.sql # Testdaten (15 Leads)
docs/ # Dokumentation
```
+22
View File
@@ -0,0 +1,22 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
interval: 30s
timeout: 3s
start_period: 10s
retries: 3
+207
View File
@@ -0,0 +1,207 @@
# API Documentation
## Base URL
```
http://localhost:3000/api
```
## Endpoints
### List Leads
```
GET /api/leads?page=1&limit=10
```
**Query Parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| page | number | 1 | Page number (min: 1) |
| limit | number | 10 | Items per page (min: 1, max: 100) |
**Response (200):**
```json
{
"data": [
{
"id": "uuid",
"first_name": "Max",
"email": "max@beispiel.de",
"phone": "+49 123 456789",
"fitness_goal": "abnehmen",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"deleted_at": null
}
],
"meta": {
"current_page": 1,
"per_page": 10,
"total": 15,
"total_pages": 2,
"has_more": true
}
}
```
---
### Create Lead
```
POST /api/leads
Content-Type: application/json
```
**Request Body:**
```json
{
"first_name": "Max",
"email": "max@beispiel.de",
"phone": "+49 123 456789",
"fitness_goal": "abnehmen"
}
```
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| first_name | string | yes | 2-100 chars, trimmed |
| email | string | yes | Valid email, max 255, lowercased |
| phone | string | no | Max 20 chars, digits/spaces/+/- |
| fitness_goal | enum | yes | abnehmen, muskelaufbau, ausdauer, flexibilitaet |
**Response (201):**
```json
{
"data": { ... }
}
```
**Error (422) - Validation:**
```json
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validierungsfehler",
"details": {
"email": ["Bitte eine gueltige E-Mail-Adresse eingeben"]
}
}
}
```
**Error (409) - Duplicate email:**
```json
{
"error": {
"code": "CONFLICT",
"message": "Ein Lead mit dieser E-Mail-Adresse existiert bereits"
}
}
```
---
### Get Lead
```
GET /api/leads/:id
```
**Response (200):**
```json
{
"data": { ... }
}
```
**Error (404):**
```json
{
"error": {
"code": "NOT_FOUND",
"message": "Lead nicht gefunden"
}
}
```
---
### Update Lead
```
PUT /api/leads/:id
Content-Type: application/json
```
**Request Body (partial update):**
```json
{
"first_name": "Maximilian",
"fitness_goal": "muskelaufbau"
}
```
All fields are optional but at least one must be provided.
**Response (200):**
```json
{
"data": { ... }
}
```
---
### Delete Lead (Soft Delete)
```
DELETE /api/leads/:id
```
Sets `deleted_at` timestamp instead of removing the record.
**Response (200):**
```json
{
"data": {
"message": "Lead erfolgreich geloescht"
}
}
```
---
## Error Response Format
All errors follow this structure:
```json
{
"error": {
"code": "ERROR_CODE",
"message": "Human-readable message",
"details": {}
}
}
```
| HTTP Status | Code | Description |
|------------|------|-------------|
| 400 | INVALID_ID / INVALID_JSON | Bad request |
| 404 | NOT_FOUND | Resource not found |
| 409 | CONFLICT | Duplicate entry |
| 422 | VALIDATION_ERROR | Input validation failed |
| 500 | DATABASE_ERROR / INTERNAL_ERROR | Server error |
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
+38
View File
@@ -0,0 +1,38 @@
{
"name": "fitcoachpro",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@supabase/supabase-js": "^2.101.1",
"lucide-react": "^1.7.0",
"next": "16.2.2",
"react": "19.2.4",
"react-dom": "19.2.4",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"jsdom": "^29.0.1",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.1.2"
}
}
+5150
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+213
View File
@@ -0,0 +1,213 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerClient } from "@/lib/supabase/server";
import { updateLeadSchema } from "@/lib/validations/lead";
import type { Lead, ApiError } from "@/lib/types/database";
import { ZodError } from "zod/v4";
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function validateId(id: string): boolean {
return UUID_REGEX.test(id);
}
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
): Promise<NextResponse<{ data: Lead } | ApiError>> {
const { id } = await params;
if (!validateId(id)) {
return NextResponse.json(
{ error: { code: "INVALID_ID", message: "Ungültige Lead-ID" } },
{ status: 400 }
);
}
try {
const supabase = getServerClient();
const { data, error } = await supabase
.from("leads")
.select("*")
.eq("id", id)
.is("deleted_at", null)
.single();
if (error || !data) {
return NextResponse.json(
{ error: { code: "NOT_FOUND", message: "Lead nicht gefunden" } },
{ status: 404 }
);
}
return NextResponse.json({ data });
} catch {
return NextResponse.json(
{ error: { code: "INTERNAL_ERROR", message: "Ein unerwarteter Fehler ist aufgetreten" } },
{ status: 500 }
);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
): Promise<NextResponse<{ data: Lead } | ApiError>> {
const { id } = await params;
if (!validateId(id)) {
return NextResponse.json(
{ error: { code: "INVALID_ID", message: "Ungültige Lead-ID" } },
{ status: 400 }
);
}
try {
const body = await request.json();
const validated = updateLeadSchema.parse(body);
if (Object.keys(validated).length === 0) {
return NextResponse.json(
{
error: {
code: "VALIDATION_ERROR",
message: "Mindestens ein Feld muss aktualisiert werden",
},
},
{ status: 422 }
);
}
const supabase = getServerClient();
const { data: existing } = await supabase
.from("leads")
.select("id")
.eq("id", id)
.is("deleted_at", null)
.single();
if (!existing) {
return NextResponse.json(
{ error: { code: "NOT_FOUND", message: "Lead nicht gefunden" } },
{ status: 404 }
);
}
if (validated.email) {
const { data: duplicate } = await supabase
.from("leads")
.select("id")
.eq("email", validated.email)
.is("deleted_at", null)
.neq("id", id)
.single();
if (duplicate) {
return NextResponse.json(
{
error: {
code: "CONFLICT",
message: "Ein anderer Lead mit dieser E-Mail-Adresse existiert bereits",
},
},
{ status: 409 }
);
}
}
const { data, error } = await supabase
.from("leads")
.update({ ...validated, updated_at: new Date().toISOString() })
.eq("id", id)
.is("deleted_at", null)
.select()
.single();
if (error || !data) {
return NextResponse.json(
{ error: { code: "DATABASE_ERROR", message: "Fehler beim Aktualisieren des Leads" } },
{ status: 500 }
);
}
return NextResponse.json({ data });
} catch (error) {
if (error instanceof ZodError) {
const fieldErrors: Record<string, string[]> = {};
for (const issue of error.issues) {
const field = issue.path.join(".");
if (!fieldErrors[field]) {
fieldErrors[field] = [];
}
fieldErrors[field].push(issue.message);
}
return NextResponse.json(
{
error: {
code: "VALIDATION_ERROR",
message: "Validierungsfehler",
details: fieldErrors,
},
},
{ status: 422 }
);
}
if (error instanceof SyntaxError) {
return NextResponse.json(
{ error: { code: "INVALID_JSON", message: "Ungültiges JSON im Request-Body" } },
{ status: 400 }
);
}
return NextResponse.json(
{ error: { code: "INTERNAL_ERROR", message: "Ein unerwarteter Fehler ist aufgetreten" } },
{ status: 500 }
);
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
): Promise<NextResponse<{ data: { message: string } } | ApiError>> {
const { id } = await params;
if (!validateId(id)) {
return NextResponse.json(
{ error: { code: "INVALID_ID", message: "Ungültige Lead-ID" } },
{ status: 400 }
);
}
try {
const supabase = getServerClient();
const { data, error } = await supabase
.from("leads")
.update({ deleted_at: new Date().toISOString() })
.eq("id", id)
.is("deleted_at", null)
.select()
.single();
if (error || !data) {
return NextResponse.json(
{ error: { code: "NOT_FOUND", message: "Lead nicht gefunden" } },
{ status: 404 }
);
}
return NextResponse.json({
data: { message: "Lead erfolgreich gelöscht" },
});
} catch {
return NextResponse.json(
{ error: { code: "INTERNAL_ERROR", message: "Ein unerwarteter Fehler ist aufgetreten" } },
{ status: 500 }
);
}
}
+160
View File
@@ -0,0 +1,160 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerClient } from "@/lib/supabase/server";
import {
createLeadSchema,
paginationSchema,
} from "@/lib/validations/lead";
import type { PaginatedResponse, Lead, ApiError } from "@/lib/types/database";
import { ZodError } from "zod/v4";
export async function GET(
request: NextRequest
): Promise<NextResponse<PaginatedResponse<Lead> | ApiError>> {
try {
const { searchParams } = new URL(request.url);
const parsed = paginationSchema.parse({
page: searchParams.get("page") ?? undefined,
limit: searchParams.get("limit") ?? undefined,
});
const { page, limit } = parsed;
const offset = (page - 1) * limit;
const supabase = getServerClient();
const { count } = await supabase
.from("leads")
.select("*", { count: "exact", head: true })
.is("deleted_at", null);
const total = count ?? 0;
const { data, error } = await supabase
.from("leads")
.select("*")
.is("deleted_at", null)
.order("created_at", { ascending: false })
.range(offset, offset + limit - 1);
if (error) {
return NextResponse.json(
{ error: { code: "DATABASE_ERROR", message: "Fehler beim Laden der Leads" } },
{ status: 500 }
);
}
const totalPages = Math.ceil(total / limit);
return NextResponse.json({
data: data ?? [],
meta: {
current_page: page,
per_page: limit,
total,
total_pages: totalPages,
has_more: page < totalPages,
},
});
} catch (error) {
if (error instanceof ZodError) {
return NextResponse.json(
{
error: {
code: "VALIDATION_ERROR",
message: "Ungültige Paginierungs-Parameter",
},
},
{ status: 400 }
);
}
return NextResponse.json(
{ error: { code: "INTERNAL_ERROR", message: "Ein unerwarteter Fehler ist aufgetreten" } },
{ status: 500 }
);
}
}
export async function POST(
request: NextRequest
): Promise<NextResponse<{ data: Lead } | ApiError>> {
try {
const body = await request.json();
const validated = createLeadSchema.parse(body);
const supabase = getServerClient();
const { data: existingLead } = await supabase
.from("leads")
.select("id")
.eq("email", validated.email)
.is("deleted_at", null)
.single();
if (existingLead) {
return NextResponse.json(
{
error: {
code: "CONFLICT",
message: "Ein Lead mit dieser E-Mail-Adresse existiert bereits",
},
},
{ status: 409 }
);
}
const { data, error } = await supabase
.from("leads")
.insert({
first_name: validated.first_name,
email: validated.email,
phone: validated.phone,
fitness_goal: validated.fitness_goal,
})
.select()
.single();
if (error) {
return NextResponse.json(
{ error: { code: "DATABASE_ERROR", message: "Fehler beim Speichern des Leads" } },
{ status: 500 }
);
}
return NextResponse.json({ data }, { status: 201 });
} catch (error) {
if (error instanceof ZodError) {
const fieldErrors: Record<string, string[]> = {};
for (const issue of error.issues) {
const field = issue.path.join(".");
if (!fieldErrors[field]) {
fieldErrors[field] = [];
}
fieldErrors[field].push(issue.message);
}
return NextResponse.json(
{
error: {
code: "VALIDATION_ERROR",
message: "Validierungsfehler",
details: fieldErrors,
},
},
{ status: 422 }
);
}
if (error instanceof SyntaxError) {
return NextResponse.json(
{ error: { code: "INVALID_JSON", message: "Ungültiges JSON im Request-Body" } },
{ status: 400 }
);
}
return NextResponse.json(
{ error: { code: "INTERNAL_ERROR", message: "Ein unerwarteter Fehler ist aufgetreten" } },
{ status: 500 }
);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+65
View File
@@ -0,0 +1,65 @@
@import "tailwindcss";
:root {
--background: #f8fafc;
--foreground: #0f172a;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--primary-light: #dbeafe;
--secondary: #64748b;
--success: #16a34a;
--success-light: #dcfce7;
--danger: #dc2626;
--danger-light: #fee2e2;
--warning: #f59e0b;
--warning-light: #fef3c7;
--border: #e2e8f0;
--card: #ffffff;
--muted: #f1f5f9;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-hover: var(--primary-hover);
--color-primary-light: var(--primary-light);
--color-secondary: var(--secondary);
--color-success: var(--success);
--color-success-light: var(--success-light);
--color-danger: var(--danger);
--color-danger-light: var(--danger-light);
--color-warning: var(--warning);
--color-warning-light: var(--warning-light);
--color-border: var(--border);
--color-card: var(--card);
--color-muted: var(--muted);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0f172a;
--foreground: #f1f5f9;
--primary: #3b82f6;
--primary-hover: #2563eb;
--primary-light: #1e3a5f;
--secondary: #94a3b8;
--success: #22c55e;
--success-light: #14532d;
--danger: #ef4444;
--danger-light: #450a0a;
--warning: #fbbf24;
--warning-light: #451a03;
--border: #334155;
--card: #1e293b;
--muted: #1e293b;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-geist-sans), system-ui, sans-serif;
}
+49
View File
@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "FitCoach Pro - Lead-Verwaltung",
description:
"Lead-Erfassung und Verwaltung für FitCoach Pro Online-Fitness-Coaching",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="de"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<header className="border-b border-border bg-card">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary text-white font-bold text-sm">
FC
</div>
<h1 className="text-lg font-semibold">FitCoach Pro</h1>
</div>
<span className="text-sm text-secondary">Lead-Verwaltung</span>
</div>
</div>
</header>
<main className="flex-1">{children}</main>
</body>
</html>
);
}
+196
View File
@@ -0,0 +1,196 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Plus, RefreshCw } from "lucide-react";
import { LeadTable } from "@/components/LeadTable";
import { LeadFormModal } from "@/components/LeadFormModal";
import { Pagination } from "@/components/Pagination";
import { ToastContainer } from "@/components/Toast";
import type { ToastMessage } from "@/components/Toast";
import type {
Lead,
FitnessGoal,
PaginationMeta,
} from "@/lib/types/database";
const DEFAULT_META: PaginationMeta = {
current_page: 1,
per_page: 10,
total: 0,
total_pages: 0,
has_more: false,
};
export default function LeadsPage() {
const [leads, setLeads] = useState<Lead[]>([]);
const [meta, setMeta] = useState<PaginationMeta>(DEFAULT_META);
const [isLoading, setIsLoading] = useState(true);
const [page, setPage] = useState(1);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingLead, setEditingLead] = useState<Lead | undefined>();
const [toasts, setToasts] = useState<ToastMessage[]>([]);
function addToast(type: "success" | "error", message: string) {
const id = crypto.randomUUID();
setToasts((prev) => [...prev, { id, type, message }]);
}
const dismissToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const fetchLeads = useCallback(async (pageNum: number) => {
setIsLoading(true);
try {
const response = await fetch(`/api/leads?page=${pageNum}&limit=10`);
const result = await response.json();
if (response.ok) {
setLeads(result.data);
setMeta(result.meta);
} else {
addToast("error", result.error?.message ?? "Fehler beim Laden der Leads");
}
} catch {
addToast("error", "Verbindungsfehler - bitte erneut versuchen");
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchLeads(page);
}, [page, fetchLeads]);
function handlePageChange(newPage: number) {
setPage(newPage);
}
function handleOpenCreate() {
setEditingLead(undefined);
setIsModalOpen(true);
}
function handleOpenEdit(lead: Lead) {
setEditingLead(lead);
setIsModalOpen(true);
}
function handleCloseModal() {
setIsModalOpen(false);
setEditingLead(undefined);
}
async function handleSubmit(data: {
first_name: string;
email: string;
phone: string | null;
fitness_goal: FitnessGoal;
}) {
const isEditing = Boolean(editingLead);
const url = isEditing ? `/api/leads/${editingLead!.id}` : "/api/leads";
const method = isEditing ? "PUT" : "POST";
const response = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const result = await response.json();
if (!response.ok) {
addToast("error", result.error?.message ?? "Fehler beim Speichern");
throw new Error(result.error?.message);
}
addToast(
"success",
isEditing
? `Lead "${data.first_name}" aktualisiert`
: `Lead "${data.first_name}" erstellt`
);
handleCloseModal();
fetchLeads(isEditing ? page : 1);
if (!isEditing) setPage(1);
}
async function handleDelete(id: string) {
const response = await fetch(`/api/leads/${id}`, { method: "DELETE" });
const result = await response.json();
if (!response.ok) {
addToast("error", result.error?.message ?? "Fehler beim Löschen");
return;
}
addToast("success", "Lead gelöscht");
fetchLeads(page);
}
return (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold">Leads</h2>
<p className="text-sm text-secondary mt-1">
{meta.total} {meta.total === 1 ? "Lead" : "Leads"} gesamt
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => fetchLeads(page)}
disabled={isLoading}
className="rounded-lg border border-border p-2.5 text-secondary hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50"
aria-label="Leads aktualisieren"
>
<RefreshCw
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
/>
</button>
<button
onClick={handleOpenCreate}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-hover transition-colors"
>
<Plus className="h-4 w-4" />
<span>Neuer Lead</span>
</button>
</div>
</div>
{isLoading && leads.length === 0 ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="h-16 rounded-lg bg-muted animate-pulse"
/>
))}
</div>
) : (
<>
<LeadTable
leads={leads}
onEdit={handleOpenEdit}
onDelete={handleDelete}
/>
<div className="mt-4">
<Pagination meta={meta} onPageChange={handlePageChange} />
</div>
</>
)}
<LeadFormModal
isOpen={isModalOpen}
lead={editingLead}
onSubmit={handleSubmit}
onClose={handleCloseModal}
/>
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { useEffect, useRef } from "react";
import { AlertTriangle } from "lucide-react";
interface DeleteConfirmDialogProps {
isOpen: boolean;
leadName: string;
isDeleting: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export function DeleteConfirmDialog({
isOpen,
leadName,
isDeleting,
onConfirm,
onCancel,
}: DeleteConfirmDialogProps) {
const cancelButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen) {
cancelButtonRef.current?.focus();
}
}, [isOpen]);
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape" && isOpen && !isDeleting) {
onCancel();
}
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, isDeleting, onCancel]);
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={(e) => {
if (e.target === e.currentTarget && !isDeleting) onCancel();
}}
role="dialog"
aria-modal="true"
aria-labelledby="delete-dialog-title"
>
<div className="w-full max-w-md rounded-lg bg-card p-6 shadow-xl">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-danger-light">
<AlertTriangle className="h-5 w-5 text-danger" />
</div>
<div>
<h3 id="delete-dialog-title" className="text-lg font-semibold">
Lead löschen
</h3>
<p className="mt-1 text-sm text-secondary">
Möchtest du den Lead{" "}
<span className="font-medium text-foreground">{leadName}</span>{" "}
wirklich löschen? Diese Aktion kann nicht rückgängig gemacht
werden.
</p>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
ref={cancelButtonRef}
onClick={onCancel}
disabled={isDeleting}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-muted transition-colors disabled:opacity-50"
>
Abbrechen
</button>
<button
onClick={onConfirm}
disabled={isDeleting}
className="rounded-lg bg-danger px-4 py-2 text-sm font-medium text-white hover:bg-danger/90 transition-colors disabled:opacity-50"
>
{isDeleting ? "Wird gelöscht..." : "Löschen"}
</button>
</div>
</div>
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
"use client";
import { useState } from "react";
import { createLeadSchema } from "@/lib/validations/lead";
import { FITNESS_GOALS } from "@/lib/types/database";
import type { Lead, FitnessGoal } from "@/lib/types/database";
import { ZodError } from "zod/v4";
interface LeadFormProps {
lead?: Lead;
onSubmit: (data: {
first_name: string;
email: string;
phone: string | null;
fitness_goal: FitnessGoal;
}) => Promise<void>;
onCancel: () => void;
}
interface FormErrors {
first_name?: string;
email?: string;
phone?: string;
fitness_goal?: string;
general?: string;
}
export function LeadForm({ lead, onSubmit, onCancel }: LeadFormProps) {
const [firstName, setFirstName] = useState(lead?.first_name ?? "");
const [email, setEmail] = useState(lead?.email ?? "");
const [phone, setPhone] = useState(lead?.phone ?? "");
const [fitnessGoal, setFitnessGoal] = useState<string>(
lead?.fitness_goal ?? ""
);
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const isEditing = Boolean(lead);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setErrors({});
try {
const validated = createLeadSchema.parse({
first_name: firstName,
email,
phone: phone || null,
fitness_goal: fitnessGoal,
});
setIsSubmitting(true);
await onSubmit(validated);
} catch (error) {
if (error instanceof ZodError) {
const fieldErrors: FormErrors = {};
for (const issue of error.issues) {
const field = issue.path[0] as keyof FormErrors;
if (!fieldErrors[field]) {
fieldErrors[field] = issue.message;
}
}
setErrors(fieldErrors);
}
} finally {
setIsSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
<div>
<label
htmlFor="first_name"
className="block text-sm font-medium mb-1.5"
>
Vorname <span className="text-danger">*</span>
</label>
<input
id="first_name"
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="z. B. Max"
autoFocus
className={`w-full rounded-lg border px-3 py-2.5 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50 ${
errors.first_name
? "border-danger bg-danger-light/30"
: "border-border bg-card hover:border-secondary"
}`}
aria-invalid={Boolean(errors.first_name)}
aria-describedby={errors.first_name ? "first_name-error" : undefined}
/>
{errors.first_name && (
<p id="first_name-error" className="mt-1 text-sm text-danger">
{errors.first_name}
</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1.5">
E-Mail <span className="text-danger">*</span>
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="max@beispiel.de"
className={`w-full rounded-lg border px-3 py-2.5 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50 ${
errors.email
? "border-danger bg-danger-light/30"
: "border-border bg-card hover:border-secondary"
}`}
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<p id="email-error" className="mt-1 text-sm text-danger">
{errors.email}
</p>
)}
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium mb-1.5">
Telefon
</label>
<input
id="phone"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+49 123 456789"
className={`w-full rounded-lg border px-3 py-2.5 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50 ${
errors.phone
? "border-danger bg-danger-light/30"
: "border-border bg-card hover:border-secondary"
}`}
aria-invalid={Boolean(errors.phone)}
aria-describedby={errors.phone ? "phone-error" : undefined}
/>
{errors.phone && (
<p id="phone-error" className="mt-1 text-sm text-danger">
{errors.phone}
</p>
)}
</div>
<div>
<label
htmlFor="fitness_goal"
className="block text-sm font-medium mb-1.5"
>
Fitness-Ziel <span className="text-danger">*</span>
</label>
<select
id="fitness_goal"
value={fitnessGoal}
onChange={(e) => setFitnessGoal(e.target.value)}
className={`w-full rounded-lg border px-3 py-2.5 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50 ${
errors.fitness_goal
? "border-danger bg-danger-light/30"
: "border-border bg-card hover:border-secondary"
}`}
aria-invalid={Boolean(errors.fitness_goal)}
aria-describedby={
errors.fitness_goal ? "fitness_goal-error" : undefined
}
>
<option value="">Bitte auswählen</option>
{FITNESS_GOALS.map((goal) => (
<option key={goal.value} value={goal.value}>
{goal.label}
</option>
))}
</select>
{errors.fitness_goal && (
<p id="fitness_goal-error" className="mt-1 text-sm text-danger">
{errors.fitness_goal}
</p>
)}
</div>
{errors.general && (
<div className="rounded-lg bg-danger-light border border-danger/20 px-4 py-3 text-sm text-danger">
{errors.general}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onCancel}
disabled={isSubmitting}
className="rounded-lg border border-border px-4 py-2.5 text-sm font-medium hover:bg-muted transition-colors disabled:opacity-50"
>
Abbrechen
</button>
<button
type="submit"
disabled={isSubmitting}
className="rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-white hover:bg-primary-hover transition-colors disabled:opacity-50"
>
{isSubmitting
? "Wird gespeichert..."
: isEditing
? "Aktualisieren"
: "Erstellen"}
</button>
</div>
</form>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useEffect } from "react";
import { X } from "lucide-react";
import { LeadForm } from "@/components/LeadForm";
import type { Lead, FitnessGoal } from "@/lib/types/database";
interface LeadFormModalProps {
isOpen: boolean;
lead?: Lead;
onSubmit: (data: {
first_name: string;
email: string;
phone: string | null;
fitness_goal: FitnessGoal;
}) => Promise<void>;
onClose: () => void;
}
export function LeadFormModal({
isOpen,
lead,
onSubmit,
onClose,
}: LeadFormModalProps) {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape" && isOpen) {
onClose();
}
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
const isEditing = Boolean(lead);
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-[10vh] overflow-y-auto"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
role="dialog"
aria-modal="true"
aria-labelledby="lead-form-title"
>
<div className="w-full max-w-lg rounded-lg bg-card p-6 shadow-xl">
<div className="flex items-center justify-between mb-6">
<h2 id="lead-form-title" className="text-lg font-semibold">
{isEditing ? "Lead bearbeiten" : "Neuen Lead erstellen"}
</h2>
<button
onClick={onClose}
className="rounded-md p-1.5 text-secondary hover:text-foreground hover:bg-muted transition-colors"
aria-label="Schließen"
>
<X className="h-5 w-5" />
</button>
</div>
<LeadForm lead={lead} onSubmit={onSubmit} onCancel={onClose} />
</div>
</div>
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { useState } from "react";
import { Pencil, Trash2 } from "lucide-react";
import type { Lead } from "@/lib/types/database";
import { FITNESS_GOALS } from "@/lib/types/database";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
interface LeadTableProps {
leads: Lead[];
onEdit: (lead: Lead) => void;
onDelete: (id: string) => Promise<void>;
}
function getFitnessGoalLabel(value: string): string {
return FITNESS_GOALS.find((g) => g.value === value)?.label ?? value;
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export function LeadTable({ leads, onEdit, onDelete }: LeadTableProps) {
const [deleteTarget, setDeleteTarget] = useState<Lead | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
async function handleConfirmDelete() {
if (!deleteTarget) return;
setIsDeleting(true);
try {
await onDelete(deleteTarget.id);
} finally {
setIsDeleting(false);
setDeleteTarget(null);
}
}
if (leads.length === 0) {
return (
<div className="rounded-lg border border-border bg-card p-12 text-center">
<p className="text-secondary text-lg">Noch keine Leads vorhanden</p>
<p className="text-secondary text-sm mt-1">
Erstelle deinen ersten Lead mit dem Button oben.
</p>
</div>
);
}
return (
<>
{/* Desktop Table */}
<div className="hidden md:block overflow-hidden rounded-lg border border-border bg-card">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-muted">
<th className="px-4 py-3 text-left text-sm font-medium text-secondary">
Vorname
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-secondary">
E-Mail
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-secondary">
Telefon
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-secondary">
Fitness-Ziel
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-secondary">
Erstellt am
</th>
<th className="px-4 py-3 text-right text-sm font-medium text-secondary">
Aktionen
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{leads.map((lead) => (
<tr key={lead.id} className="hover:bg-muted/50 transition-colors">
<td className="px-4 py-3 text-sm font-medium">
{lead.first_name}
</td>
<td className="px-4 py-3 text-sm">{lead.email}</td>
<td className="px-4 py-3 text-sm text-secondary">
{lead.phone ?? ""}
</td>
<td className="px-4 py-3 text-sm">
<span className="inline-flex items-center rounded-full bg-primary-light px-2.5 py-0.5 text-xs font-medium text-primary">
{getFitnessGoalLabel(lead.fitness_goal)}
</span>
</td>
<td className="px-4 py-3 text-sm text-secondary">
{formatDate(lead.created_at)}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => onEdit(lead)}
className="rounded-md p-1.5 text-secondary hover:text-primary hover:bg-primary-light transition-colors"
aria-label={`${lead.first_name} bearbeiten`}
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => setDeleteTarget(lead)}
className="rounded-md p-1.5 text-secondary hover:text-danger hover:bg-danger-light transition-colors"
aria-label={`${lead.first_name} löschen`}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile Cards */}
<div className="md:hidden space-y-3">
{leads.map((lead) => (
<div
key={lead.id}
className="rounded-lg border border-border bg-card p-4"
>
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{lead.first_name}</p>
<p className="text-sm text-secondary truncate">{lead.email}</p>
</div>
<div className="flex items-center gap-1 ml-2">
<button
onClick={() => onEdit(lead)}
className="rounded-md p-1.5 text-secondary hover:text-primary hover:bg-primary-light transition-colors"
aria-label={`${lead.first_name} bearbeiten`}
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => setDeleteTarget(lead)}
className="rounded-md p-1.5 text-secondary hover:text-danger hover:bg-danger-light transition-colors"
aria-label={`${lead.first_name} löschen`}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-3 flex items-center gap-3 text-sm">
{lead.phone && (
<span className="text-secondary">{lead.phone}</span>
)}
<span className="inline-flex items-center rounded-full bg-primary-light px-2.5 py-0.5 text-xs font-medium text-primary">
{getFitnessGoalLabel(lead.fitness_goal)}
</span>
</div>
<p className="mt-2 text-xs text-secondary">
{formatDate(lead.created_at)}
</p>
</div>
))}
</div>
<DeleteConfirmDialog
isOpen={deleteTarget !== null}
leadName={deleteTarget?.first_name ?? ""}
isDeleting={isDeleting}
onConfirm={handleConfirmDelete}
onCancel={() => setDeleteTarget(null)}
/>
</>
);
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import type { PaginationMeta } from "@/lib/types/database";
interface PaginationProps {
meta: PaginationMeta;
onPageChange: (page: number) => void;
}
export function Pagination({ meta, onPageChange }: PaginationProps) {
if (meta.total_pages <= 1) return null;
return (
<div className="flex items-center justify-between border-t border-border bg-card px-4 py-3 sm:px-6 rounded-b-lg">
<div className="text-sm text-secondary">
<span className="font-medium">{meta.total}</span>{" "}
{meta.total === 1 ? "Lead" : "Leads"} gesamt
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(meta.current_page - 1)}
disabled={meta.current_page <= 1}
className="rounded-md border border-border p-1.5 text-sm hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label="Vorherige Seite"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-sm text-secondary">
Seite {meta.current_page} von {meta.total_pages}
</span>
<button
onClick={() => onPageChange(meta.current_page + 1)}
disabled={!meta.has_more}
className="rounded-md border border-border p-1.5 text-sm hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label="Nächste Seite"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { useEffect } from "react";
import { CheckCircle, XCircle, X } from "lucide-react";
export interface ToastMessage {
id: string;
type: "success" | "error";
message: string;
}
interface ToastProps {
toast: ToastMessage;
onDismiss: (id: string) => void;
}
export function Toast({ toast, onDismiss }: ToastProps) {
useEffect(() => {
const timer = setTimeout(() => {
onDismiss(toast.id);
}, 4000);
return () => clearTimeout(timer);
}, [toast.id, onDismiss]);
const isSuccess = toast.type === "success";
return (
<div
className={`flex items-center gap-3 rounded-lg border px-4 py-3 shadow-lg ${
isSuccess
? "border-success/20 bg-success-light text-success"
: "border-danger/20 bg-danger-light text-danger"
}`}
role="alert"
>
{isSuccess ? (
<CheckCircle className="h-5 w-5 shrink-0" />
) : (
<XCircle className="h-5 w-5 shrink-0" />
)}
<p className="text-sm font-medium flex-1">{toast.message}</p>
<button
onClick={() => onDismiss(toast.id)}
className="shrink-0 rounded-md p-0.5 hover:opacity-70 transition-opacity"
aria-label="Benachrichtigung schließen"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
interface ToastContainerProps {
toasts: ToastMessage[];
onDismiss: (id: string) => void;
}
export function ToastContainer({ toasts, onDismiss }: ToastContainerProps) {
if (toasts.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
{toasts.map((toast) => (
<Toast key={toast.id} toast={toast} onDismiss={onDismiss} />
))}
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { LeadForm } from "../LeadForm";
import type { Lead } from "@/lib/types/database";
const mockLead: Lead = {
id: "123e4567-e89b-12d3-a456-426614174000",
first_name: "Max",
email: "max@beispiel.de",
phone: "+49 123 456789",
fitness_goal: "abnehmen",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
deleted_at: null,
};
describe("LeadForm", () => {
it("should render all form fields", () => {
render(<LeadForm onSubmit={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByLabelText(/vorname/i)).toBeInTheDocument();
expect(screen.getByLabelText(/e-mail/i)).toBeInTheDocument();
expect(screen.getByLabelText(/telefon/i)).toBeInTheDocument();
expect(screen.getByLabelText(/fitness-ziel/i)).toBeInTheDocument();
});
it("should show 'Erstellen' button for new lead", () => {
render(<LeadForm onSubmit={vi.fn()} onCancel={vi.fn()} />);
const buttons = screen.getAllByRole("button", { name: /erstellen/i });
expect(buttons.length).toBeGreaterThanOrEqual(1);
});
it("should show 'Aktualisieren' button when editing", () => {
render(
<LeadForm lead={mockLead} onSubmit={vi.fn()} onCancel={vi.fn()} />
);
const buttons = screen.getAllByRole("button", { name: /aktualisieren/i });
expect(buttons.length).toBeGreaterThanOrEqual(1);
});
it("should pre-fill fields when editing", async () => {
render(
<LeadForm lead={mockLead} onSubmit={vi.fn()} onCancel={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByLabelText(/vorname/i)).toHaveValue("Max");
expect(screen.getByLabelText(/e-mail/i)).toHaveValue("max@beispiel.de");
expect(screen.getByLabelText(/telefon/i)).toHaveValue("+49 123 456789");
expect(screen.getByLabelText(/fitness-ziel/i)).toHaveValue("abnehmen");
});
});
it("should call onCancel when cancel button is clicked", async () => {
const onCancel = vi.fn();
render(<LeadForm onSubmit={vi.fn()} onCancel={onCancel} />);
const cancelButtons = screen.getAllByRole("button", { name: /abbrechen/i });
fireEvent.click(cancelButtons[0]);
await waitFor(() => {
expect(onCancel).toHaveBeenCalledOnce();
});
});
it("should show validation errors for empty required fields", async () => {
render(<LeadForm onSubmit={vi.fn()} onCancel={vi.fn()} />);
const submitButtons = screen.getAllByRole("button", { name: /erstellen/i });
fireEvent.click(submitButtons[0]);
await waitFor(() => {
expect(screen.getByText(/vorname muss mindestens/i)).toBeInTheDocument();
});
});
it("should show validation error for invalid email", async () => {
render(<LeadForm onSubmit={vi.fn()} onCancel={vi.fn()} />);
fireEvent.change(screen.getByLabelText(/vorname/i), {
target: { value: "Max" },
});
fireEvent.change(screen.getByLabelText(/e-mail/i), {
target: { value: "not-valid" },
});
fireEvent.change(screen.getByLabelText(/fitness-ziel/i), {
target: { value: "abnehmen" },
});
const submitButtons = screen.getAllByRole("button", { name: /erstellen/i });
fireEvent.click(submitButtons[0]);
await waitFor(() => {
expect(screen.getByText(/gültige e-mail/i)).toBeInTheDocument();
});
});
it("should call onSubmit with valid data", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<LeadForm onSubmit={onSubmit} onCancel={vi.fn()} />);
fireEvent.change(screen.getByLabelText(/vorname/i), {
target: { value: "Anna" },
});
fireEvent.change(screen.getByLabelText(/e-mail/i), {
target: { value: "anna@test.de" },
});
fireEvent.change(screen.getByLabelText(/telefon/i), {
target: { value: "+49 111 222333" },
});
fireEvent.change(screen.getByLabelText(/fitness-ziel/i), {
target: { value: "muskelaufbau" },
});
const submitButtons = screen.getAllByRole("button", { name: /erstellen/i });
fireEvent.click(submitButtons[0]);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
first_name: "Anna",
email: "anna@test.de",
phone: "+49 111 222333",
fitness_goal: "muskelaufbau",
});
});
});
it("should render all fitness goal options", () => {
render(<LeadForm onSubmit={vi.fn()} onCancel={vi.fn()} />);
const select = screen.getByLabelText(/fitness-ziel/i);
expect(select).toContainHTML("Abnehmen");
expect(select).toContainHTML("Muskelaufbau");
expect(select).toContainHTML("Ausdauer");
expect(select).toContainHTML("Flexibilität");
});
});
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { LeadTable } from "../LeadTable";
import type { Lead } from "@/lib/types/database";
const mockLeads: Lead[] = [
{
id: "id-1",
first_name: "Max",
email: "max@beispiel.de",
phone: "+49 123 456789",
fitness_goal: "abnehmen",
created_at: "2026-01-15T10:30:00Z",
updated_at: "2026-01-15T10:30:00Z",
deleted_at: null,
},
{
id: "id-2",
first_name: "Anna",
email: "anna@beispiel.de",
phone: null,
fitness_goal: "muskelaufbau",
created_at: "2026-01-16T14:00:00Z",
updated_at: "2026-01-16T14:00:00Z",
deleted_at: null,
},
];
describe("LeadTable", () => {
it("should show empty state when no leads", () => {
render(
<LeadTable leads={[]} onEdit={vi.fn()} onDelete={vi.fn()} />
);
expect(screen.getByText(/noch keine leads vorhanden/i)).toBeInTheDocument();
});
it("should render lead data", () => {
render(
<LeadTable leads={mockLeads} onEdit={vi.fn()} onDelete={vi.fn()} />
);
expect(screen.getAllByText("Max").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("max@beispiel.de").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Anna").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("anna@beispiel.de").length).toBeGreaterThanOrEqual(1);
});
it("should display fitness goal labels correctly", () => {
render(
<LeadTable leads={mockLeads} onEdit={vi.fn()} onDelete={vi.fn()} />
);
expect(screen.getAllByText("Abnehmen").length).toBeGreaterThan(0);
expect(screen.getAllByText("Muskelaufbau").length).toBeGreaterThan(0);
});
it("should call onEdit when edit button is clicked", async () => {
const onEdit = vi.fn();
render(
<LeadTable leads={mockLeads} onEdit={onEdit} onDelete={vi.fn()} />
);
const editButtons = screen.getAllByLabelText(/max bearbeiten/i);
fireEvent.click(editButtons[0]);
await waitFor(() => {
expect(onEdit).toHaveBeenCalledWith(mockLeads[0]);
});
});
it("should show delete confirmation dialog", async () => {
render(
<LeadTable leads={mockLeads} onEdit={vi.fn()} onDelete={vi.fn()} />
);
const deleteButtons = screen.getAllByLabelText(/max löschen/i);
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(screen.getByText(/lead löschen/i)).toBeInTheDocument();
});
});
it("should call onDelete after confirming", async () => {
const onDelete = vi.fn().mockResolvedValue(undefined);
render(
<LeadTable leads={mockLeads} onEdit={vi.fn()} onDelete={onDelete} />
);
const deleteButtons = screen.getAllByLabelText(/max löschen/i);
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(screen.getByText(/lead löschen/i)).toBeInTheDocument();
});
// Find the confirm "Löschen" button in the dialog
const dialogButtons = screen.getAllByRole("button");
const confirmButton = dialogButtons.find(
(btn) => btn.textContent === "Löschen"
);
expect(confirmButton).toBeTruthy();
fireEvent.click(confirmButton!);
await waitFor(() => {
expect(onDelete).toHaveBeenCalledWith("id-1");
});
});
it("should show dash for missing phone in desktop table", () => {
render(
<LeadTable leads={mockLeads} onEdit={vi.fn()} onDelete={vi.fn()} />
);
const cells = document.querySelectorAll("td");
const dashCell = Array.from(cells).find(
(cell) => cell.textContent === ""
);
expect(dashCell).toBeTruthy();
});
});
@@ -0,0 +1,116 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { Pagination } from "../Pagination";
import type { PaginationMeta } from "@/lib/types/database";
describe("Pagination", () => {
it("should not render when there is only one page", () => {
const meta: PaginationMeta = {
current_page: 1,
per_page: 10,
total: 5,
total_pages: 1,
has_more: false,
};
const { container } = render(
<Pagination meta={meta} onPageChange={vi.fn()} />
);
expect(container.innerHTML).toBe("");
});
it("should render page info", () => {
const meta: PaginationMeta = {
current_page: 1,
per_page: 10,
total: 25,
total_pages: 3,
has_more: true,
};
render(<Pagination meta={meta} onPageChange={vi.fn()} />);
expect(screen.getByText(/seite 1 von 3/i)).toBeInTheDocument();
expect(screen.getByText("25")).toBeInTheDocument();
});
it("should disable previous button on first page", async () => {
const meta: PaginationMeta = {
current_page: 1,
per_page: 10,
total: 25,
total_pages: 3,
has_more: true,
};
render(<Pagination meta={meta} onPageChange={vi.fn()} />);
await waitFor(() => {
const prevButtons = screen.getAllByLabelText(/vorherige seite/i);
const nextButtons = screen.getAllByLabelText(/nächste seite/i);
expect(prevButtons[0]).toBeDisabled();
expect(nextButtons[0]).not.toBeDisabled();
});
});
it("should disable next button on last page", async () => {
const meta: PaginationMeta = {
current_page: 3,
per_page: 10,
total: 25,
total_pages: 3,
has_more: false,
};
render(<Pagination meta={meta} onPageChange={vi.fn()} />);
await waitFor(() => {
const prevButtons = screen.getAllByLabelText(/vorherige seite/i);
const nextButtons = screen.getAllByLabelText(/nächste seite/i);
expect(prevButtons[0]).not.toBeDisabled();
expect(nextButtons[0]).toBeDisabled();
});
});
it("should call onPageChange with correct page", async () => {
const onPageChange = vi.fn();
const meta: PaginationMeta = {
current_page: 2,
per_page: 10,
total: 25,
total_pages: 3,
has_more: true,
};
render(<Pagination meta={meta} onPageChange={onPageChange} />);
const nextButtons = screen.getAllByLabelText(/nächste seite/i);
fireEvent.click(nextButtons[0]);
await waitFor(() => {
expect(onPageChange).toHaveBeenCalledWith(3);
});
const prevButtons = screen.getAllByLabelText(/vorherige seite/i);
fireEvent.click(prevButtons[0]);
await waitFor(() => {
expect(onPageChange).toHaveBeenCalledWith(1);
});
});
it("should show total count", () => {
const meta: PaginationMeta = {
current_page: 1,
per_page: 10,
total: 25,
total_pages: 3,
has_more: true,
};
render(<Pagination meta={meta} onPageChange={vi.fn()} />);
expect(screen.getByText("25")).toBeInTheDocument();
});
});
+16
View File
@@ -0,0 +1,16 @@
import { createClient } from "@supabase/supabase-js";
function getServerClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseServiceKey) {
throw new Error(
"Missing Supabase environment variables. Check .env.example for required keys."
);
}
return createClient(supabaseUrl, supabaseServiceKey);
}
export { getServerClient };
+44
View File
@@ -0,0 +1,44 @@
export type FitnessGoal =
| "abnehmen"
| "muskelaufbau"
| "ausdauer"
| "flexibilitaet";
export const FITNESS_GOALS: { value: FitnessGoal; label: string }[] = [
{ value: "abnehmen", label: "Abnehmen" },
{ value: "muskelaufbau", label: "Muskelaufbau" },
{ value: "ausdauer", label: "Ausdauer" },
{ value: "flexibilitaet", label: "Flexibilität" },
];
export interface Lead {
id: string;
first_name: string;
email: string;
phone: string | null;
fitness_goal: FitnessGoal;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
export interface PaginationMeta {
current_page: number;
per_page: number;
total: number;
total_pages: number;
has_more: boolean;
}
export interface PaginatedResponse<T> {
data: T[];
meta: PaginationMeta;
}
export interface ApiError {
error: {
code: string;
message: string;
details?: Record<string, string[]>;
};
}
+197
View File
@@ -0,0 +1,197 @@
import { describe, it, expect } from "vitest";
import { createLeadSchema, updateLeadSchema, paginationSchema } from "../lead";
describe("createLeadSchema", () => {
it("should validate a complete valid lead", () => {
const result = createLeadSchema.parse({
first_name: "Max",
email: "max@beispiel.de",
phone: "+49 123 456789",
fitness_goal: "abnehmen",
});
expect(result.first_name).toBe("Max");
expect(result.email).toBe("max@beispiel.de");
expect(result.phone).toBe("+49 123 456789");
expect(result.fitness_goal).toBe("abnehmen");
});
it("should validate a lead without phone (optional)", () => {
const result = createLeadSchema.parse({
first_name: "Anna",
email: "anna@beispiel.de",
fitness_goal: "muskelaufbau",
});
expect(result.first_name).toBe("Anna");
expect(result.phone).toBeNull();
});
it("should transform empty phone to null", () => {
const result = createLeadSchema.parse({
first_name: "Anna",
email: "anna@beispiel.de",
phone: "",
fitness_goal: "ausdauer",
});
expect(result.phone).toBeNull();
});
it("should lowercase and trim email", () => {
const result = createLeadSchema.parse({
first_name: "Max",
email: " Max@Beispiel.DE ",
fitness_goal: "flexibilitaet",
});
expect(result.email).toBe("max@beispiel.de");
});
it("should trim first_name", () => {
const result = createLeadSchema.parse({
first_name: " Max ",
email: "max@beispiel.de",
fitness_goal: "abnehmen",
});
expect(result.first_name).toBe("Max");
});
it("should reject first_name shorter than 2 characters", () => {
expect(() =>
createLeadSchema.parse({
first_name: "M",
email: "m@beispiel.de",
fitness_goal: "abnehmen",
})
).toThrow();
});
it("should reject first_name longer than 100 characters", () => {
expect(() =>
createLeadSchema.parse({
first_name: "A".repeat(101),
email: "max@beispiel.de",
fitness_goal: "abnehmen",
})
).toThrow();
});
it("should reject invalid email", () => {
expect(() =>
createLeadSchema.parse({
first_name: "Max",
email: "not-an-email",
fitness_goal: "abnehmen",
})
).toThrow();
});
it("should reject invalid fitness_goal", () => {
expect(() =>
createLeadSchema.parse({
first_name: "Max",
email: "max@beispiel.de",
fitness_goal: "yoga",
})
).toThrow();
});
it("should reject missing required fields", () => {
expect(() => createLeadSchema.parse({})).toThrow();
});
it("should accept all valid fitness goals", () => {
const goals = ["abnehmen", "muskelaufbau", "ausdauer", "flexibilitaet"];
for (const goal of goals) {
const result = createLeadSchema.parse({
first_name: "Max",
email: "max@beispiel.de",
fitness_goal: goal,
});
expect(result.fitness_goal).toBe(goal);
}
});
it("should reject invalid phone format", () => {
expect(() =>
createLeadSchema.parse({
first_name: "Max",
email: "max@beispiel.de",
phone: "abc-not-a-phone",
fitness_goal: "abnehmen",
})
).toThrow();
});
it("should accept various valid phone formats", () => {
const validPhones = [
"+49 123 456789",
"0123456789",
"+49 (0) 123/456-789",
"0049 123 456789",
];
for (const phone of validPhones) {
const result = createLeadSchema.parse({
first_name: "Max",
email: "max@beispiel.de",
phone,
fitness_goal: "abnehmen",
});
expect(result.phone).toBe(phone);
}
});
});
describe("updateLeadSchema", () => {
it("should accept partial updates", () => {
const result = updateLeadSchema.parse({
first_name: "Updated",
});
expect(result.first_name).toBe("Updated");
expect(result.email).toBeUndefined();
});
it("should accept empty object (all fields optional)", () => {
const result = updateLeadSchema.parse({});
// phone transform converts undefined to null even in partial schema
expect(result).toEqual({ phone: null });
});
it("should still validate field constraints", () => {
expect(() =>
updateLeadSchema.parse({
first_name: "M",
})
).toThrow();
});
});
describe("paginationSchema", () => {
it("should provide defaults", () => {
const result = paginationSchema.parse({});
expect(result.page).toBe(1);
expect(result.limit).toBe(10);
});
it("should coerce string values to numbers", () => {
const result = paginationSchema.parse({ page: "2", limit: "20" });
expect(result.page).toBe(2);
expect(result.limit).toBe(20);
});
it("should reject page less than 1", () => {
expect(() => paginationSchema.parse({ page: 0 })).toThrow();
});
it("should reject limit greater than 100", () => {
expect(() => paginationSchema.parse({ limit: 101 })).toThrow();
});
it("should reject non-integer values", () => {
expect(() => paginationSchema.parse({ page: 1.5 })).toThrow();
});
});
+46
View File
@@ -0,0 +1,46 @@
import { z } from "zod/v4";
const FITNESS_GOAL_VALUES = [
"abnehmen",
"muskelaufbau",
"ausdauer",
"flexibilitaet",
] as const;
export const createLeadSchema = z.object({
first_name: z
.string()
.trim()
.min(2, "Vorname muss mindestens 2 Zeichen lang sein")
.max(100, "Vorname darf maximal 100 Zeichen lang sein"),
email: z
.string()
.trim()
.toLowerCase()
.email("Bitte eine gültige E-Mail-Adresse eingeben")
.max(255, "E-Mail darf maximal 255 Zeichen lang sein"),
phone: z
.string()
.trim()
.max(20, "Telefonnummer darf maximal 20 Zeichen lang sein")
.regex(
/^[+]?[\d\s\-/()]*$/,
"Bitte eine gültige Telefonnummer eingeben"
)
.nullable()
.optional()
.transform((val) => (val === "" ? null : val ?? null)),
fitness_goal: z.enum(FITNESS_GOAL_VALUES, {
error: "Bitte ein Fitness-Ziel auswählen",
}),
});
export const updateLeadSchema = createLeadSchema.partial();
export const paginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(10),
});
export type CreateLeadInput = z.infer<typeof createLeadSchema>;
export type UpdateLeadInput = z.infer<typeof updateLeadSchema>;
+7
View File
@@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});
@@ -0,0 +1,32 @@
-- Create leads table for FitCoach Pro lead management
CREATE TABLE IF NOT EXISTS leads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
first_name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
phone VARCHAR(20),
fitness_goal VARCHAR(20) NOT NULL CHECK (
fitness_goal IN ('abnehmen', 'muskelaufbau', 'ausdauer', 'flexibilitaet')
),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
-- Indexes
CREATE UNIQUE INDEX idx_leads_email_active ON leads (email) WHERE deleted_at IS NULL;
CREATE INDEX idx_leads_fitness_goal ON leads (fitness_goal) WHERE deleted_at IS NULL;
CREATE INDEX idx_leads_created_at ON leads (created_at DESC) WHERE deleted_at IS NULL;
-- Auto-update updated_at trigger
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_leads_updated_at
BEFORE UPDATE ON leads
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
+17
View File
@@ -0,0 +1,17 @@
-- Seed data: realistic test leads for FitCoach Pro
INSERT INTO leads (first_name, email, phone, fitness_goal) VALUES
('Max', 'max.mueller@beispiel.de', '+49 151 12345678', 'abnehmen'),
('Anna', 'anna.schmidt@beispiel.de', '+49 170 98765432', 'muskelaufbau'),
('Lukas', 'lukas.fischer@beispiel.de', '+49 162 55544433', 'ausdauer'),
('Sophie', 'sophie.weber@beispiel.de', NULL, 'flexibilitaet'),
('Leon', 'leon.wagner@beispiel.de', '+49 157 11122233', 'abnehmen'),
('Emma', 'emma.becker@beispiel.de', '+49 176 44455566', 'muskelaufbau'),
('Jonas', 'jonas.hoffmann@beispiel.de', '+49 155 77788899', 'ausdauer'),
('Mia', 'mia.schaefer@beispiel.de', '+49 163 22233344', 'abnehmen'),
('Felix', 'felix.koch@beispiel.de', NULL, 'muskelaufbau'),
('Lina', 'lina.richter@beispiel.de', '+49 172 66677788', 'flexibilitaet'),
('Paul', 'paul.klein@beispiel.de', '+49 152 33344455', 'abnehmen'),
('Hannah', 'hannah.wolf@beispiel.de', '+49 178 99900011', 'ausdauer'),
('Tim', 'tim.schroeder@beispiel.de', NULL, 'muskelaufbau'),
('Laura', 'laura.neumann@beispiel.de', '+49 160 55566677', 'flexibilitaet'),
('Niklas', 'niklas.braun@beispiel.de', '+49 171 88899900', 'abnehmen');
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/test/**",
"src/**/*.test.{ts,tsx}",
"src/**/*.d.ts",
"src/app/layout.tsx",
],
thresholds: {
statements: 80,
branches: 80,
functions: 80,
lines: 80,
},
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});