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