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:
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
temp
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.claude
|
||||||
|
coverage
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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 -->
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
@@ -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 |
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5150
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
ignoredBuiltDependencies:
|
||||||
|
- sharp
|
||||||
|
- unrs-resolver
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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[]>;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>;
|
||||||
@@ -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();
|
||||||
@@ -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');
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user