main repo

This commit is contained in:
Basilosaurusrex
2025-11-24 18:09:40 +01:00
parent b636ee5e70
commit f027651f9b
34146 changed files with 4436636 additions and 0 deletions

BIN
.DS_Store vendored

Binary file not shown.

351
DEPLOYMENT_HETZNER.md Normal file
View File

@@ -0,0 +1,351 @@
# 🚀 Deployment-Anleitung für Hetzner Server
Diese Anleitung zeigt dir, wie du deine Next.js App auf einem Hetzner Server deployst.
## 📋 Voraussetzungen
- Hetzner Cloud Server (Ubuntu 22.04 oder neuer empfohlen)
- SSH-Zugang zum Server
- Domain (optional, aber empfohlen)
- Supabase-Projekt mit Environment Variables
---
## 🎯 Option 1: Direktes Deployment (Empfohlen)
### Schritt 1: Server vorbereiten
```bash
# Auf deinem Server (via SSH)
sudo apt update && sudo apt upgrade -y
# Node.js 20.x installieren
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
# PM2 für Process Management installieren
sudo npm install -g pm2
# Nginx installieren (für Reverse Proxy)
sudo apt install -y nginx
# Git installieren (falls nicht vorhanden)
sudo apt install -y git
```
### Schritt 2: Projekt auf Server klonen
```bash
# In deinem Home-Verzeichnis
cd ~
git clone https://github.com/DEIN_USERNAME/Webklar_app.git
cd Webklar_app
# Dependencies installieren
npm install
```
### Schritt 3: Environment Variables setzen
```bash
# .env.production Datei erstellen
nano .env.production
```
Füge folgende Variablen ein:
```env
NEXT_PUBLIC_SUPABASE_URL=deine_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=dein_supabase_anon_key
NODE_ENV=production
```
### Schritt 4: Build erstellen
```bash
npm run build
```
### Schritt 5: Mit PM2 starten
```bash
# PM2 Ecosystem File erstellen
nano ecosystem.config.js
```
Füge folgendes ein:
```javascript
module.exports = {
apps: [{
name: 'webklar-app',
script: 'npm',
args: 'start',
cwd: '/root/Webklar_app', // Passe den Pfad an
env: {
NODE_ENV: 'production',
PORT: 3000
}
}]
}
```
```bash
# App mit PM2 starten
pm2 start ecosystem.config.js
# PM2 beim Server-Start automatisch starten
pm2 startup
pm2 save
```
### Schritt 6: Nginx konfigurieren
```bash
sudo nano /etc/nginx/sites-available/webklar
```
Füge folgende Konfiguration ein:
```nginx
server {
listen 80;
server_name deine-domain.de www.deine-domain.de;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
```
```bash
# Nginx Konfiguration aktivieren
sudo ln -s /etc/nginx/sites-available/webklar /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
```
### Schritt 7: SSL mit Let's Encrypt (Optional, aber empfohlen)
```bash
# Certbot installieren
sudo apt install -y certbot python3-certbot-nginx
# SSL-Zertifikat erstellen
sudo certbot --nginx -d deine-domain.de -d www.deine-domain.de
# Auto-Renewal testen
sudo certbot renew --dry-run
```
---
## 🐳 Option 2: Deployment mit Docker (Einfacher)
### Schritt 1: Dockerfile erstellen
Erstelle eine `Dockerfile` im Projekt-Root:
```dockerfile
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Production image, copy all the files and run next
FROM base 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"
CMD ["node", "server.js"]
```
### Schritt 2: next.config.js anpassen
Füge `output: 'standalone'` zur next.config.js hinzu:
```javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone', // Für Docker
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
images: { unoptimized: true },
trailingSlash: true,
};
module.exports = nextConfig;
```
### Schritt 3: docker-compose.yml erstellen
```yaml
version: '3.8'
services:
webklar-app:
build: .
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
- NODE_ENV=production
restart: unless-stopped
```
### Schritt 4: Auf Server deployen
```bash
# Auf deinem Server
cd ~
git clone https://github.com/DEIN_USERNAME/Webklar_app.git
cd Webklar_app
# Docker installieren (falls nicht vorhanden)
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# Docker Compose installieren
sudo apt install -y docker-compose
# .env Datei erstellen
nano .env
# Füge deine Environment Variables ein
# Container bauen und starten
docker-compose up -d --build
# Logs ansehen
docker-compose logs -f
```
---
## 🔄 Updates deployen
### Option 1 (Direktes Deployment):
```bash
cd ~/Webklar_app
git pull origin main
npm install
npm run build
pm2 restart webklar-app
```
### Option 2 (Docker):
```bash
cd ~/Webklar_app
git pull origin main
docker-compose up -d --build
```
---
## 📊 Monitoring & Logs
### PM2:
```bash
pm2 status # Status anzeigen
pm2 logs webklar-app # Logs anzeigen
pm2 monit # Live Monitoring
```
### Docker:
```bash
docker-compose logs -f webklar-app
docker-compose ps
```
---
## 🔒 Sicherheit
1. **Firewall konfigurieren:**
```bash
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw enable
```
2. **Environment Variables niemals committen!**
3. **Regelmäßige Backups erstellen**
4. **SSH Keys statt Passwörter verwenden**
---
## 🐛 Troubleshooting
### App startet nicht:
- Prüfe Logs: `pm2 logs` oder `docker-compose logs`
- Prüfe Port 3000: `sudo netstat -tulpn | grep 3000`
- Prüfe Environment Variables
### Nginx Fehler:
- Teste Konfiguration: `sudo nginx -t`
- Prüfe Logs: `sudo tail -f /var/log/nginx/error.log`
### Build Fehler:
- Node.js Version prüfen: `node -v` (sollte 20.x sein)
- Dependencies neu installieren: `rm -rf node_modules && npm install`
---
## 📝 Checkliste vor dem Deployment
- [ ] Server vorbereitet (Node.js, PM2/Docker, Nginx)
- [ ] Projekt auf Server geklont
- [ ] Environment Variables gesetzt
- [ ] Build erfolgreich erstellt
- [ ] App läuft auf Port 3000
- [ ] Nginx konfiguriert und getestet
- [ ] SSL-Zertifikat installiert (optional)
- [ ] Firewall konfiguriert
- [ ] Domain zeigt auf Server-IP
---
**Viel Erfolg beim Deployment! 🚀**

47
Dockerfile Normal file
View File

@@ -0,0 +1,47 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Production image, copy all the files and run next
FROM base 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
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
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"
CMD ["node", "server.js"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Professional Appointment Booking System
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

204
README.md Normal file
View File

@@ -0,0 +1,204 @@
# 🎯 Professional Appointment Booking System
Ein professionelles Terminbuchungssystem mit Admin-Dashboard für Kundenprojekte und strukturierte Kundenbefragung.
## ✨ Features
### 📅 **Terminbuchung**
- **E-Mail-Verifikation** mit Supabase Auth
- **Automatische Bestätigung** nach Klick auf E-Mail-Link
- **Seamless User Flow** mit automatischen Weiterleitungen
- **Responsive Design** für alle Geräte
### 🏢 **Admin-Dashboard**
- **Professionelles UI** mit Glassmorphism-Design
- **Live-Status-Updates** für Termine
- **Strukturierte Kundenbefragung** mit 6 Hauptbereichen
- **Fortschritts-Tracking** in Echtzeit
- **Kreuz und quer Navigation** für effiziente Arbeit
### 📊 **Kundenprojekte Management**
- **Termin-Status-System:** Wartend → Läuft → Abgeschlossen
- **Live-Kollaboration** zwischen Admins
- **Vollständige Datenbearbeitung** in Echtzeit
- **Strukturierte Projektübersicht** für produktive Arbeit
## 🚀 Quick Start
### **1. Repository klonen**
```bash
git clone https://github.com/yourusername/appointment-booking-system.git
cd appointment-booking-system
```
### **2. Dependencies installieren**
```bash
npm install
```
### **3. Environment Variables setzen**
```bash
# .env.local erstellen
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
```
### **4. Development Server starten**
```bash
npm run dev
```
### **5. Browser öffnen**
```
http://localhost:3000
```
## 🛠️ Technologie-Stack
### **Frontend**
- **Next.js 14** mit App Router
- **React 18** mit TypeScript
- **TailwindCSS** für Styling
- **shadcn/ui** für UI-Komponenten
- **Lucide React** für Icons
### **Backend**
- **Supabase** für Datenbank und Auth
- **PostgreSQL** als Datenbank
- **Row Level Security (RLS)** für Sicherheit
### **Deployment**
- **Vercel** (empfohlen)
- **Netlify**
- **Any Static Hosting**
## 📋 Projektstruktur
```
project/
├── app/ # Next.js App Router
│ ├── auth/ # Authentifizierung
│ ├── kunden-projekte/ # Admin Dashboard
│ ├── success/ # Erfolgs-Seite
│ └── admin-login/ # Admin Login
├── components/ # React Komponenten
│ ├── ui/ # shadcn/ui Komponenten
│ ├── AppointmentStatus.tsx # Termin-Status
│ └── CustomerQuestionnaire.tsx # Kundenbefragung
├── hooks/ # Custom React Hooks
├── lib/ # Utilities
├── middleware.ts # Next.js Middleware
└── docs/ # Dokumentation
```
## 🎯 Workflow
### **1. Kunde bucht Termin**
```
Kunde → Terminbuchung → E-Mail-Verifikation → Automatische Bestätigung → Erfolgs-Seite
```
### **2. Admin verwaltet Termine**
```
Admin → Kunden-Projekte → Termin starten → Befragung → Projektarbeit
```
### **3. Strukturierte Kundenbefragung**
- **Kontaktdaten** (Firma, Ansprechpartner, E-Mail, Telefon)
- **Projektinfo** (Beschreibung, Zielgruppe, Website-Ziele)
- **Design & Features** (Design-Wünsche, Features, Stilvorbilder)
- **Technische Details** (Integrationen, Funktionen, Betreuung)
- **Zeitplan & Budget** (Deadline, Budget, Kommunikation)
## 🔧 Konfiguration
### **Supabase Setup**
1. **Projekt erstellen** auf [supabase.com](https://supabase.com)
2. **Database Schema** importieren (siehe `docs/`)
3. **Environment Variables** setzen
4. **Row Level Security** aktivieren
### **Email Setup**
1. **SMTP Provider** konfigurieren
2. **E-Mail-Templates** anpassen
3. **Domain-Verifikation** durchführen
## 📚 Dokumentation
### **Setup Guides**
- [Installation Guide](INSTALLATION_GUIDE.md)
- [Environment Setup](ENV_SETUP.md)
- [Supabase Auth Setup](SUPABASE_AUTH_SETUP.md)
### **Feature Guides**
- [Admin Dashboard](ADMIN_TERMIN_SYSTEM.md)
- [Database Schema](DATABASE_SCHEMA_FIX.md)
- [E-Mail-Verifikation Setup](EMAIL_VERIFICATION_SETUP.md)
### **Troubleshooting**
- [Database Debug](DATABASE_DEBUG_GUIDE.md)
- [Next.js Fixes](NEXTJS_CLIENT_SERVER_FIX.md)
- [Auth Flow](SUCCESSFUL_AUTH_FLOW.md)
## 🎨 Design Features
### **Professional UI**
- **Glassmorphism-Effekt** mit Backdrop-Blur
- **Gradient-Hintergründe** für modernen Look
- **Responsive Design** für alle Geräte
- **Intuitive Navigation** mit Icons
### **User Experience**
- **Live-Updates** für alle Status-Änderungen
- **Fortschritts-Balken** für Befragung
- **Toast-Benachrichtigungen** für Feedback
- **Loading States** für bessere UX
## 🔒 Sicherheit
### **Authentifizierung**
- **E-Mail-Verifikation** ohne Passwörter
- **Automatische Bestätigung** nach Klick auf E-Mail-Link
- **Session Management** mit Supabase
### **Datenbank-Sicherheit**
- **Row Level Security (RLS)** aktiviert
- **Prepared Statements** gegen SQL-Injection
- **Input Validation** auf Client und Server
## 🚀 Deployment
### **Vercel (Empfohlen)**
```bash
npm install -g vercel
vercel
```
### **Environment Variables**
```bash
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
```
## 🤝 Contributing
1. **Fork** das Repository
2. **Feature Branch** erstellen (`git checkout -b feature/AmazingFeature`)
3. **Commit** Änderungen (`git commit -m 'Add some AmazingFeature'`)
4. **Push** zum Branch (`git push origin feature/AmazingFeature`)
5. **Pull Request** erstellen
## 📄 License
Dieses Projekt ist unter der MIT License lizenziert - siehe [LICENSE](LICENSE) Datei für Details.
## 🙏 Credits
- **shadcn/ui** für UI-Komponenten
- **Lucide React** für Icons
- **Supabase** für Backend-Services
- **Next.js** für das Framework
---
**Entwickelt mit ❤️ für professionelle Terminbuchung und Kundenprojektverwaltung**

Binary file not shown.

107
app/admin-login/page.tsx Normal file
View File

@@ -0,0 +1,107 @@
"use client";
import { useState } from 'react';
import { supabase } from '@/lib/supabase';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useRouter } from 'next/navigation';
import { useToast } from '@/hooks/use-toast';
export default function AdminLoginPage() {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter();
const { toast } = useToast();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${window.location.origin}/kunden-projekte`
}
});
if (error) {
console.error('❌ Login error:', error);
toast({
title: "Fehler",
description: "Login fehlgeschlagen. Bitte versuchen Sie es erneut.",
variant: "destructive",
});
return;
}
toast({
title: "E-Mail gesendet",
description: "Prüfen Sie Ihre E-Mail für den Login-Link.",
});
} catch (error) {
console.error('❌ Error:', error);
toast({
title: "Fehler",
description: "Ein unerwarteter Fehler ist aufgetreten.",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
return (
<div className="container mx-auto p-6">
<div className="max-w-md mx-auto">
<div className="mb-6 text-center">
<h1 className="text-3xl font-bold mb-2">Admin Login</h1>
<p className="text-gray-600">
Melden Sie sich an, um auf die Admin-Funktionen zuzugreifen
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Administrator-Zugang</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-Mail-Adresse</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@beispiel.de"
required
/>
</div>
<Button
type="submit"
disabled={loading}
className="w-full"
>
{loading ? 'Sende E-Mail...' : 'E-Mail senden'}
</Button>
</form>
<div className="mt-4 text-center">
<Button
variant="outline"
onClick={() => router.push('/kunden-projekte')}
className="w-full"
>
Direkt zu Kunden-Projekte (Test-Modus)
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

65
app/agb/page.tsx Normal file
View File

@@ -0,0 +1,65 @@
"use client";
import WebklarLogoHeader from '@/components/WebklarLogoHeader';
import WebklarFooter from '@/components/WebklarFooter';
export default function AgbPage() {
return (
<div className="min-h-screen flex flex-col bg-[#FEFAE0]">
<WebklarLogoHeader />
<div className="flex-1 flex items-center justify-center p-4">
<div className="max-w-3xl w-full bg-white/80 rounded-2xl shadow-2xl p-8 mx-auto">
<h1 className="text-2xl font-bold mb-6 text-center">Allgemeine Geschäftsbedingungen (AGB)</h1>
<div className="space-y-6 text-gray-800 text-sm">
<div>
<h2 className="text-lg font-semibold mb-2">Geltung</h2>
<p>Diese AGB gelten für alle Dienstleistungen, die Webklar gegenüber Geschäftskunden (B2B) erbringt insbesondere Webentwicklung, Hosting, Wartung und SEO.</p>
</div>
<div>
<h2 className="text-lg font-semibold mb-2">Vertrag</h2>
<p>Ein Vertrag entsteht durch schriftliche oder digitale Bestätigung (z. B. E-Mail). Absprachen sind verbindlich, sobald sie von beiden Seiten akzeptiert wurden.</p>
</div>
<div>
<h2 className="text-lg font-semibold mb-2">Leistungen</h2>
<p>Die Leistungen richten sich nach individueller Absprache. Änderungen oder Zusatzleistungen müssen gesondert vereinbart werden.</p>
</div>
<div>
<h2 className="text-lg font-semibold mb-2">Zahlung</h2>
<p>Zahlungen sind innerhalb von 14 Tagen nach Rechnungsstellung fällig, sofern nichts anderes vereinbart wurde. Alle Preise zzgl. gesetzlicher MwSt.</p>
</div>
<div>
<h2 className="text-lg font-semibold mb-2">Inhalte</h2>
<p>Für bereitgestellte Inhalte (Texte, Bilder etc.) ist der Kunde verantwortlich. Webklar übernimmt keine Haftung für etwaige Urheberrechtsverletzungen durch Kundenmaterial.</p>
</div>
<div>
<h2 className="text-lg font-semibold mb-2">Haftung</h2>
<p>Webklar haftet nur für grob fahrlässige oder vorsätzliche Pflichtverletzungen. Für technische Störungen wird keine Garantie übernommen, es sei denn, sie wurden durch Webklar verursacht.</p>
</div>
<div>
<h2 className="text-lg font-semibold mb-2">Rechte</h2>
<p>Die Rechte an entwickelten Designs und Quellcodes bleiben bis zur vollständigen Bezahlung bei Webklar. Danach erhält der Kunde ein einfaches Nutzungsrecht.</p>
</div>
<div>
<h2 className="text-lg font-semibold mb-2">Datenschutz</h2>
<p>Kundendaten werden nur im Rahmen der DSGVO verarbeitet. Eine separate Datenschutzerklärung wird zur Verfügung gestellt.</p>
</div>
<div>
<h2 className="text-lg font-semibold mb-2">Gerichtsstand</h2>
<p>Gerichtsstand ist, soweit gesetzlich zulässig, der Sitz des Unternehmens Webklar.</p>
</div>
</div>
</div>
</div>
<WebklarFooter />
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { prompt } = body;
const response = await fetch('https://codestral.mistral.ai/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ZMW8yHscLYI6iND4dh7cGuTmpO9Guotm'
},
body: JSON.stringify({
model: "mistral-small",
messages: [
{
role: "user",
content: prompt
}
],
temperature: 0.7,
max_tokens: 400
})
});
if (!response.ok) {
const errorText = await response.text();
console.error('Mistral API Error:', errorText);
return NextResponse.json(
{ error: `API request failed: ${response.status} - ${errorText}` },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Proxy error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,49 @@
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const requestUrl = new URL(request.url)
const code = requestUrl.searchParams.get('code')
const next = requestUrl.searchParams.get('next') ?? '/success'
if (code) {
const supabase = createRouteHandlerClient({ cookies })
try {
// Exchange the code for a session
const { data, error } = await supabase.auth.exchangeCodeForSession(code)
if (error) {
console.error('Auth callback error:', error)
// Redirect to auth page with error
return NextResponse.redirect(
`${requestUrl.origin}/auth?error=auth_callback_error&error_description=${encodeURIComponent(error.message)}`
)
}
if (data.session) {
// Successfully authenticated
console.log('User authenticated successfully:', data.user?.email)
// Check if this is an appointment booking verification
const userMetadata = data.user?.user_metadata
if (userMetadata?.appointment_booking) {
// Redirect to success page for appointment booking
return NextResponse.redirect(`${requestUrl.origin}/success`)
} else {
// Redirect to admin dashboard or default page
return NextResponse.redirect(`${requestUrl.origin}/kunden-projekte`)
}
}
} catch (error) {
console.error('Unexpected error in auth callback:', error)
return NextResponse.redirect(
`${requestUrl.origin}/auth?error=unexpected_error&error_description=${encodeURIComponent('Ein unerwarteter Fehler ist aufgetreten.')}`
)
}
}
// If no code, redirect to auth page
return NextResponse.redirect(`${requestUrl.origin}/auth`)
}

43
app/auth/page.tsx Normal file
View File

@@ -0,0 +1,43 @@
"use client";
import { useSearchParams } from 'next/navigation'
import EmailAuth from '@/components/MagicLinkAuth'
import { AlertCircle } from 'lucide-react'
import { colors } from '@/lib/colors'
export default function AuthPage() {
const searchParams = useSearchParams()
const error = searchParams.get('error')
const errorDescription = searchParams.get('error_description')
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Error Display */}
{error && (
<div className="mb-6 p-4 rounded-xl border-2 border-red-500 bg-red-50">
<div className="flex items-center space-x-3 mb-2">
<AlertCircle className="w-5 h-5 text-red-600" />
<h3 className="font-semibold text-red-700">
Authentifizierungsfehler
</h3>
</div>
<p className="text-sm text-red-600 mb-2">
{errorDescription || 'Ein Fehler ist bei der Authentifizierung aufgetreten.'}
</p>
<div className="text-xs text-red-500">
<p><strong>Fehler:</strong> {error}</p>
{error === 'otp_expired' && (
<p className="mt-2">
💡 <strong>Tipp:</strong> Der E-Mail-Link ist abgelaufen. Bitte fordern Sie einen neuen Link an.
</p>
)}
</div>
</div>
)}
<EmailAuth />
</div>
</div>
)
}

25
app/datenschutz/page.tsx Normal file
View File

@@ -0,0 +1,25 @@
"use client";
import WebklarLogoHeader from '@/components/WebklarLogoHeader';
import WebklarFooter from '@/components/WebklarFooter';
export default function DatenschutzPage() {
return (
<div className="min-h-screen flex flex-col bg-[#FEFAE0]">
<WebklarLogoHeader />
<div className="flex-1 flex items-center justify-center p-4">
<div className="max-w-xl w-full bg-white/80 rounded-2xl shadow-2xl p-8 mx-auto">
<h1 className="text-2xl font-bold mb-6 text-center">Datenschutzerklärung</h1>
<div className="space-y-4 text-gray-800 text-sm">
<p><b>1. Allgemeines</b><br/>Wir nehmen den Schutz Ihrer Daten ernst. Diese Website verarbeitet personenbezogene Daten nur, soweit dies technisch und organisatorisch notwendig ist.</p>
<p><b>2. Kontaktformular</b><br/>Wenn Sie uns per Formular Anfragen zukommen lassen, werden Ihre Angaben aus dem Formular inklusive der von Ihnen dort angegebenen Kontaktdaten zur Bearbeitung der Anfrage und für den Fall von Anschlussfragen bei uns gespeichert. Diese Daten geben wir nicht ohne Ihre Einwilligung weiter.</p>
<p><b>3. Hosting</b><br/>Unsere Website wird bei Netlify gehostet. Netlify verarbeitet personenbezogene Daten im Rahmen der Auftragsverarbeitung. Weitere Informationen finden Sie in der Datenschutzerklärung von Netlify.</p>
<p><b>4. Cookies & externe Fonts</b><br/>Wir verwenden keine Cookies und keine externen Schriftarten.</p>
<p><b>5. Trackingtools</b><br/>Optional: Wir nutzen datenschutzfreundliche Analyse-Tools wie Plausible. Es werden keine personenbezogenen Daten gespeichert.</p>
<p><b>6. Ihre Rechte</b><br/>Sie haben jederzeit das Recht auf Auskunft, Berichtigung oder Löschung Ihrer gespeicherten Daten.</p>
</div>
</div>
</div>
<WebklarFooter />
</div>
);
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

261
app/globals.css Normal file
View File

@@ -0,0 +1,261 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 84% 4.9%;
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 84% 4.9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* Enhanced Glassmorphism */
.glass-enhanced {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow:
0 8px 32px 0 rgba(31, 38, 135, 0.37),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
/* Smooth Hover Effects */
.hover-lift {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.hover-lift:hover {
transform: translateY(-4px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
}
/* Subtle Parallax */
.parallax-bg {
transform: translateZ(0);
will-change: transform;
}
/* Static Gradient Background */
.animated-gradient {
background: linear-gradient(-45deg, #22c55e, #16a34a, #15803d, #166534);
background-size: 400% 400%;
}
/* Pulse Glow */
.pulse-glow {
animation: pulseGlow 2s ease-in-out infinite alternate;
}
@keyframes pulseGlow {
from { box-shadow: 0 0 20px rgba(34, 197, 94, 0.3); }
to { box-shadow: 0 0 40px rgba(34, 197, 94, 0.6); }
}
/* Smooth Scroll Behavior */
html {
scroll-behavior: smooth;
}
/* Enhanced Button Hover */
.btn-enhanced {
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.btn-enhanced::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.btn-enhanced:hover::before {
left: 100%;
}
/* Text Glow Effect */
.text-glow {
text-shadow: 0 0 10px rgba(34, 197, 94, 0.5);
}
/* Card Hover Effect */
.card-hover {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.card-hover:hover {
transform: scale(1.02);
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
}
/* Spinning Numbers Animation */
.spinning-number {
position: relative;
font-size: 0.8em; /* Slightly larger than original but smaller than before */
width: 100%;
height: 100%;
min-height: 350px; /* Slightly larger than original */
display: flex;
align-items: center;
justify-content: center;
}
@media (min-width: 768px) {
.spinning-number {
font-size: 1em; /* Medium size on desktop */
min-height: 400px;
}
}
.spinning-number .wheel {
animation: spinning-number-spin var(--t) linear infinite var(--r1);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
@keyframes spinning-number-spin {
0% {
transform: translate(-50%, -50%) rotate(0deg);
}
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
.spinning-number .number {
position: absolute;
transform: translate(-50%, -50%) rotate(var(--a)) translateY(calc(var(--l) * -1)) scale(var(--s));
}
.spinning-number .number::before {
content: '1';
--z: 2;
transform: translate(-50%, -50%);
animation: spinning-number-changing calc(var(--t) * var(--z))
calc(-1 * var(--z) * var(--t) * var(--i) / var(--m) - 60s) linear infinite var(--r);
}
@keyframes spinning-number-changing {
0% {
content: '1';
}
to {
content: '0';
}
}
/* LogoLoop Container - overflow visible beim Hover für Text */
.logo-loop-container {
overflow: visible;
}
.logo-loop-inner {
overflow: hidden;
}
.logo-loop-container:hover .logo-loop-inner {
overflow: visible;
}
/* LogoLoop Fade mit Hintergrundfarbe - Gradient vom Hero-Bereich */
/* Links: helleres Grün (secondary), Rechts: dunkleres Grün (primary) */
.logo-loop-fade.logoloop--fade::before {
background: linear-gradient(
to right,
rgba(129, 144, 103, 0.9) 0%,
rgba(129, 144, 103, 0.7) 30%,
transparent 100%
) !important;
}
.logo-loop-fade.logoloop--fade::after {
background: linear-gradient(
to left,
rgba(10, 64, 12, 0.9) 0%,
rgba(10, 64, 12, 0.7) 30%,
transparent 100%
) !important;
}

61
app/impressum/page.tsx Normal file
View File

@@ -0,0 +1,61 @@
"use client";
import WebklarLogoHeader from '@/components/WebklarLogoHeader';
import WebklarFooter from '@/components/WebklarFooter';
export default function ImpressumPage() {
return (
<div className="min-h-screen flex flex-col bg-[#FEFAE0]">
<WebklarLogoHeader />
<div className="flex-1 flex items-center justify-center p-4">
<div className="max-w-2xl w-full bg-white/80 rounded-2xl shadow-2xl p-8 mx-auto">
<h1 className="text-2xl font-bold mb-6 text-center">Impressum</h1>
<div className="space-y-4 text-gray-800">
<div className="mb-6">
<h2 className="text-lg font-semibold mb-2">Angaben gemäß § 5 TMG</h2>
</div>
<div>
<span className="font-semibold">Firmenname:</span> Webklar IT-Dienstleistungen GbR
</div>
<div>
<span className="font-semibold">Adresse:</span> Am Schwimmbad 10, 67722 Winnweiler
</div>
<div className="mt-4">
<span className="font-semibold">Vertreten durch:</span>
<div className="ml-4 mt-1">
<div>Justin Klein</div>
<div>Kenso Grimm</div>
</div>
</div>
<div className="mt-4">
<span className="font-semibold">Kontakt:</span>
<div className="ml-4 mt-1">
<div>Telefon: 0170 4969375</div>
<div>E-Mail: support@webklar.com</div>
</div>
</div>
<div className="mt-4">
<span className="font-semibold">Umsatzsteuer:</span>
<div className="ml-4 mt-1">
<div>Umsatzsteuer-Identifikationsnummer gemäß § 27a UStG: nicht vorhanden</div>
<div className="text-sm text-gray-600 mt-1">(Kleinunternehmerregelung)</div>
</div>
</div>
<div className="mt-4">
<span className="font-semibold">Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV:</span>
<div className="ml-4 mt-1">
<div>Justin Klein & Kenso Grimm</div>
<div>Am Schwimmbad 10, 67722 Winnweiler</div>
</div>
</div>
</div>
</div>
</div>
<WebklarFooter />
</div>
);
}

35
app/kontakte/page.tsx Normal file
View File

@@ -0,0 +1,35 @@
"use client";
import WebklarLogoHeader from '@/components/WebklarLogoHeader';
import WebklarFooter from '@/components/WebklarFooter';
export default function KontaktePage() {
return (
<div className="min-h-screen flex flex-col bg-[#FEFAE0]">
<WebklarLogoHeader />
<div className="flex-1 flex items-center justify-center p-4">
<div className="max-w-3xl w-full mx-auto">
<h1 className="text-2xl font-bold mb-8 text-center">Kontakte</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Karte 1 */}
<div className="bg-white/80 rounded-2xl shadow-2xl p-6 flex flex-col items-center">
<div className="w-20 h-20 rounded-full bg-gray-200 mb-4 flex items-center justify-center text-3xl text-gray-400">👤</div>
<div className="font-bold text-lg mb-1">Kenso Grimm</div>
<div className="text-sm text-gray-600 mb-2">CEO & Webentwickler</div>
<div className="text-sm text-gray-800 mb-1">support@webklar.com</div>
<div className="text-sm text-gray-800">+49 176 23726355</div>
</div>
{/* Karte 2 */}
<div className="bg-white/80 rounded-2xl shadow-2xl p-6 flex flex-col items-center">
<div className="w-20 h-20 rounded-full bg-gray-200 mb-4 flex items-center justify-center text-3xl text-gray-400">👤</div>
<div className="font-bold text-lg mb-1">Justin Klein</div>
<div className="text-sm text-gray-600 mb-2">CEO & Kundenbetreuung</div>
<div className="text-sm text-gray-800 mb-1">support@webklar.com</div>
<div className="text-sm text-gray-800">+49 170 4969375</div>
</div>
</div>
</div>
</div>
<WebklarFooter />
</div>
);
}

385
app/kunden-liste/page.tsx Normal file
View File

@@ -0,0 +1,385 @@
'use client';
import React, { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabaseClient';
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Search,
RefreshCw,
Building,
Calendar,
Users,
Globe,
Euro,
Clock,
ArrowLeft,
Plus,
Filter,
Download
} from "lucide-react";
import { colors } from "@/lib/colors";
interface KundenProjekt {
id: string;
erstellt_am: string;
firma: string;
beschreibung: string;
zielgruppe: string;
website_vorhanden: boolean;
stilvorbilder: string;
was_gefaellt_gefaellt_nicht: string;
ziel_der_website: string;
seiten_geplant: string;
texte_bilder_vorhanden: boolean;
fokus_inhalte: string;
logo_farben_vorhanden: boolean;
design_wunsch: string;
beispiellinks: string;
features_gewuenscht: string;
drittanbieter: string;
selbst_pflegen: boolean;
laufende_betreuung: boolean;
deadline: string;
projekt_verantwortlich: string;
budget: string;
kommunikationsweg: string;
feedback_geschwindigkeit: string;
}
export default function KundenListePage() {
const [kunden, setKunden] = useState<KundenProjekt[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
fetchKunden();
}, []);
async function fetchKunden() {
try {
setLoading(true);
const { data, error } = await supabase
.from('kunden_projekte')
.select('*')
.order('erstellt_am', { ascending: false });
if (error) {
setError('Fehler beim Laden der Daten: ' + error.message);
} else {
setKunden(data || []);
}
} catch (e: any) {
setError('Unbekannter Fehler: ' + e.message);
} finally {
setLoading(false);
}
}
const filteredKunden = kunden.filter(kunde =>
kunde.firma?.toLowerCase().includes(searchTerm.toLowerCase()) ||
kunde.beschreibung?.toLowerCase().includes(searchTerm.toLowerCase()) ||
kunde.zielgruppe?.toLowerCase().includes(searchTerm.toLowerCase())
);
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('de-DE');
}
function formatBoolean(value: boolean) {
return value ? 'Ja' : 'Nein';
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center !bg-[#FEFAE0]" style={{ backgroundColor: colors.background }}>
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 mx-auto mb-4" style={{ borderColor: colors.primary }}></div>
<p style={{ color: colors.primary }}>Lade Kundendaten...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center !bg-[#FEFAE0]" style={{ backgroundColor: colors.background }}>
<div className="text-center max-w-md mx-auto p-6 rounded-2xl shadow-lg" style={{ backgroundColor: colors.white }}>
<p className="mb-4" style={{ color: colors.primary }}>{error}</p>
<Button
onClick={fetchKunden}
className="rounded-full font-medium"
style={{
backgroundColor: colors.primary,
color: colors.background
}}
>
Erneut versuchen
</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen !bg-[#FEFAE0]" style={{ backgroundColor: colors.background }}>
{/* Header Section */}
<div className="relative overflow-hidden">
{/* Background Pattern */}
<div className="absolute inset-0 opacity-10">
<div className="absolute inset-0" style={{
background: `linear-gradient(135deg, ${colors.primary}20, ${colors.secondary}20)`
}}></div>
</div>
<div className="relative z-10 px-4 sm:px-8 py-8 max-w-7xl mx-auto">
{/* Navigation */}
<div className="flex items-center justify-between mb-8">
<Button
variant="ghost"
className="rounded-full font-medium hover:scale-105 transition-all duration-300"
style={{ color: colors.primary }}
onClick={() => window.history.back()}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Zurück
</Button>
<div className="text-center">
<h1 className="text-3xl sm:text-4xl font-bold mb-2" style={{ color: colors.primary }}>
Kundenprojekte
</h1>
<p className="text-sm" style={{ color: colors.secondary }}>
Übersicht aller Kundenprojekte und deren Status
</p>
</div>
<Button
className="rounded-full font-medium px-4 py-2 shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105"
style={{
backgroundColor: colors.primary,
color: colors.background
}}
>
<Plus className="w-4 h-4 mr-2" />
Neues Projekt
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div className="p-6 rounded-2xl shadow-lg backdrop-blur-sm" style={{ backgroundColor: `${colors.white}CC` }}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium" style={{ color: colors.secondary }}>Gesamt Projekte</p>
<p className="text-2xl font-bold" style={{ color: colors.primary }}>{kunden.length}</p>
</div>
<div className="p-3 rounded-full" style={{ backgroundColor: colors.primary }}>
<Building className="w-6 h-6" style={{ color: colors.background }} />
</div>
</div>
</div>
<div className="p-6 rounded-2xl shadow-lg backdrop-blur-sm" style={{ backgroundColor: `${colors.white}CC` }}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium" style={{ color: colors.secondary }}>Aktive Projekte</p>
<p className="text-2xl font-bold" style={{ color: colors.primary }}>
{kunden.filter(k => k.laufende_betreuung).length}
</p>
</div>
<div className="p-3 rounded-full" style={{ backgroundColor: colors.secondary }}>
<Clock className="w-6 h-6" style={{ color: colors.background }} />
</div>
</div>
</div>
<div className="p-6 rounded-2xl shadow-lg backdrop-blur-sm" style={{ backgroundColor: `${colors.white}CC` }}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium" style={{ color: colors.secondary }}>Neue diese Woche</p>
<p className="text-2xl font-bold" style={{ color: colors.primary }}>
{kunden.filter(k => {
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
return new Date(k.erstellt_am) > weekAgo;
}).length}
</p>
</div>
<div className="p-3 rounded-full" style={{ backgroundColor: colors.tertiary }}>
<Calendar className="w-6 h-6" style={{ color: colors.primary }} />
</div>
</div>
</div>
<div className="p-6 rounded-2xl shadow-lg backdrop-blur-sm" style={{ backgroundColor: `${colors.white}CC` }}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium" style={{ color: colors.secondary }}>Durchschnitt Budget</p>
<p className="text-2xl font-bold" style={{ color: colors.primary }}>
{(() => {
const budgets = kunden
.filter(k => k.budget)
.map(k => parseInt(k.budget.replace(/[^\d]/g, '')))
.filter(b => !isNaN(b));
return budgets.length > 0
? `${Math.round(budgets.reduce((a, b) => a + b, 0) / budgets.length)}`
: '-';
})()}
</p>
</div>
<div className="p-3 rounded-full" style={{ backgroundColor: colors.primary }}>
<Euro className="w-6 h-6" style={{ color: colors.background }} />
</div>
</div>
</div>
</div>
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5" style={{ color: colors.secondary }} />
<input
type="text"
placeholder="Nach Firma, Beschreibung oder Zielgruppe suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-3 rounded-full border-2 focus:outline-none focus:ring-2 transition-all duration-300"
style={{
borderColor: colors.secondary,
backgroundColor: colors.white,
color: colors.primary
}}
/>
</div>
<div className="flex gap-2">
<Button
variant="outline"
className="rounded-full"
style={{
borderColor: colors.secondary,
color: colors.secondary,
backgroundColor: colors.white
}}
>
<Filter className="w-4 h-4 mr-2" />
Filter
</Button>
<Button
onClick={fetchKunden}
className="rounded-full font-medium"
style={{
backgroundColor: colors.secondary,
color: colors.background
}}
>
<RefreshCw className="w-4 h-4 mr-2" />
Aktualisieren
</Button>
<Button
variant="outline"
className="rounded-full"
style={{
borderColor: colors.secondary,
color: colors.secondary,
backgroundColor: colors.white
}}
>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
</div>
</div>
{/* Results */}
<div className="rounded-2xl shadow-lg overflow-hidden" style={{ backgroundColor: colors.white }}>
{filteredKunden.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center" style={{ backgroundColor: colors.secondary }}>
<Building className="w-8 h-8" style={{ color: colors.background }} />
</div>
<p className="text-lg font-medium mb-2" style={{ color: colors.primary }}>
{searchTerm ? 'Keine Kunden gefunden' : 'Noch keine Kundenprojekte vorhanden'}
</p>
<p className="text-sm" style={{ color: colors.secondary }}>
{searchTerm ? 'Versuchen Sie einen anderen Suchbegriff.' : 'Erstellen Sie Ihr erstes Kundenprojekt.'}
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr style={{ backgroundColor: colors.background }}>
<th className="text-left p-4 font-semibold" style={{ color: colors.primary }}>Firma</th>
<th className="text-left p-4 font-semibold" style={{ color: colors.primary }}>Erstellt am</th>
<th className="text-left p-4 font-semibold" style={{ color: colors.primary }}>Zielgruppe</th>
<th className="text-left p-4 font-semibold" style={{ color: colors.primary }}>Website vorhanden</th>
<th className="text-left p-4 font-semibold" style={{ color: colors.primary }}>Deadline</th>
<th className="text-left p-4 font-semibold" style={{ color: colors.primary }}>Budget</th>
<th className="text-left p-4 font-semibold" style={{ color: colors.primary }}>Status</th>
</tr>
</thead>
<tbody>
{filteredKunden.map((kunde, index) => (
<tr
key={kunde.id}
className="border-b transition-all duration-200 hover:scale-[1.01] cursor-pointer"
style={{
borderColor: colors.background,
backgroundColor: index % 2 === 0 ? colors.white : colors.background
}}
>
<td className="p-4">
<div className="font-medium" style={{ color: colors.primary }}>{kunde.firma}</div>
<div className="text-sm truncate max-w-xs" style={{ color: colors.secondary }}>
{kunde.beschreibung}
</div>
</td>
<td className="p-4 text-sm" style={{ color: colors.secondary }}>
{formatDate(kunde.erstellt_am)}
</td>
<td className="p-4 text-sm" style={{ color: colors.secondary }}>
{kunde.zielgruppe}
</td>
<td className="p-4 text-sm" style={{ color: colors.secondary }}>
{formatBoolean(kunde.website_vorhanden)}
</td>
<td className="p-4 text-sm" style={{ color: colors.secondary }}>
{kunde.deadline ? formatDate(kunde.deadline) : '-'}
</td>
<td className="p-4 text-sm" style={{ color: colors.secondary }}>
{kunde.budget || '-'}
</td>
<td className="p-4">
<Badge
className="rounded-full font-medium"
style={{
backgroundColor: kunde.laufende_betreuung ? colors.secondary : colors.tertiary,
color: kunde.laufende_betreuung ? colors.background : colors.primary
}}
>
{kunde.laufende_betreuung ? 'Aktiv' : 'In Bearbeitung'}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Footer */}
<div className="mt-6 text-center">
<p className="text-sm" style={{ color: colors.secondary }}>
{filteredKunden.length} von {kunden.length} Kundenprojekten
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,715 @@
"use client";
import { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabase';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { useRouter } from 'next/navigation';
import {
Building,
User,
Phone,
Mail,
Calendar,
Target,
Globe,
Palette,
Settings,
FileText,
CheckCircle,
AlertCircle,
Play,
ArrowLeft,
Save,
Eye,
Edit,
Users,
TrendingUp,
Zap,
Star
} from 'lucide-react';
interface Customer {
id: string;
berater?: string;
firma?: string;
ansprechpartn?: string;
telefon?: string;
email?: string;
beschreibung?: string;
zielgruppe?: string;
website_vorha?: boolean;
was_gefaellt_c?: string;
ziel_der_websi?: string;
seiten_geplant?: string;
texte_bilder_v?: boolean;
fokus_inhalte?: string;
logo_farben_v?: boolean;
stilvorbilder?: string;
design_wunsch?: string;
features_gewu?: string;
drittanbieter?: string;
selbst_pflegen?: boolean;
laufende_betre?: boolean;
deadline?: string;
projekt_verant?: string;
budget?: string;
kommunikation?: string;
feedback_gesc?: string;
beispiellinks?: string;
benoetigte_fur?: string;
webseiten_ziel?: string;
geplante_seite?: string;
termin_datum?: string;
erstellt_am: string;
appointment_status?: 'pending' | 'running' | 'completed';
started_by?: string;
started_at?: string;
}
interface CustomerDetailPageProps {
params: {
id: string;
};
}
export default function CustomerDetailPage({ params }: CustomerDetailPageProps) {
const [customer, setCustomer] = useState<Customer | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<any>({});
const [activeSection, setActiveSection] = useState('overview');
const router = useRouter();
useEffect(() => {
fetchCustomer();
}, [params.id]);
const fetchCustomer = async () => {
try {
console.log('🔍 Fetching customer details:', params.id);
const { data, error } = await supabase
.from('kunden_projekte')
.select('*')
.eq('id', params.id)
.single();
if (error) {
console.error('❌ Error fetching customer:', error);
return;
}
console.log('✅ Customer details fetched:', data);
setCustomer(data);
setFormData(data);
} catch (error) {
console.error('❌ Error:', error);
} finally {
setLoading(false);
}
};
const updateAppointmentStatus = async (status: 'pending' | 'running' | 'completed') => {
try {
console.log(`🔄 Updating appointment status to ${status}`);
const updateData: any = { appointment_status: status };
if (status === 'running') {
updateData.started_by = 'admin@example.com';
updateData.started_at = new Date().toISOString();
} else if (status === 'completed') {
updateData.completed_at = new Date().toISOString();
}
const { error } = await supabase
.from('kunden_projekte')
.update(updateData)
.eq('id', params.id);
if (error) {
console.error('❌ Error updating status:', error);
return;
}
console.log('✅ Status updated successfully');
fetchCustomer();
} catch (error) {
console.error('❌ Error:', error);
}
};
const saveCustomerData = async () => {
setSaving(true);
try {
console.log('💾 Saving customer data:', formData);
const { error } = await supabase
.from('kunden_projekte')
.update(formData)
.eq('id', params.id);
if (error) {
console.error('❌ Error updating customer:', error);
return;
}
console.log('✅ Customer updated successfully');
fetchCustomer();
} catch (error) {
console.error('❌ Error:', error);
} finally {
setSaving(false);
}
};
const handleInputChange = (field: string, value: any) => {
setFormData((prev: any) => ({
...prev,
[field]: value
}));
};
const getStatusBadge = (status: string, startedBy?: string) => {
switch (status) {
case 'pending':
return (
<Badge variant="secondary" className="flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Termin wartet
</Badge>
);
case 'running':
return (
<div className="flex items-center gap-2">
<Badge variant="default" className="bg-green-500 flex items-center gap-1">
<Play className="w-3 h-3" />
Termin läuft
</Badge>
{startedBy && (
<span className="text-sm text-gray-600">({startedBy})</span>
)}
</div>
);
case 'completed':
return (
<Badge variant="outline" className="flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Termin abgeschlossen
</Badge>
);
default:
return (
<Badge variant="secondary" className="flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Termin wartet
</Badge>
);
}
};
const getCompletionStatus = () => {
if (!customer) return { percentage: 0, filledFields: 0, totalFields: 0 };
const fields = [
customer.firma, customer.ansprechpartn, customer.telefon, customer.email,
customer.beschreibung, customer.zielgruppe, customer.ziel_der_websi,
customer.seiten_geplant, customer.fokus_inhalte, customer.design_wunsch,
customer.features_gewu, customer.budget, customer.deadline
];
const filledFields = fields.filter(field => field && field.trim() !== '').length;
const totalFields = fields.length;
const percentage = Math.round((filledFields / totalFields) * 100);
return { filledFields, totalFields, percentage };
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
<div className="max-w-7xl mx-auto">
<div className="text-center">
<h1 className="text-3xl font-bold mb-4 text-gray-800">Kunden-Projekt</h1>
<div className="animate-spin rounded-full h-12 w-12 border-b-4 border-blue-500 border-t-transparent mx-auto"></div>
<p className="mt-4 text-gray-600">Lade Projektdaten...</p>
</div>
</div>
</div>
);
}
if (!customer) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
<div className="max-w-7xl mx-auto">
<div className="text-center">
<h1 className="text-3xl font-bold mb-4 text-gray-800">Projekt nicht gefunden</h1>
<p className="text-gray-600 mb-6">Das angeforderte Projekt konnte nicht gefunden werden.</p>
<Button
onClick={() => router.push('/kunden-projekte')}
className="bg-blue-500 hover:bg-blue-600"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Zurück zur Übersicht
</Button>
</div>
</div>
</div>
);
}
const completion = getCompletionStatus();
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="max-w-7xl mx-auto p-6">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Button
onClick={() => router.push('/kunden-projekte')}
variant="outline"
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Zurück
</Button>
<div className="flex items-center gap-3">
<div className="p-3 bg-blue-500 rounded-xl">
<Building className="w-8 h-8 text-white" />
</div>
<div>
<h1 className="text-3xl font-bold text-gray-800">
{customer.ansprechpartn || customer.firma || 'Unbekannter Kunde'}
</h1>
<p className="text-gray-600">
{customer.firma && customer.ansprechpartn ? `${customer.firma} - ${customer.ansprechpartn}` : customer.firma || customer.ansprechpartn}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
{getStatusBadge(customer.appointment_status || 'pending', customer.started_by)}
{customer.appointment_status === 'pending' && (
<Button
onClick={() => updateAppointmentStatus('running')}
className="bg-green-500 hover:bg-green-600 flex items-center gap-2"
>
<Play className="w-4 h-4" />
Termin starten
</Button>
)}
{customer.appointment_status === 'running' && (
<Button
onClick={() => updateAppointmentStatus('completed')}
variant="outline"
className="flex items-center gap-2"
>
<CheckCircle className="w-4 h-4" />
Termin beenden
</Button>
)}
</div>
</div>
{/* Progress Bar */}
<Card className="bg-white/80 backdrop-blur-sm mb-6">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-800">Befragung Fortschritt</h3>
<span className="text-sm font-medium text-gray-600">{completion.percentage}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className={`h-3 rounded-full transition-all duration-300 ${
completion.percentage >= 80 ? 'bg-green-500' :
completion.percentage >= 50 ? 'bg-yellow-500' : 'bg-blue-500'
}`}
style={{ width: `${completion.percentage}%` }}
></div>
</div>
<p className="text-sm text-gray-500 mt-2">
{completion.filledFields} von {completion.totalFields} Feldern ausgefüllt
</p>
</CardContent>
</Card>
</div>
{/* Navigation Tabs */}
<div className="flex gap-2 mb-6 overflow-x-auto">
{[
{ id: 'overview', label: 'Übersicht', icon: Eye },
{ id: 'contact', label: 'Kontaktdaten', icon: User },
{ id: 'project', label: 'Projektinfo', icon: FileText },
{ id: 'design', label: 'Design & Features', icon: Palette },
{ id: 'technical', label: 'Technische Details', icon: Settings },
{ id: 'timeline', label: 'Zeitplan & Budget', icon: Calendar }
].map((tab) => (
<Button
key={tab.id}
variant={activeSection === tab.id ? 'default' : 'outline'}
onClick={() => setActiveSection(tab.id)}
className="flex items-center gap-2 whitespace-nowrap"
>
<tab.icon className="w-4 h-4" />
{tab.label}
</Button>
))}
</div>
{/* Content Sections */}
<div className="grid gap-6">
{/* Overview Section */}
{activeSection === 'overview' && (
<div className="grid gap-6">
<Card className="bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building className="w-5 h-5" />
Projektübersicht
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-600">Firma</Label>
<p className="text-gray-800">{customer.firma || 'Nicht angegeben'}</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-600">Ansprechpartner</Label>
<p className="text-gray-800">{customer.ansprechpartn || 'Nicht angegeben'}</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-600">E-Mail</Label>
<p className="text-gray-800">{customer.email || 'Nicht angegeben'}</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-600">Telefon</Label>
<p className="text-gray-800">{customer.telefon || 'Nicht angegeben'}</p>
</div>
</div>
{customer.beschreibung && (
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-600">Projektbeschreibung</Label>
<p className="text-gray-800">{customer.beschreibung}</p>
</div>
)}
{customer.termin_datum && (
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-600">Termin</Label>
<p className="text-gray-800">{new Date(customer.termin_datum).toLocaleString('de-DE')}</p>
</div>
)}
</CardContent>
</Card>
</div>
)}
{/* Contact Section */}
{activeSection === 'contact' && (
<Card className="bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="w-5 h-5" />
Kontaktdaten
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firma">Firma *</Label>
<Input
id="firma"
value={formData.firma || ''}
onChange={(e) => handleInputChange('firma', e.target.value)}
placeholder="Firmenname"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ansprechpartn">Ansprechpartner *</Label>
<Input
id="ansprechpartn"
value={formData.ansprechpartn || ''}
onChange={(e) => handleInputChange('ansprechpartn', e.target.value)}
placeholder="Name des Ansprechpartners"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-Mail *</Label>
<Input
id="email"
type="email"
value={formData.email || ''}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="email@beispiel.de"
/>
</div>
<div className="space-y-2">
<Label htmlFor="telefon">Telefon</Label>
<Input
id="telefon"
type="tel"
value={formData.telefon || ''}
onChange={(e) => handleInputChange('telefon', e.target.value)}
placeholder="+49 123 456789"
/>
</div>
</div>
</CardContent>
</Card>
)}
{/* Project Section */}
{activeSection === 'project' && (
<Card className="bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
Projektinformationen
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="beschreibung">Projektbeschreibung *</Label>
<Textarea
id="beschreibung"
value={formData.beschreibung || ''}
onChange={(e) => handleInputChange('beschreibung', e.target.value)}
placeholder="Beschreiben Sie Ihr Projekt, Ihre Ziele und Anforderungen..."
rows={4}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="zielgruppe">Zielgruppe</Label>
<Input
id="zielgruppe"
value={formData.zielgruppe || ''}
onChange={(e) => handleInputChange('zielgruppe', e.target.value)}
placeholder="Ihre Zielgruppe"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ziel_der_websi">Ziel der Website</Label>
<Input
id="ziel_der_websi"
value={formData.ziel_der_websi || ''}
onChange={(e) => handleInputChange('ziel_der_websi', e.target.value)}
placeholder="Hauptziel der Website"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="seiten_geplant">Geplante Seiten</Label>
<Input
id="seiten_geplant"
value={formData.seiten_geplant || ''}
onChange={(e) => handleInputChange('seiten_geplant', e.target.value)}
placeholder="z.B. Home, Über uns, Kontakt, Blog..."
/>
</div>
</CardContent>
</Card>
)}
{/* Design Section */}
{activeSection === 'design' && (
<Card className="bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="w-5 h-5" />
Design & Features
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="design_wunsch">Design-Wünsche</Label>
<Textarea
id="design_wunsch"
value={formData.design_wunsch || ''}
onChange={(e) => handleInputChange('design_wunsch', e.target.value)}
placeholder="Beschreiben Sie Ihre Design-Vorstellungen..."
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="stilvorbilder">Stilvorbilder</Label>
<Input
id="stilvorbilder"
value={formData.stilvorbilder || ''}
onChange={(e) => handleInputChange('stilvorbilder', e.target.value)}
placeholder="Websites die Ihnen gefallen"
/>
</div>
<div className="space-y-2">
<Label htmlFor="features_gewu">Gewünschte Features</Label>
<Textarea
id="features_gewu"
value={formData.features_gewu || ''}
onChange={(e) => handleInputChange('features_gewu', e.target.value)}
placeholder="Welche Funktionen soll die Website haben?"
rows={3}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center space-x-2">
<Checkbox
id="logo_farben_v"
checked={formData.logo_farben_v || false}
onCheckedChange={(checked) => handleInputChange('logo_farben_v', checked)}
/>
<Label htmlFor="logo_farben_v">Logo & Farben vorhanden</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="texte_bilder_v"
checked={formData.texte_bilder_v || false}
onCheckedChange={(checked) => handleInputChange('texte_bilder_v', checked)}
/>
<Label htmlFor="texte_bilder_v">Texte & Bilder vorhanden</Label>
</div>
</div>
</CardContent>
</Card>
)}
{/* Technical Section */}
{activeSection === 'technical' && (
<Card className="bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="w-5 h-5" />
Technische Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="drittanbieter">Drittanbieter-Integrationen</Label>
<Input
id="drittanbieter"
value={formData.drittanbieter || ''}
onChange={(e) => handleInputChange('drittanbieter', e.target.value)}
placeholder="z.B. Google Analytics, Newsletter, Zahlungssysteme..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="benoetigte_fur">Benötigte Funktionen</Label>
<Textarea
id="benoetigte_fur"
value={formData.benoetigte_fur || ''}
onChange={(e) => handleInputChange('benoetigte_fur', e.target.value)}
placeholder="Spezielle technische Anforderungen..."
rows={3}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center space-x-2">
<Checkbox
id="selbst_pflegen"
checked={formData.selbst_pflegen || false}
onCheckedChange={(checked) => handleInputChange('selbst_pflegen', checked)}
/>
<Label htmlFor="selbst_pflegen">Selbst pflegen möchten</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="laufende_betre"
checked={formData.laufende_betre || false}
onCheckedChange={(checked) => handleInputChange('laufende_betre', checked)}
/>
<Label htmlFor="laufende_betre">Laufende Betreuung gewünscht</Label>
</div>
</div>
</CardContent>
</Card>
)}
{/* Timeline Section */}
{activeSection === 'timeline' && (
<Card className="bg-white/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="w-5 h-5" />
Zeitplan & Budget
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="deadline">Deadline</Label>
<Input
id="deadline"
type="date"
value={formData.deadline || ''}
onChange={(e) => handleInputChange('deadline', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="budget">Budget</Label>
<Input
id="budget"
value={formData.budget || ''}
onChange={(e) => handleInputChange('budget', e.target.value)}
placeholder="Budget-Rahmen"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="projekt_verant">Projektverantwortlicher</Label>
<Input
id="projekt_verant"
value={formData.projekt_verant || ''}
onChange={(e) => handleInputChange('projekt_verant', e.target.value)}
placeholder="Name des Projektverantwortlichen"
/>
</div>
<div className="space-y-2">
<Label htmlFor="kommunikation">Kommunikationsweg</Label>
<Input
id="kommunikation"
value={formData.kommunikation || ''}
onChange={(e) => handleInputChange('kommunikation', e.target.value)}
placeholder="Bevorzugter Kommunikationsweg"
/>
</div>
</CardContent>
</Card>
)}
</div>
{/* Save Button */}
<div className="mt-8 flex justify-end">
<Button
onClick={saveCustomerData}
disabled={saving}
className="bg-blue-500 hover:bg-blue-600 flex items-center gap-2"
>
<Save className="w-4 h-4" />
{saving ? 'Speichern...' : 'Daten speichern'}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,477 @@
"use client";
import { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabase';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useRouter } from 'next/navigation';
import {
Calendar,
User,
Building,
Phone,
Mail,
FileText,
Clock,
Play,
CheckCircle,
AlertCircle,
Users,
Target,
Globe,
Palette,
Settings,
TrendingUp
} from 'lucide-react';
interface Customer {
id: string;
berater?: string;
firma?: string;
ansprechpartn?: string;
telefon?: string;
email?: string;
beschreibung?: string;
zielgruppe?: string;
website_vorha?: boolean;
was_gefaellt_c?: string;
ziel_der_websi?: string;
seiten_geplant?: string;
texte_bilder_v?: boolean;
fokus_inhalte?: string;
logo_farben_v?: boolean;
stilvorbilder?: string;
design_wunsch?: string;
features_gewu?: string;
drittanbieter?: string;
selbst_pflegen?: boolean;
laufende_betre?: boolean;
deadline?: string;
projekt_verant?: string;
budget?: string;
kommunikation?: string;
feedback_gesc?: string;
beispiellinks?: string;
benoetigte_fur?: string;
webseiten_ziel?: string;
geplante_seite?: string;
termin_datum?: string;
erstellt_am: string;
appointment_status?: 'pending' | 'running' | 'completed';
started_by?: string;
started_at?: string;
}
export default function KundenProjektePage() {
const [customers, setCustomers] = useState<Customer[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const router = useRouter();
useEffect(() => {
fetchCustomers();
}, []);
const fetchCustomers = async () => {
try {
console.log('🔍 Fetching customers with appointments...');
const { data, error } = await supabase
.from('kunden_projekte')
.select('*')
.not('termin_datum', 'is', null)
.order('id', { ascending: false });
if (error) {
console.error('❌ Error fetching customers:', error);
return;
}
console.log('✅ Customers fetched:', data?.length || 0);
setCustomers(data || []);
} catch (error) {
console.error('❌ Error:', error);
} finally {
setLoading(false);
}
};
const updateAppointmentStatus = async (customerId: string, status: 'pending' | 'running' | 'completed') => {
try {
console.log(`🔄 Updating appointment status for ${customerId} to ${status}`);
const updateData: any = { appointment_status: status };
if (status === 'running') {
updateData.started_by = 'admin@example.com'; // TODO: Get from auth
updateData.started_at = new Date().toISOString();
} else if (status === 'completed') {
updateData.completed_at = new Date().toISOString();
}
const { error } = await supabase
.from('kunden_projekte')
.update(updateData)
.eq('id', customerId);
if (error) {
console.error('❌ Error updating status:', error);
return;
}
console.log('✅ Status updated successfully');
fetchCustomers(); // Refresh data
} catch (error) {
console.error('❌ Error:', error);
}
};
const getStatusBadge = (status: string, startedBy?: string) => {
switch (status) {
case 'pending':
return (
<Badge variant="secondary" className="flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Termin wartet
</Badge>
);
case 'running':
return (
<div className="flex items-center gap-2">
<Badge variant="default" className="bg-green-500 flex items-center gap-1">
<Play className="w-3 h-3" />
Termin läuft
</Badge>
{startedBy && (
<span className="text-sm text-gray-600">({startedBy})</span>
)}
</div>
);
case 'completed':
return (
<Badge variant="outline" className="flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Termin abgeschlossen
</Badge>
);
default:
return (
<Badge variant="secondary" className="flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Termin wartet
</Badge>
);
}
};
const getStatusActions = (customer: Customer) => {
switch (customer.appointment_status) {
case 'pending':
return (
<Button
onClick={() => updateAppointmentStatus(customer.id, 'running')}
className="bg-green-500 hover:bg-green-600 flex items-center gap-2"
>
<Play className="w-4 h-4" />
Termin starten
</Button>
);
case 'running':
return (
<div className="flex gap-2">
<Button
onClick={() => router.push(`/kunden-projekte/${customer.id}`)}
className="bg-blue-500 hover:bg-blue-600 flex items-center gap-2"
>
<FileText className="w-4 h-4" />
Befragung
</Button>
<Button
onClick={() => updateAppointmentStatus(customer.id, 'completed')}
variant="outline"
className="flex items-center gap-2"
>
<CheckCircle className="w-4 h-4" />
Beenden
</Button>
</div>
);
case 'completed':
return (
<div className="flex gap-2">
<Button
onClick={() => router.push(`/kunden-projekte/${customer.id}`)}
variant="outline"
className="flex items-center gap-2"
>
<FileText className="w-4 h-4" />
Projekt anzeigen
</Button>
<Button
onClick={() => updateAppointmentStatus(customer.id, 'pending')}
variant="outline"
size="sm"
>
Zurücksetzen
</Button>
</div>
);
default:
return (
<Button
onClick={() => updateAppointmentStatus(customer.id, 'running')}
className="bg-green-500 hover:bg-green-600 flex items-center gap-2"
>
<Play className="w-4 h-4" />
Termin starten
</Button>
);
}
};
const getCompletionStatus = (customer: Customer) => {
const fields = [
customer.firma, customer.ansprechpartn, customer.telefon, customer.email,
customer.beschreibung, customer.zielgruppe, customer.ziel_der_websi,
customer.seiten_geplant, customer.fokus_inhalte, customer.design_wunsch,
customer.features_gewu, customer.budget, customer.deadline
];
const filledFields = fields.filter(field => field && field.trim() !== '').length;
const totalFields = fields.length;
const percentage = Math.round((filledFields / totalFields) * 100);
return { filledFields, totalFields, percentage };
};
const filteredCustomers = customers.filter(customer => {
const matchesSearch = (customer.ansprechpartn || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(customer.firma || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(customer.email || '').toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || customer.appointment_status === statusFilter;
return matchesSearch && matchesStatus;
});
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
<div className="max-w-7xl mx-auto">
<div className="text-center">
<h1 className="text-3xl font-bold mb-4 text-gray-800">Kunden-Projekte</h1>
<div className="animate-spin rounded-full h-12 w-12 border-b-4 border-blue-500 border-t-transparent mx-auto"></div>
<p className="mt-4 text-gray-600">Lade Projekte...</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="max-w-7xl mx-auto p-6">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<div className="p-3 bg-blue-500 rounded-xl">
<Users className="w-8 h-8 text-white" />
</div>
<div>
<h1 className="text-4xl font-bold text-gray-800">Kunden-Projekte</h1>
<p className="text-lg text-gray-600">
Professionelle Projektverwaltung und Kundenbefragung
</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="bg-white/80 backdrop-blur-sm">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Calendar className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="text-sm text-gray-600">Gesamt Projekte</p>
<p className="text-2xl font-bold text-gray-800">{customers.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-white/80 backdrop-blur-sm">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-yellow-100 rounded-lg">
<AlertCircle className="w-5 h-5 text-yellow-600" />
</div>
<div>
<p className="text-sm text-gray-600">Wartend</p>
<p className="text-2xl font-bold text-gray-800">
{customers.filter(c => c.appointment_status === 'pending').length}
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-white/80 backdrop-blur-sm">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 rounded-lg">
<Play className="w-5 h-5 text-green-600" />
</div>
<div>
<p className="text-sm text-gray-600">Aktiv</p>
<p className="text-2xl font-bold text-gray-800">
{customers.filter(c => c.appointment_status === 'running').length}
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-white/80 backdrop-blur-sm">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-100 rounded-lg">
<CheckCircle className="w-5 h-5 text-gray-600" />
</div>
<div>
<p className="text-sm text-gray-600">Abgeschlossen</p>
<p className="text-2xl font-bold text-gray-800">
{customers.filter(c => c.appointment_status === 'completed').length}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="flex-1">
<div className="relative">
<Input
placeholder="Nach Kunde, Firma oder E-Mail suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 bg-white/80 backdrop-blur-sm border-0 shadow-sm"
/>
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
</div>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-48 bg-white/80 backdrop-blur-sm border-0 shadow-sm">
<SelectValue placeholder="Status filtern" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle Status</SelectItem>
<SelectItem value="pending">Wartend</SelectItem>
<SelectItem value="running">Aktiv</SelectItem>
<SelectItem value="completed">Abgeschlossen</SelectItem>
</SelectContent>
</Select>
</div>
{/* Projects Grid */}
{filteredCustomers.length === 0 ? (
<Card className="bg-white/80 backdrop-blur-sm">
<CardContent className="p-12 text-center">
<div className="p-4 bg-blue-100 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<Users className="w-8 h-8 text-blue-600" />
</div>
<h3 className="text-xl font-semibold text-gray-800 mb-2">
{customers.length === 0
? "Keine Projekte gefunden"
: "Keine Projekte entsprechen den Filterkriterien"}
</h3>
<p className="text-gray-600">
{customers.length === 0
? "Erstellen Sie Ihr erstes Kundenprojekt über die Terminbuchung."
: "Versuchen Sie andere Suchkriterien oder Filter."}
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-6">
{filteredCustomers.map((customer) => {
const completion = getCompletionStatus(customer);
return (
<Card key={customer.id} className="bg-white/80 backdrop-blur-sm hover:shadow-lg transition-all duration-300 border-0 shadow-sm">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Building className="w-5 h-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-xl text-gray-800">
{customer.ansprechpartn || customer.firma || 'Unbekannter Kunde'}
</CardTitle>
<p className="text-sm text-gray-600">
{customer.firma && customer.ansprechpartn ? `${customer.firma} - ${customer.ansprechpartn}` : customer.firma || customer.ansprechpartn}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
<div className="flex items-center gap-2 text-sm text-gray-600">
<Mail className="w-4 h-4" />
<span>{customer.email || 'E-Mail nicht angegeben'}</span>
</div>
{customer.telefon && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Phone className="w-4 h-4" />
<span>{customer.telefon}</span>
</div>
)}
{customer.termin_datum && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Calendar className="w-4 h-4" />
<span>{new Date(customer.termin_datum).toLocaleString('de-DE')}</span>
</div>
)}
</div>
{/* Completion Progress */}
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Befragung Fortschritt</span>
<span className="text-sm text-gray-600">{completion.percentage}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${
completion.percentage >= 80 ? 'bg-green-500' :
completion.percentage >= 50 ? 'bg-yellow-500' : 'bg-blue-500'
}`}
style={{ width: `${completion.percentage}%` }}
></div>
</div>
<p className="text-xs text-gray-500 mt-1">
{completion.filledFields} von {completion.totalFields} Feldern ausgefüllt
</p>
</div>
</div>
<div className="flex flex-col items-end gap-3">
{getStatusBadge(customer.appointment_status || 'pending', customer.started_by)}
{getStatusActions(customer)}
</div>
</div>
</CardHeader>
</Card>
);
})}
</div>
)}
</div>
</div>
);
}

28
app/layout.tsx Normal file
View File

@@ -0,0 +1,28 @@
import './globals.css';
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Webklar Klarheit im Webdesign',
description: 'Wir gestalten moderne, schnelle Websites für Ihr Business.',
openGraph: {
title: 'Webklar Klarheit im Webdesign',
description: 'Wir gestalten moderne, schnelle Websites für Ihr Business.',
url: 'https://webklar.com',
type: 'website',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de">
<body className={inter.className}>{children}</body>
</html>
);
}

874
app/page.tsx Normal file
View File

@@ -0,0 +1,874 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Calendar, Cookie } from "lucide-react";
import { colors } from "@/lib/colors";
import ProtectedAppointmentBooking from "@/components/ProtectedAppointmentBooking";
import AppointmentStatus from "@/components/AppointmentStatus";
import PriceCalculator from "@/components/PriceCalculator";
import SpinningNumbers from "@/components/SpinningNumbers";
import GlassSurface from "@/components/GlassSurface";
import LogoLoop from "@/components/LogoLoop";
import PillNav from "@/components/PillNav";
import TimelineDemo from "@/components/ui/timeline-demo";
import { HoverEffect } from "@/components/ui/card-hover-effect";
import HeroScrollDemo from "@/components/ui/container-scroll-animation-demo";
import Link from 'next/link';
// Scroll animation hook
const useScrollAnimation = () => {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handleScroll = () => setScrollY(window.scrollY);
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return scrollY;
};
// Intersection Observer hook for fade-in animations
const useInView = (threshold = 0.1) => {
const [isInView, setIsInView] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
}
},
{ threshold }
);
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, [threshold]);
return [ref, isInView] as const;
};
// Cookie Button Component
const CookieButton = () => {
const [showBanner, setShowBanner] = useState(false);
const handleAccept = () => {
setShowBanner(false);
// Add cookie acceptance logic here
};
return (
<>
{/* Cookie Button */}
<button
onClick={() => setShowBanner(true)}
className="fixed bottom-6 left-6 z-50 w-14 h-14 rounded-full flex items-center justify-center shadow-lg transition-all duration-300 hover:scale-110"
style={{ backgroundColor: colors.secondary }}
>
<Cookie className="w-6 h-6 text-white" />
</button>
{/* Cookie Banner */}
{showBanner && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div
className="max-w-md w-full p-6 rounded-3xl shadow-2xl"
style={{ backgroundColor: colors.background }}
>
<div className="flex items-center space-x-3 mb-4">
<Cookie className="w-6 h-6" style={{ color: colors.primary }} />
<h3 className="text-lg font-semibold" style={{ color: colors.primary }}>
Cookie-Einstellungen
</h3>
</div>
<p className="mb-6 text-sm" style={{ color: colors.secondary }}>
Wir verwenden Cookies, um Ihre Erfahrung zu verbessern. Durch die Nutzung unserer Website stimmen Sie unserer Datenschutzrichtlinie zu.
</p>
<div className="flex space-x-3">
<Button
onClick={handleAccept}
className="flex-1 rounded-full font-medium"
style={{
backgroundColor: colors.primary,
color: colors.background
}}
>
Akzeptieren
</Button>
<Button
onClick={() => setShowBanner(false)}
variant="outline"
className="flex-1 rounded-full"
style={{
borderColor: colors.secondary,
color: colors.secondary
}}
>
Ablehnen
</Button>
</div>
</div>
</div>
)}
</>
);
};
export default function AboutServicePage() {
const scrollY = useScrollAnimation();
const [heroRef, heroInView] = useInView();
const [servicesRef, servicesInView] = useInView();
const [processRef, processInView] = useInView();
const [pricingRef, pricingInView] = useInView();
const [aboutRef, aboutInView] = useInView();
const [contactRef, contactInView] = useInView();
const navWrapperRef = useRef<HTMLDivElement>(null);
const navInnerRef = useRef<HTMLDivElement>(null);
const [navOffset, setNavOffset] = useState(0);
const navOffsetRef = useRef(0);
const navExpandedRef = useRef(false);
const animationFrameRef = useRef<number | null>(null);
useEffect(() => {
navOffsetRef.current = navOffset;
}, [navOffset]);
useEffect(() => {
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, []);
const animateOffset = useCallback((target: number) => {
if (!Number.isFinite(target)) return;
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
const startValue = navOffsetRef.current;
const delta = target - startValue;
if (Math.abs(delta) < 0.5) {
navOffsetRef.current = target;
setNavOffset(target);
return;
}
const duration = 520;
const startTime = performance.now();
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
const step = (now: number) => {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = easeOutCubic(progress);
const value = startValue + delta * eased;
navOffsetRef.current = value;
setNavOffset(value);
if (progress < 1) {
animationFrameRef.current = requestAnimationFrame(step);
} else {
animationFrameRef.current = null;
}
};
animationFrameRef.current = requestAnimationFrame(step);
}, []);
const calculateNavOffset = useCallback(
(mode: "animate" | "immediate" = "animate", expandedOverride?: boolean) => {
if (!navWrapperRef.current || !navInnerRef.current) return;
const expanded = expandedOverride ?? navExpandedRef.current;
const wrapperWidth = navWrapperRef.current.clientWidth;
const navWidth = navInnerRef.current.offsetWidth;
const offset = expanded ? Math.max(0, (wrapperWidth - navWidth) / 2) : 0;
if (!Number.isFinite(offset)) return;
if (mode === "immediate") {
navOffsetRef.current = offset;
setNavOffset(offset);
} else {
animateOffset(offset);
}
},
[animateOffset]
);
useEffect(() => {
calculateNavOffset("immediate", navExpandedRef.current);
const handleResize = () => calculateNavOffset("immediate", navExpandedRef.current);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [calculateNavOffset]);
useEffect(() => {
if (!navInnerRef.current || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
calculateNavOffset("immediate", navExpandedRef.current);
});
observer.observe(navInnerRef.current);
return () => observer.disconnect();
}, [calculateNavOffset]);
useEffect(() => {
const wrapper = navWrapperRef.current;
if (!wrapper) return;
const handleTransitionEnd = (event: TransitionEvent) => {
if (event.propertyName === "max-width" || event.propertyName === "width" || event.propertyName === "transform") {
calculateNavOffset(navExpandedRef.current ? "animate" : "immediate", navExpandedRef.current);
}
};
wrapper.addEventListener("transitionend", handleTransitionEnd);
return () => wrapper.removeEventListener("transitionend", handleTransitionEnd);
}, [calculateNavOffset]);
// Partner Logos für LogoLoop
const partnerLogos = [
{
node: (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<svg viewBox="0 0 48 48" fill="none" width={96} height={96} xmlns="http://www.w3.org/2000/svg">
{/* Traefik - Reverse Proxy mit drei Punkten */}
<circle cx="16" cy="16" r="4" fill={colors.background}/>
<circle cx="24" cy="24" r="4" fill={colors.background}/>
<circle cx="32" cy="32" r="4" fill={colors.background}/>
<path d="M16 16L24 24M24 24L32 32" stroke={colors.background} strokeWidth="2" strokeLinecap="round"/>
<path d="M12 24L24 12L36 24" stroke={colors.background} strokeWidth="2" opacity="0.5"/>
</svg>
<span style={{ color: colors.background, fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>Traefik</span>
</div>
),
title: "Traefik",
href: "https://traefik.io"
},
{
node: (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<svg viewBox="0 0 48 48" fill="none" width={96} height={96} xmlns="http://www.w3.org/2000/svg">
{/* Porkbun - Domain/Globe */}
<circle cx="24" cy="24" r="16" stroke={colors.background} strokeWidth="3"/>
<path d="M8 24C8 18 12 14 24 14C36 14 40 18 40 24C40 30 36 34 24 34C12 34 8 30 8 24Z" stroke={colors.background} strokeWidth="2"/>
<path d="M24 8C24 8 18 14 18 20C18 26 24 32 24 32" stroke={colors.background} strokeWidth="2"/>
</svg>
<span style={{ color: colors.background, fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>Porkbun</span>
</div>
),
title: "Porkbun",
href: "https://porkbun.com"
},
{
node: (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<svg viewBox="0 0 48 48" fill="none" width={96} height={96} xmlns="http://www.w3.org/2000/svg">
{/* n8n - Workflow Nodes */}
<circle cx="16" cy="16" r="5" fill={colors.background}/>
<circle cx="32" cy="16" r="5" fill={colors.background}/>
<circle cx="16" cy="32" r="5" fill={colors.background}/>
<circle cx="32" cy="32" r="5" fill={colors.background}/>
<path d="M21 16L27 16M16 21L16 27M21 32L27 32" stroke={colors.background} strokeWidth="2" strokeLinecap="round"/>
</svg>
<span style={{ color: colors.background, fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>n8n</span>
</div>
),
title: "n8n",
href: "https://n8n.io"
},
{
node: (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<svg viewBox="0 0 48 48" fill="none" width={96} height={96} xmlns="http://www.w3.org/2000/svg">
{/* Mistral AI - Wind/Wave */}
<path d="M8 24Q16 16 24 24T40 24" stroke={colors.background} strokeWidth="3" strokeLinecap="round" fill="none"/>
<path d="M10 28Q18 20 26 28T42 28" stroke={colors.background} strokeWidth="3" strokeLinecap="round" fill="none" opacity="0.7"/>
<circle cx="24" cy="24" r="2" fill={colors.background} opacity="0.5"/>
</svg>
<span style={{ color: colors.background, fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>Mistral AI</span>
</div>
),
title: "Mistral AI",
href: "https://mistral.ai"
},
{
node: (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<svg viewBox="0 0 48 48" fill="none" width={96} height={96} xmlns="http://www.w3.org/2000/svg">
{/* Hetzner - Server/Cloud */}
<rect x="12" y="14" width="24" height="20" rx="2" stroke={colors.background} strokeWidth="3"/>
<path d="M16 20H32M16 24H32M16 28H28" stroke={colors.background} strokeWidth="2" strokeLinecap="round"/>
<circle cx="36" cy="18" r="3" fill={colors.background}/>
</svg>
<span style={{ color: colors.background, fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>Hetzner</span>
</div>
),
title: "Hetzner",
href: "https://www.hetzner.com"
},
{
node: (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<svg viewBox="0 0 48 48" fill="none" width={96} height={96} xmlns="http://www.w3.org/2000/svg">
{/* Cursor - Code Editor */}
<rect x="12" y="12" width="24" height="24" rx="4" stroke={colors.background} strokeWidth="3"/>
<path d="M18 20L24 24L18 28" stroke={colors.background} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M28 20L30 24L28 28" stroke={colors.background} strokeWidth="2" strokeLinecap="round"/>
</svg>
<span style={{ color: colors.background, fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>Cursor</span>
</div>
),
title: "Cursor AI",
href: "https://cursor.sh"
},
{
node: (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<svg viewBox="0 0 48 48" fill="none" width={96} height={96} xmlns="http://www.w3.org/2000/svg">
{/* Appwrite - Database/Backend */}
<rect x="14" y="10" width="20" height="28" rx="2" stroke={colors.background} strokeWidth="3"/>
<path d="M14 18H34M14 24H34M14 30H28" stroke={colors.background} strokeWidth="2" strokeLinecap="round"/>
<circle cx="32" cy="14" r="2" fill={colors.background}/>
</svg>
<span style={{ color: colors.background, fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>Appwrite</span>
</div>
),
title: "Appwrite",
href: "https://appwrite.io"
}
];
const valueProps = [
{
title: "Zeitersparnis",
description: "Wir übernehmen den digitalen Teil, Sie konzentrieren sich aufs Geschäft",
link: "#zeitersparnis"
},
{
title: "Kompetenz & Erfahrung",
description: "Technisch stark, klar in der Umsetzung",
link: "#kompetenz-erfahrung"
},
{
title: "Maßgeschneiderte Lösungen",
description: "Keine Templates, sondern individuelle Umsetzung",
link: "#massgeschneiderte-loesungen"
},
{
title: "Stressfreies Webmanagement",
description: "Ein Ansprechpartner für alles",
link: "#stressfreies-webmanagement"
}
];
const navIsExpanded = scrollY > 40;
const previousNavState = useRef(navIsExpanded);
useEffect(() => {
const previous = previousNavState.current;
previousNavState.current = navIsExpanded;
navExpandedRef.current = navIsExpanded;
let raf1: number | null = null;
let raf2: number | null = null;
let timeoutId: number | null = null;
if (navIsExpanded) {
if (previous === false) {
raf1 = requestAnimationFrame(() => {
calculateNavOffset("animate", true);
});
raf2 = requestAnimationFrame(() => {
calculateNavOffset("animate", true);
});
timeoutId = window.setTimeout(() => {
calculateNavOffset("animate", true);
}, 320);
} else {
calculateNavOffset("immediate", true);
}
} else {
animateOffset(0);
}
return () => {
if (raf1 !== null) cancelAnimationFrame(raf1);
if (raf2 !== null) cancelAnimationFrame(raf2);
if (timeoutId !== null) window.clearTimeout(timeoutId);
};
}, [navIsExpanded, calculateNavOffset, animateOffset]);
return (
<>
<div className="min-h-screen overflow-hidden" style={{ backgroundColor: colors.background }}>
{/* Fixed Navigation mit PillNav */}
<div
className="fixed left-0 right-0 z-40 px-4 top-4 transition-all duration-500"
style={{
transitionTimingFunction: "cubic-bezier(0.22, 1, 0.36, 1)"
}}
>
<div
className="w-full mx-auto transition-all duration-500 ease-out"
style={{
maxWidth: navIsExpanded ? "72rem" : "38rem",
width: navIsExpanded ? "100%" : "96%"
}}
>
<GlassSurface
width="100%"
height="auto"
borderRadius={9999}
displace={2.0}
backgroundOpacity={0.3}
className="w-full transition-all duration-500 ease-out"
style={{
minHeight: '70px',
backgroundColor: `${colors.secondary}66`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0.5rem 1.5rem',
transition: 'backdrop-filter 0.45s ease'
}}
>
<div
ref={navWrapperRef}
className="relative w-full flex items-center justify-center transition-all duration-500"
style={{
transitionTimingFunction: "cubic-bezier(0.22, 1, 0.36, 1)"
}}
>
{/* PillNav mit den Nav-Links */}
<div
ref={navInnerRef}
className="inline-flex transition-all duration-500"
style={{
transform: navIsExpanded ? `translateX(-${navOffset}px)` : "translateX(0)",
transitionTimingFunction: "cubic-bezier(0.22, 1, 0.36, 1)"
}}
>
<PillNav
logo="/WebKlarLogo.png"
logoAlt="Webklar Logo"
items={[
{ label: 'Über uns', href: '#about' },
{ label: 'Leistungen', href: '#services' },
{ label: 'Unsere Abläufe', href: '#process' }
]}
activeHref="#"
baseColor={colors.secondary}
pillColor={colors.background}
hoveredPillTextColor={colors.background}
pillTextColor={colors.primary}
className="pill-nav-custom"
/>
</div>
{/* Kontakt-Button rechts */}
<div
className="hidden md:block absolute right-0 transition-all duration-500 overflow-hidden"
style={{
opacity: navIsExpanded ? 1 : 0,
maxWidth: navIsExpanded ? "200px" : "0px",
pointerEvents: navIsExpanded ? "auto" : "none",
transform: navIsExpanded ? "translateX(0)" : "translateX(16px)",
transitionTimingFunction: "cubic-bezier(0.22, 1, 0.36, 1)"
}}
>
<Link href="/kontakte">
<Button className="rounded-full text-sm font-semibold px-3 sm:px-5 py-1.5 shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105 btn-enhanced pulse-glow" style={{ backgroundColor: colors.primary, color: colors.background }}>
Kontakt
</Button>
</Link>
</div>
</div>
</GlassSurface>
</div>
</div>
{/* Background Video Hero Section */}
<section id="hero" className="relative h-screen flex items-center justify-center overflow-hidden">
{/* Video Background */}
<div className="absolute inset-0 z-0">
<video
autoPlay
muted
loop
playsInline
className="w-full h-full object-cover"
style={{ filter: 'blur(8px)' }}
>
<source src="/path/to/your/background-video.mp4" type="video/mp4" />
</video>
<div
className="absolute inset-0 backdrop-blur-sm"
style={{
background: `linear-gradient(135deg, ${colors.primary}CC, ${colors.secondary}CC)`
}}
></div>
</div>
<div ref={heroRef} className="relative z-20 px-4 sm:px-8 pt-24 sm:pt-28 pb-20 sm:pb-24 max-w-7xl mx-auto text-center">
<h1 className={`text-4xl sm:text-6xl md:text-8xl font-bold mb-6 sm:mb-8 transition-all duration-1000 ${
heroInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'
}`}>
<span style={{ color: colors.background }}>
webklar das Web maßgeschneidert auf Ihr Unternehmen
</span>
</h1>
<div className={`flex flex-wrap justify-center gap-2 sm:gap-4 mb-8 sm:mb-12 text-xs sm:text-sm mt-6 transition-all duration-1000 delay-300 ${
heroInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'
}`}>
{['Strategieberatung', 'UX/UI Design', 'Entwicklung', 'SEO & Support'].map((item) => (
<span
key={item}
className="px-3 sm:px-4 py-2 rounded-full backdrop-blur-sm border"
style={{
backgroundColor: `${colors.background}80`,
borderColor: colors.tertiary,
color: colors.primary
}}
>
{item}
</span>
))}
</div>
<div className={`flex flex-col sm:flex-row items-center justify-center gap-4 sm:gap-6 transition-all duration-1000 delay-500 ${
heroInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'
}`}>
<Button
onClick={() => document.getElementById('contact')?.scrollIntoView({ behavior: 'smooth' })}
className="w-full sm:w-auto px-6 sm:px-8 py-3 sm:py-4 rounded-full flex items-center justify-center space-x-3 text-base sm:text-lg font-semibold shadow-2xl hover:shadow-3xl transition-all duration-300 hover:scale-105 btn-enhanced hover-lift"
style={{
backgroundColor: colors.background,
color: colors.primary
}}
>
<Calendar className="w-5 sm:w-6 h-5 sm:h-6" />
<span>Kostenlosen Termin buchen</span>
</Button>
</div>
{/* Partner Tools */}
<div className="mt-8 sm:mt-12 px-4 sm:px-8">
<div style={{ position: 'relative', overflow: 'visible', maxWidth: '100%' }} className="logo-loop-container">
<div style={{ height: '180px', position: 'relative', width: '100%', paddingBottom: '30px', overflow: 'hidden' }} className="logo-loop-inner">
<LogoLoop
logos={partnerLogos}
speed={60}
direction="left"
logoHeight={140}
gap={100}
pauseOnHover
scaleOnHover
ariaLabel="Technology partners"
style={{ width: '100%' }}
/>
</div>
</div>
</div>
</div>
</section>
{/* About Section */}
<section
id="about"
ref={aboutRef}
className="relative px-4 sm:px-8 py-12 sm:py-20 rounded-t-[2rem] sm:rounded-t-[3rem] rounded-b-[2rem] sm:rounded-b-[3rem] mx-2 sm:mx-4 backdrop-blur-sm"
style={{ backgroundColor: `${colors.background}F0`, position: 'relative', zIndex: 10 }}
>
<div className="max-w-7xl mx-auto">
<div className="grid lg:grid-cols-2 gap-8 sm:gap-16 items-center">
{/* Links: Spinning Numbers */}
<div className="relative order-2 lg:order-1">
<SpinningNumbers />
</div>
{/* Rechts: Text im Zeitungsstil (mehrspaltig) */}
<div className={`order-1 lg:order-2 transition-all duration-1000 w-full flex flex-col justify-center ${
aboutInView ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-10'
}`}>
<h2 className="text-3xl sm:text-5xl md:text-6xl font-bold mb-6 sm:mb-8 leading-tight whitespace-nowrap" style={{ color: colors.primary }}>
<span className="inline-block">Worauf wir</span>{" "}
<span className="inline-block">Wert</span>{" "}
<span className="inline-block">legen</span>
</h2>
<div
className="text-xl sm:text-2xl md:text-3xl leading-relaxed"
style={{
color: colors.secondary,
width: '100%',
maxWidth: '100%',
columnCount: 1,
columnGap: '3rem',
columnFill: 'balance',
textAlign: 'justify'
}}
>
Sicherheit ist für uns keine Nebensache, sondern die Grundlage jeder Website. Wir setzen auf moderne Technologien, zertifizierte Partner und höchste Datenschutzstandards. Unsere Systeme sind darauf ausgelegt, Ausfälle zu vermeiden und langfristig stabile Ergebnisse zu liefern damit Ihre Online-Präsenz so zuverlässig ist wie Ihr Unternehmen selbst.
</div>
</div>
</div>
</div>
</section>
{/* Services Grid */}
<section
id="services"
ref={servicesRef}
className="relative px-4 sm:px-8 py-12 sm:py-20 rounded-t-[2rem] sm:rounded-t-[3rem] rounded-b-[2rem] sm:rounded-b-[3rem] mx-2 sm:mx-4 backdrop-blur-sm"
style={{ backgroundColor: colors.primary }}
>
<div className="max-w-7xl mx-auto">
<div className="text-center mb-12 sm:mb-16">
<h2 className={`text-3xl sm:text-5xl md:text-6xl font-bold mb-4 sm:mb-6 transition-all duration-1000 ${
servicesInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'
}`} style={{ color: colors.tertiary }}>
Unsere Leistungen
</h2>
<p className={`text-lg sm:text-xl transition-all duration-1000 delay-200 ${
servicesInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'
}`} style={{ color: colors.background }}>
Alles aus einer Hand für Ihren digitalen Erfolg
</p>
</div>
<div
className={`transition-all duration-1000 ${
servicesInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-10"
}`}
>
<HeroScrollDemo />
</div>
</div>
</section>
{/* Process Section */}
<section
id="process"
ref={processRef}
className="relative px-4 sm:px-8 py-12 sm:py-20 rounded-t-[2rem] sm:rounded-t-[3rem] rounded-b-[2rem] sm:rounded-b-[3rem] mx-2 sm:mx-4 backdrop-blur-sm"
style={{ backgroundColor: `${colors.background}F0` }}
>
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12 sm:mb-16">
<h2 className={`text-3xl sm:text-5xl md:text-6xl font-bold mb-4 sm:mb-6 transition-all duration-1000 ${
processInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'
}`} style={{ color: colors.primary }}>
Unser Ablauf
</h2>
<p className={`text-lg sm:text-xl transition-all duration-1000 delay-200 ${
processInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'
}`} style={{ color: colors.secondary }}>
So läuft die Zusammenarbeit ab
</p>
</div>
<div
className="rounded-[32px] px-2 py-10 sm:px-6 sm:py-16 transition-all duration-700"
style={{
background: `linear-gradient(135deg, ${colors.background}F2, ${colors.background}E8)`
}}
>
<TimelineDemo />
</div>
</div>
</section>
{/* Pricing Section */}
<section
id="references"
ref={pricingRef}
className="relative px-4 sm:px-8 py-12 sm:py-20 rounded-t-[2rem] sm:rounded-t-[3rem] rounded-b-[2rem] sm:rounded-b-[3rem] mx-2 sm:mx-4 backdrop-blur-sm"
style={{ backgroundColor: `${colors.primary}F0` }}
>
<div className="max-w-4xl mx-auto text-center">
<div className="mb-12 sm:mb-16">
<h2 className={`text-3xl sm:text-5xl md:text-6xl font-bold mb-4 sm:mb-6 transition-all duration-1000 ${
pricingInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'
}`} style={{ color: colors.tertiary }}>
Faire Preise
</h2>
<p className={`text-lg sm:text-xl transition-all duration-1000 delay-200 ${
pricingInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'
}`} style={{ color: colors.background }}>
Transparent und flexibel
</p>
</div>
<div className={`p-8 sm:p-12 rounded-3xl shadow-2xl backdrop-blur-sm transition-all duration-1000 delay-300 ${
pricingInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'
}`} style={{ backgroundColor: `${colors.background}F0` }}>
<h3 className="text-2xl sm:text-3xl font-bold mb-6" style={{ color: colors.primary }}>
Individuelle Lösungen
</h3>
<p className="text-lg sm:text-xl mb-8 leading-relaxed" style={{ color: colors.secondary }}>
Unsere Preise richten sich nach dem Projektumfang und Ihren Anforderungen.
Gemeinsam finden wir eine Lösung, die zu Ihrem Budget passt transparent, fair und flexibel.
</p>
<PriceCalculator />
</div>
</div>
</section>
{/* Target Groups & Value Props */}
<section
className="relative px-4 sm:px-8 py-12 sm:py-20 rounded-t-[2rem] sm:rounded-t-[3rem] rounded-b-[2rem] sm:rounded-b-[3rem] mx-2 sm:mx-4 backdrop-blur-sm"
style={{ backgroundColor: `${colors.background}F0` }}
>
<div className="max-w-7xl mx-auto">
<div className="grid lg:grid-cols-2 gap-12 sm:gap-16">
{/* Target Groups */}
<div>
<h2 className="text-2xl sm:text-4xl font-bold mb-6 sm:mb-8" style={{ color: colors.primary }}>
Für wen wir arbeiten
</h2>
<p className="text-lg sm:text-xl leading-relaxed" style={{ color: colors.secondary }}>
Wir arbeiten mit Unternehmen, die ihre veraltete Website modernisieren oder ihre Zeit nicht mehr mit Technik und Support verschwenden wollen.
</p>
</div>
{/* Value Props */}
<div>
<h2 className="text-2xl sm:text-4xl font-bold mb-6 sm:mb-8" style={{ color: colors.primary }}>
Warum wir das tun
</h2>
<div
className="rounded-3xl border shadow-sm"
style={{
background: `linear-gradient(135deg, ${colors.background}F5, ${colors.tertiary}1A)`,
borderColor: `${colors.secondary}55`
}}
>
<div className="p-4 sm:p-8">
<HoverEffect
items={valueProps}
className="py-2"
/>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Contact Section */}
<section
ref={contactRef}
id="contact"
className="relative px-4 sm:px-8 py-12 sm:py-20 rounded-t-[2rem] sm:rounded-t-[3rem] rounded-b-[2rem] sm:rounded-b-[3rem] mx-2 sm:mx-4 backdrop-blur-sm"
style={{ backgroundColor: colors.secondary }}
>
<div className="max-w-4xl mx-auto">
<div className="text-center mb-12 sm:mb-16">
<h2
className="text-3xl sm:text-5xl md:text-6xl font-bold mb-4 sm:mb-6"
style={{ color: colors.background }}
>
Lassen Sie uns sprechen
</h2>
<p
className="text-lg sm:text-xl opacity-90"
style={{ color: colors.background }}
>
Erzählen Sie uns von Ihrem Projekt
</p>
</div>
<div className={`transition-all duration-1000 ${
contactInView ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'
}`}>
<ProtectedAppointmentBooking />
</div>
</div>
</section>
{/* Footer */}
<footer
className="relative py-8 sm:py-12 border-t rounded-t-[2rem] sm:rounded-t-[3rem] mx-2 sm:mx-4 backdrop-blur-sm"
style={{
backgroundColor: `${colors.primary}F0`,
borderColor: colors.secondary
}}
>
<div className="max-w-7xl mx-auto px-4 sm:px-8">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6 sm:gap-8">
<div className="col-span-1 sm:col-span-2">
<div
className="text-2xl sm:text-3xl font-bold mb-4 relative"
style={{ color: colors.tertiary }}
>
<span className="relative z-10">Webklar</span>
<div
className="absolute -inset-2 rounded-xl blur-sm opacity-20"
style={{ backgroundColor: colors.secondary }}
></div>
</div>
<p
className="mb-6 leading-relaxed text-sm sm:text-base"
style={{ color: colors.background }}
>
Ihr Partner für Web & Support. Moderne Websites. Klare Kommunikation. Persönlicher Support.
</p>
</div>
<div>
<h4 className="text-base sm:text-lg font-semibold mb-4" style={{ color: colors.tertiary }}>
Services
</h4>
<ul className="space-y-2 text-sm sm:text-base" style={{ color: colors.background }}>
{['Webdesign', 'E-Commerce', 'SEO', 'Hosting'].map((item) => (
<li key={item}>
<a href="#" className="hover:opacity-80 transition-opacity">{item}</a>
</li>
))}
</ul>
</div>
<div>
<h4 className="text-base sm:text-lg font-semibold mb-4" style={{ color: colors.tertiary }}>
Kontakt
</h4>
<ul className="space-y-2 text-sm sm:text-base" style={{ color: colors.background }}>
<li><Link href="/impressum" className="hover:opacity-80 transition-opacity">Impressum</Link></li>
<li><Link href="/datenschutz" className="hover:opacity-80 transition-opacity">Datenschutz</Link></li>
<li><Link href="/agb" className="hover:opacity-80 transition-opacity">AGB</Link></li>
<li><Link href="/kontakte" className="hover:opacity-80 transition-opacity">Kontakte</Link></li>
</ul>
</div>
</div>
<div
className="border-t mt-6 sm:mt-8 pt-6 sm:pt-8 text-center text-sm"
style={{
borderColor: colors.secondary,
color: colors.background
}}
>
<p>&copy; 2025 Webklar. Alle Rechte vorbehalten.</p>
</div>
</div>
</footer>
{/* Film Grain Effect */}
{/* Cookie Button */}
<CookieButton />
{/* Appointment Status */}
<AppointmentStatus />
</div>
</>
);
}

9
app/protected/page.tsx Normal file
View File

@@ -0,0 +1,9 @@
import ProtectedAppointmentBooking from '@/components/ProtectedAppointmentBooking'
export default function ProtectedPage() {
return (
<div className="min-h-screen p-4">
<ProtectedAppointmentBooking />
</div>
)
}

103
app/success/page.tsx Normal file
View File

@@ -0,0 +1,103 @@
"use client";
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from "@/components/ui/button";
import { CheckCircle, ArrowRight, Mail } from "lucide-react";
import { colors } from '@/lib/colors';
export default function SuccessPage() {
const router = useRouter();
const [countdown, setCountdown] = useState(5);
useEffect(() => {
// Auto-redirect after 5 seconds
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer);
router.push('/');
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [router]);
const handleContinue = () => {
router.push('/');
};
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div
className="p-8 rounded-3xl shadow-lg backdrop-blur-sm text-center"
style={{ backgroundColor: `${colors.background}F0` }}
>
{/* Success Icon */}
<div className="w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6"
style={{ backgroundColor: `${colors.primary}20` }}>
<CheckCircle className="w-12 h-12" style={{ color: colors.primary }} />
</div>
{/* Success Message */}
<h1 className="text-2xl font-bold mb-4" style={{ color: colors.primary }}>
E-Mail erfolgreich bestätigt!
</h1>
<p className="text-lg mb-6" style={{ color: colors.secondary }}>
Ihre E-Mail-Adresse wurde erfolgreich verifiziert.
Ihr Termin ist jetzt bestätigt.
</p>
{/* Additional Info */}
<div className="p-4 rounded-xl border-2 mb-6"
style={{
backgroundColor: `${colors.primary}10`,
borderColor: colors.primary
}}>
<div className="flex items-center space-x-2 mb-2">
<Mail className="w-5 h-5" style={{ color: colors.primary }} />
<span className="text-sm font-semibold" style={{ color: colors.primary }}>
Automatische Bestätigung
</span>
</div>
<p className="text-xs" style={{ color: colors.secondary }}>
Sie wurden automatisch angemeldet und Ihr Termin wurde bestätigt.
Keine weiteren Schritte erforderlich.
</p>
</div>
{/* Action Buttons */}
<div className="space-y-3">
<Button
onClick={handleContinue}
className="w-full py-3 rounded-xl text-lg font-semibold flex items-center justify-center space-x-2"
style={{
backgroundColor: colors.primary,
color: colors.background
}}
>
<ArrowRight className="w-5 h-5" />
<span>Weiter zur Startseite</span>
</Button>
<p className="text-xs" style={{ color: colors.secondary }}>
Automatische Weiterleitung in {countdown} Sekunden...
</p>
</div>
{/* Additional Info */}
<div className="mt-6 text-xs" style={{ color: colors.secondary }}>
<p> Sie erhalten eine Bestätigungs-E-Mail</p>
<p> Unser Team wird sich bald bei Ihnen melden</p>
<p> Vielen Dank für Ihr Vertrauen!</p>
</div>
</div>
</div>
</div>
);
}

162
app/verify-email/page.tsx Normal file
View File

@@ -0,0 +1,162 @@
"use client";
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from "@/components/ui/button";
import { CheckCircle, AlertCircle, ArrowRight } from "lucide-react";
import { supabase } from '@/lib/supabaseClient';
import { colors } from '@/lib/colors';
export default function VerifyEmailPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [verificationStatus, setVerificationStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [errorMessage, setErrorMessage] = useState<string>('');
useEffect(() => {
const handleEmailVerification = async () => {
try {
// Get the session from the URL parameters
const { data, error } = await supabase.auth.getSession();
if (error) {
console.error('Error getting session:', error);
setVerificationStatus('error');
setErrorMessage('Fehler bei der E-Mail-Verifizierung. Bitte versuchen Sie es erneut.');
return;
}
if (data.session) {
// User is authenticated, verification was successful
setVerificationStatus('success');
// Redirect back to the main page after a short delay
setTimeout(() => {
router.push('/#contact');
}, 3000);
} else {
// No session found, verification might have failed
setVerificationStatus('error');
setErrorMessage('E-Mail-Verifizierung fehlgeschlagen. Bitte überprüfen Sie den Link und versuchen Sie es erneut.');
}
} catch (err) {
console.error('Error during email verification:', err);
setVerificationStatus('error');
setErrorMessage('Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
}
};
handleEmailVerification();
}, [router]);
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div
className="p-8 rounded-3xl shadow-lg backdrop-blur-sm text-center"
style={{ backgroundColor: `${colors.background}F0` }}
>
{verificationStatus === 'loading' && (
<>
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-6"
style={{ backgroundColor: `${colors.primary}20` }}>
<div className="animate-spin rounded-full h-8 w-8 border-b-2"
style={{ borderColor: colors.primary }}></div>
</div>
<h2 className="text-2xl font-bold mb-4" style={{ color: colors.primary }}>
E-Mail wird verifiziert...
</h2>
<p className="text-sm" style={{ color: colors.secondary }}>
Bitte warten Sie einen Moment, während wir Ihre E-Mail-Adresse bestätigen.
</p>
</>
)}
{verificationStatus === 'success' && (
<>
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-6"
style={{ backgroundColor: `${colors.primary}20` }}>
<CheckCircle className="w-8 h-8" style={{ color: colors.primary }} />
</div>
<h2 className="text-2xl font-bold mb-4" style={{ color: colors.primary }}>
E-Mail erfolgreich verifiziert!
</h2>
<p className="text-sm mb-6" style={{ color: colors.secondary }}>
Vielen Dank! Ihre E-Mail-Adresse wurde erfolgreich bestätigt.
Ihr Termin wird nun in unserem System gespeichert.
</p>
<div className="p-4 rounded-xl border-2 mb-6"
style={{
backgroundColor: `${colors.primary}10`,
borderColor: colors.primary
}}>
<p className="text-xs" style={{ color: colors.secondary }}>
Sie werden automatisch zur Hauptseite weitergeleitet...
</p>
</div>
<Button
onClick={() => router.push('/#contact')}
className="w-full rounded-xl flex items-center justify-center space-x-2"
style={{
backgroundColor: colors.primary,
color: colors.background
}}
>
<span>Zurück zur Hauptseite</span>
<ArrowRight className="w-4 h-4" />
</Button>
</>
)}
{verificationStatus === 'error' && (
<>
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-6"
style={{ backgroundColor: '#fef2f2' }}>
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
<h2 className="text-2xl font-bold mb-4" style={{ color: colors.primary }}>
Verifizierung fehlgeschlagen
</h2>
<p className="text-sm mb-6" style={{ color: colors.secondary }}>
{errorMessage}
</p>
<div className="space-y-3">
<Button
onClick={() => router.push('/#contact')}
className="w-full rounded-xl"
style={{
backgroundColor: colors.primary,
color: colors.background
}}
>
Zurück zur Terminbuchung
</Button>
<Button
variant="outline"
className="w-full rounded-xl"
style={{
borderColor: colors.tertiary,
color: colors.primary
}}
onClick={() => window.location.reload()}
>
Erneut versuchen
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}

24
components.json Normal file
View File

@@ -0,0 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@aceternity": "https://ui.aceternity.com/registry/{name}.json"
}
}

View File

@@ -0,0 +1,95 @@
.scroll-list-container {
position: relative;
width: 100%;
}
.scroll-list {
max-height: 600px;
overflow-y: auto;
padding: 16px;
}
.scroll-list::-webkit-scrollbar {
width: 8px;
}
.scroll-list::-webkit-scrollbar-track {
background: rgba(10, 64, 12, 0.3);
}
.scroll-list::-webkit-scrollbar-thumb {
background: #B1AB86;
border-radius: 4px;
}
.scroll-list::-webkit-scrollbar-thumb:hover {
background: #819067;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.item {
padding: 32px;
background-color: rgba(254, 250, 224, 0.1);
border-radius: 16px;
margin-bottom: 1.5rem;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.item.selected {
background-color: rgba(254, 250, 224, 0.2);
border-color: #B1AB86;
transform: scale(1.02);
}
.item-text {
color: #FEFAE0;
margin: 0;
white-space: pre-line;
line-height: 1.6;
}
.item-title {
font-weight: 600;
font-size: 1.4rem;
margin-bottom: 0.75rem;
color: #B1AB86;
}
.item-description {
font-size: 1.15rem;
color: #FEFAE0;
line-height: 1.6;
opacity: 0.9;
}
.top-gradient {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 50px;
background: linear-gradient(to bottom, #0A400C, transparent);
pointer-events: none;
transition: opacity 0.3s ease;
}
.bottom-gradient {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 100px;
background: linear-gradient(to top, #0A400C, transparent);
pointer-events: none;
transition: opacity 0.3s ease;
}

173
components/AnimatedList.tsx Normal file
View File

@@ -0,0 +1,173 @@
import { useRef, useState, useEffect, ReactNode } from 'react';
import { motion, useInView } from 'motion/react';
import './AnimatedList.css';
interface AnimatedItemProps {
children: ReactNode;
delay?: number;
index: number;
onMouseEnter: () => void;
onClick: () => void;
}
const AnimatedItem = ({ children, delay = 0, index, onMouseEnter, onClick }: AnimatedItemProps) => {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { amount: 0.5, triggerOnce: false });
return (
<motion.div
ref={ref}
data-index={index}
onMouseEnter={onMouseEnter}
onClick={onClick}
initial={{ scale: 0.7, opacity: 0 }}
animate={inView ? { scale: 1, opacity: 1 } : { scale: 0.7, opacity: 0 }}
transition={{ duration: 0.2, delay }}
style={{ marginBottom: '1rem', cursor: 'pointer' }}
>
{children}
</motion.div>
);
};
interface AnimatedListProps {
items?: string[];
onItemSelect?: (item: string, index: number) => void;
showGradients?: boolean;
enableArrowNavigation?: boolean;
className?: string;
itemClassName?: string;
displayScrollbar?: boolean;
initialSelectedIndex?: number;
}
const AnimatedList = ({
items = [
'Item 1',
'Item 2',
'Item 3',
'Item 4',
'Item 5',
'Item 6',
'Item 7',
'Item 8',
'Item 9',
'Item 10',
'Item 11',
'Item 12',
'Item 13',
'Item 14',
'Item 15'
],
onItemSelect,
showGradients = true,
enableArrowNavigation = true,
className = '',
itemClassName = '',
displayScrollbar = true,
initialSelectedIndex = -1
}: AnimatedListProps) => {
const listRef = useRef<HTMLDivElement>(null);
const [selectedIndex, setSelectedIndex] = useState(initialSelectedIndex);
const [keyboardNav, setKeyboardNav] = useState(false);
const [topGradientOpacity, setTopGradientOpacity] = useState(0);
const [bottomGradientOpacity, setBottomGradientOpacity] = useState(1);
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
setTopGradientOpacity(Math.min(scrollTop / 50, 1));
const bottomDistance = scrollHeight - (scrollTop + clientHeight);
setBottomGradientOpacity(scrollHeight <= clientHeight ? 0 : Math.min(bottomDistance / 50, 1));
};
useEffect(() => {
if (!enableArrowNavigation) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
e.preventDefault();
setKeyboardNav(true);
setSelectedIndex(prev => Math.min(prev + 1, items.length - 1));
} else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
e.preventDefault();
setKeyboardNav(true);
setSelectedIndex(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') {
if (selectedIndex >= 0 && selectedIndex < items.length) {
e.preventDefault();
if (onItemSelect) {
onItemSelect(items[selectedIndex], selectedIndex);
}
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [items, selectedIndex, onItemSelect, enableArrowNavigation]);
useEffect(() => {
if (!keyboardNav || selectedIndex < 0 || !listRef.current) return;
const container = listRef.current;
const selectedItem = container.querySelector(`[data-index="${selectedIndex}"]`);
if (selectedItem) {
const extraMargin = 50;
const containerScrollTop = container.scrollTop;
const containerHeight = container.clientHeight;
const itemTop = selectedItem.offsetTop;
const itemBottom = itemTop + selectedItem.offsetHeight;
if (itemTop < containerScrollTop + extraMargin) {
container.scrollTo({ top: itemTop - extraMargin, behavior: 'smooth' });
} else if (itemBottom > containerScrollTop + containerHeight - extraMargin) {
container.scrollTo({
top: itemBottom - containerHeight + extraMargin,
behavior: 'smooth'
});
}
}
setKeyboardNav(false);
}, [selectedIndex, keyboardNav]);
return (
<div className={`scroll-list-container ${className}`}>
<div ref={listRef} className={`scroll-list ${!displayScrollbar ? 'no-scrollbar' : ''}`} onScroll={handleScroll}>
{items.map((item, index) => (
<AnimatedItem
key={index}
delay={0.1}
index={index}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => {
setSelectedIndex(index);
if (onItemSelect) {
onItemSelect(item, index);
}
}}
>
<div className={`item ${selectedIndex === index ? 'selected' : ''} ${itemClassName}`}>
{typeof item === 'string' && item.includes('\n') ? (
<div>
{item.split('\n').map((line, i) => (
<p key={i} className={i === 0 ? 'item-title' : 'item-description'}>
{line}
</p>
))}
</div>
) : (
<p className="item-text">{item}</p>
)}
</div>
</AnimatedItem>
))}
</div>
{showGradients && (
<>
<div className="top-gradient" style={{ opacity: topGradientOpacity }}></div>
<div className="bottom-gradient" style={{ opacity: bottomGradientOpacity }}></div>
</>
)}
</div>
);
};
export default AnimatedList;

View File

@@ -0,0 +1,651 @@
"use client";
import React, { useState } from 'react';
import { Button } from "@/components/ui/button";
import { CheckCircle, Calendar, Mail, ArrowLeft } from "lucide-react";
import { supabase } from '@/lib/supabase';
import { colors } from '@/lib/colors';
import AppointmentForm from './AppointmentForm';
import EmailVerification from './EmailVerification';
import { useAuth } from '@/hooks/useAuth';
type BookingStep = 'form' | 'verification' | 'success';
interface AppointmentData {
name: string;
telefon: string;
firma: string;
email: string;
beschreibung: string;
termin_datum?: Date;
termin_time?: string;
}
export default function AppointmentBooking() {
const { user } = useAuth();
const [currentStep, setCurrentStep] = useState<BookingStep>('form');
const [appointmentData, setAppointmentData] = useState<AppointmentData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [testMode, setTestMode] = useState(false); // Production mode - no test indicators
const [tableStructure, setTableStructure] = useState<string[]>([]);
const [productionMode, setProductionMode] = useState(false); // Enable email verification
const [customerAction, setCustomerAction] = useState<'created' | 'updated' | null>(null);
const handleFormSubmit = async (data: AppointmentData) => {
setAppointmentData(data);
// Always go to email verification step
setCurrentStep('verification');
};
const handleVerificationComplete = async () => {
if (!appointmentData) return;
// Prüfe Authentifizierung nach E-Mail-Bestätigung
if (!user) {
setError('Bitte bestätige zuerst deine E-Mail-Adresse, um den Termin zu speichern.');
return;
}
setLoading(true);
setError(null);
try {
// Create the appointment datetime by combining date and time
console.log('=== DATE PROCESSING DEBUG ===');
console.log('Input termin_datum:', appointmentData.termin_datum);
console.log('Input termin_time:', appointmentData.termin_time);
console.log('Input termin_datum type:', typeof appointmentData.termin_datum);
console.log('Input termin_datum instanceof Date:', appointmentData.termin_datum instanceof Date);
// Check if termin_datum is actually a Date object
if (!(appointmentData.termin_datum instanceof Date)) {
console.error('❌ termin_datum is not a Date object!');
setError('Fehler: Ungültiges Datum. Bitte wählen Sie einen Termin aus.');
return;
}
const appointmentDateTime = new Date(appointmentData.termin_datum);
console.log('Created appointmentDateTime:', appointmentDateTime);
console.log('appointmentDateTime.toISOString():', appointmentDateTime.toISOString());
const [hours] = appointmentData.termin_time!.split(':').map(Number);
console.log('Extracted hours:', hours);
// Create the appointment datetime in local time to avoid timezone issues
const year = appointmentDateTime.getFullYear();
const month = appointmentDateTime.getMonth();
const day = appointmentDateTime.getDate();
console.log('Date components:', { year, month, day, hours });
// Create the final appointment datetime in local time
const finalAppointmentDateTime = new Date(year, month, day, hours, 0, 0, 0);
console.log('Final appointmentDateTime (local):', finalAppointmentDateTime);
console.log('Final appointmentDateTime.toISOString():', finalAppointmentDateTime.toISOString());
console.log('Final appointmentDateTime.getTime():', finalAppointmentDateTime.getTime());
console.log('Final appointmentDateTime local string:', finalAppointmentDateTime.toLocaleString('de-DE'));
// Store the appointment time in local time format
const localTimeString = `${year}-${(month + 1).toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}T${hours.toString().padStart(2, '0')}:00:00`;
console.log('Local time string to save:', localTimeString);
// Check if customer already exists (by email)
console.log('=== CUSTOMER CHECK DEBUG ===');
console.log('Input email:', appointmentData.email);
console.log('Input email type:', typeof appointmentData.email);
console.log('Input email length:', appointmentData.email.length);
console.log('Input email trimmed:', appointmentData.email.trim());
console.log('Input email lowercase:', appointmentData.email.trim().toLowerCase());
// First, let's check what's in the database for this email
await checkSpecificEmail(appointmentData.email);
// Try multiple email variations to find existing customer
const emailVariations = [
appointmentData.email.trim(),
appointmentData.email.trim().toLowerCase(),
appointmentData.email.trim().toUpperCase()
];
console.log('Checking email variations:', emailVariations);
let existingCustomers: any[] = [];
let checkError: any = null;
// Try each email variation
for (const emailVariation of emailVariations) {
const { data, error } = await supabase
.from('kunden_projekte')
.select('*')
.eq('email', emailVariation)
.order('erstellt_am', { ascending: false });
if (error) {
console.error(`Error checking email variation "${emailVariation}":`, error);
checkError = error;
continue;
}
if (data && data.length > 0) {
console.log(`Found ${data.length} customers with email "${emailVariation}"`);
existingCustomers = data;
break;
}
}
console.log('Database query result:', { existingCustomers, checkError });
console.log('Number of existing customers found:', existingCustomers?.length || 0);
if (existingCustomers && existingCustomers.length > 0) {
console.log('All found customers with this email:');
existingCustomers.forEach((customer, index) => {
console.log(`${index + 1}. ID: ${customer.id}, Email: "${customer.email}", Created: ${customer.erstellt_am}, Termin: ${customer.termin_datum}`);
});
}
if (checkError) {
console.error('Error checking existing customer:', checkError);
setError('Fehler beim Überprüfen der Kundendaten. Bitte versuchen Sie es erneut.');
return;
}
const existingCustomer = existingCustomers && existingCustomers.length > 0 ? existingCustomers[0] : null;
if (existingCustomer) {
console.log('✅ Existing customer found:', existingCustomer);
console.log('Will UPDATE existing customer with ID:', existingCustomer.id);
// Warn if there are multiple entries with the same email
if (existingCustomers.length > 1) {
console.warn(`⚠️ Found ${existingCustomers.length} entries with email ${appointmentData.email}. Using the most recent one.`);
}
} else {
console.log('❌ No existing customer found - will CREATE new');
}
const appointmentDataToSave = {
email: appointmentData.email,
beschreibung: `${appointmentData.beschreibung}\n\nTermin: ${formatAppointmentDate(appointmentData.termin_datum!, appointmentData.termin_time!)}\n\nKontakt: ${appointmentData.name} (${appointmentData.telefon})\nFirma: ${appointmentData.firma}`,
termin_datum: localTimeString, // Store in local time format
ansprechpartner_name: appointmentData.name,
telefon: appointmentData.telefon,
firma: appointmentData.firma,
berater: 'Webklar Team',
zielgruppe: 'Terminanfrage'
};
let result;
if (existingCustomer) {
// Use upsert to ensure we update the existing customer
console.log('🔄 Starting UPSERT operation for customer ID:', existingCustomer.id);
const newDescription = `${existingCustomer.beschreibung || ''}\n\n--- NEUER TERMIN ---\n${appointmentDataToSave.beschreibung}`;
console.log('New description:', newDescription);
// Use upsert with the existing ID to force update
const { data: upsertData, error: upsertError } = await supabase
.from('kunden_projekte')
.upsert({
id: existingCustomer.id, // Use existing ID to force update
...appointmentDataToSave,
beschreibung: newDescription,
erstellt_am: new Date().toISOString()
}, {
onConflict: 'id' // Update on ID conflict
})
.select('*');
if (upsertError) {
console.error('❌ Error upserting customer:', upsertError);
setError('Fehler beim Aktualisieren der Kundendaten. Bitte versuchen Sie es erneut.');
return;
}
if (!upsertData || upsertData.length === 0) {
console.error('❌ Upsert operation returned no data');
setError('Fehler beim Aktualisieren der Kundendaten. Bitte versuchen Sie es erneut.');
return;
}
result = upsertData[0];
console.log('✅ Customer updated via upsert:', result);
// Verify the update by fetching the customer again
const { data: verifyData, error: verifyError } = await supabase
.from('kunden_projekte')
.select('*')
.eq('id', existingCustomer.id)
.single();
if (verifyError) {
console.warn('⚠️ Could not verify update:', verifyError);
} else {
console.log('✅ Update verified - customer data:', verifyData);
}
setCustomerAction('updated');
} else {
// Create new customer record ONLY if no existing customer found
console.log('🆕 Starting CREATE operation for new customer');
console.log('Create data:', appointmentDataToSave);
const { data, error } = await supabase
.from('kunden_projekte')
.insert(appointmentDataToSave)
.select('*');
if (error) {
console.error('❌ Error creating customer:', error);
setError('Fehler beim Erstellen der Kundendaten. Bitte versuchen Sie es erneut.');
return;
}
if (!data || data.length === 0) {
console.error('❌ Create operation returned no data');
setError('Fehler beim Erstellen der Kundendaten. Bitte versuchen Sie es erneut.');
return;
}
result = data[0]; // Get the first (and only) created record
console.log('✅ Customer created successfully:', result);
setCustomerAction('created');
}
setCurrentStep('success');
} catch (err) {
console.error('Unexpected error saving appointment:', err);
setError('Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
} finally {
setLoading(false);
}
};
const handleBack = () => {
setCurrentStep('form');
setError(null);
};
const handleReset = () => {
setCurrentStep('form');
setAppointmentData(null);
setError(null);
setLoading(false);
setCustomerAction(null);
};
const testDatabaseConnection = async () => {
setLoading(true);
setError(null);
try {
// Test basic connection and get table structure
const { data, error } = await supabase
.from('kunden_projekte')
.select('*')
.limit(1);
if (error) {
console.error('Database connection test failed:', error);
setError(`Datenbank-Verbindung fehlgeschlagen: ${error.message}`);
return;
}
console.log('Database connection successful:', data);
const columns = Object.keys(data[0] || {});
console.log('Table structure:', columns);
setTableStructure(columns);
// Analyze which columns we can use
const availableColumns = {
email: columns.includes('email'),
description: columns.includes('description') || columns.includes('beschreibung'),
termin_datum: columns.includes('termin_datum'),
name: columns.includes('name') || columns.includes('ansprechpartn'),
phone: columns.includes('phone') || columns.includes('telefon'),
company: columns.includes('company') || columns.includes('firma')
};
console.log('Available columns for appointment:', availableColumns);
let statusMessage = '✅ Datenbank-Verbindung erfolgreich!\n\n';
statusMessage += '📋 Verfügbare Spalten:\n';
Object.entries(availableColumns).forEach(([key, available]) => {
statusMessage += `${available ? '✅' : '❌'} ${key}\n`;
});
setError(statusMessage);
} catch (err) {
console.error('Connection test error:', err);
setError('❌ Datenbank-Verbindung fehlgeschlagen');
} finally {
setLoading(false);
}
};
const cleanupDuplicateCustomers = async () => {
setLoading(true);
setError(null);
try {
// Find all customers with duplicate emails
const { data: allCustomers, error } = await supabase
.from('kunden_projekte')
.select('*')
.order('erstellt_am', { ascending: true });
if (error) {
console.error('Error fetching customers:', error);
setError('Fehler beim Laden der Kundendaten.');
return;
}
// Group by email
const emailGroups: { [email: string]: any[] } = {};
allCustomers?.forEach(customer => {
if (!emailGroups[customer.email]) {
emailGroups[customer.email] = [];
}
emailGroups[customer.email].push(customer);
});
// Find duplicates
const duplicates = Object.entries(emailGroups)
.filter(([email, customers]) => customers.length > 1)
.map(([email, customers]) => ({ email, customers }));
console.log('Found duplicate customers:', duplicates);
if (duplicates.length === 0) {
setError('✅ Keine Duplikate gefunden!');
return;
}
let statusMessage = `🔍 ${duplicates.length} E-Mail-Adressen mit Duplikaten gefunden:\n\n`;
duplicates.forEach(({ email, customers }) => {
statusMessage += `📧 ${email}: ${customers.length} Einträge\n`;
customers.forEach((customer, index) => {
statusMessage += ` ${index + 1}. ID: ${customer.id} (${customer.erstellt_am})\n`;
});
statusMessage += '\n';
});
statusMessage += '💡 Tipp: Der neueste Eintrag wird für neue Termine verwendet.';
setError(statusMessage);
} catch (err) {
console.error('Cleanup error:', err);
setError('Fehler beim Bereinigen der Duplikate.');
} finally {
setLoading(false);
}
};
const checkSpecificEmail = async (email: string) => {
console.log('🔍 Checking specific email:', email);
const { data, error } = await supabase
.from('kunden_projekte')
.select('*')
.eq('email', email)
.order('erstellt_am', { ascending: false });
if (error) {
console.error('Error checking email:', error);
return;
}
console.log(`Found ${data?.length || 0} entries for email "${email}":`);
data?.forEach((entry, index) => {
console.log(`${index + 1}. ID: ${entry.id}, Email: "${entry.email}", Created: ${entry.erstellt_am}`);
});
return data;
};
const formatAppointmentDate = (date: Date, time: string) => {
return new Intl.DateTimeFormat('de-DE', {
weekday: 'long',
day: '2-digit',
month: '2-digit',
year: 'numeric'
}).format(date) + ` um ${time} Uhr`;
};
const cleanupIncorrectDates = async () => {
setLoading(true);
setError(null);
try {
console.log('🧹 Cleaning up incorrect appointment dates...');
// Find all entries with the incorrect date "2025-07-21 07:00:00"
const { data: incorrectEntries, error: findError } = await supabase
.from('kunden_projekte')
.select('*')
.eq('termin_datum', '2025-07-21T07:00:00.000Z');
if (findError) {
console.error('Error finding incorrect entries:', findError);
setError('Fehler beim Finden der fehlerhaften Einträge.');
return;
}
console.log(`Found ${incorrectEntries?.length || 0} entries with incorrect date`);
if (incorrectEntries && incorrectEntries.length > 0) {
// Update all incorrect entries to remove the termin_datum
const { error: updateError } = await supabase
.from('kunden_projekte')
.update({ termin_datum: null })
.eq('termin_datum', '2025-07-21T07:00:00.000Z');
if (updateError) {
console.error('Error updating incorrect entries:', updateError);
setError('Fehler beim Bereinigen der fehlerhaften Einträge.');
return;
}
console.log('✅ Successfully cleaned up incorrect appointment dates');
alert(`${incorrectEntries.length} fehlerhafte Termine wurden bereinigt!`);
} else {
console.log('No incorrect entries found');
alert('✅ Keine fehlerhaften Termine gefunden!');
}
} catch (err) {
console.error('Unexpected error cleaning up dates:', err);
setError('Ein unerwarteter Fehler ist aufgetreten.');
} finally {
setLoading(false);
}
};
return (
<div className="w-full max-w-4xl mx-auto">
{/* Production Mode Indicator */}
{productionMode && (
<div className="mb-4 p-3 rounded-xl border-2 border-green-500 bg-green-50">
<p className="text-sm text-green-700">
<strong>Terminbuchung aktiv:</strong> Ihre Termine werden direkt in unserem System gespeichert.
</p>
<div className="mt-2 flex space-x-2">
<Button
onClick={testDatabaseConnection}
disabled={loading}
className="text-xs"
variant="outline"
size="sm"
>
{loading ? 'Teste...' : '🔍 DB-Verbindung testen'}
</Button>
<Button
onClick={cleanupDuplicateCustomers}
disabled={loading}
className="text-xs"
variant="outline"
size="sm"
>
{loading ? 'Prüfe...' : '🔍 Duplikate prüfen'}
</Button>
<Button
onClick={cleanupIncorrectDates}
disabled={loading}
className="text-xs"
variant="outline"
size="sm"
>
{loading ? 'Bereinige...' : '🧹 Fehlerhafte Termine bereinigen'}
</Button>
</div>
</div>
)}
{/* Enhanced Step Indicator */}
<div className="mb-8">
<div className="flex items-center justify-center space-x-2 sm:space-x-4">
{/* Step 1 */}
<div className={`flex flex-col items-center space-y-2 ${
currentStep === 'form' ? 'text-primary' :
currentStep === 'verification' || currentStep === 'success' ? 'text-primary' : 'text-gray-400'
}`}>
<div className={`w-10 h-10 rounded-full flex items-center justify-center transition-all duration-300 ${
currentStep === 'form'
? 'bg-primary text-white shadow-lg scale-110'
: currentStep === 'verification' || currentStep === 'success'
? 'bg-primary text-white shadow-lg'
: 'bg-gray-200 text-gray-500'
}`}>
<Calendar className="w-5 h-5" />
</div>
<span className="text-xs sm:text-sm font-medium text-center">Termin auswählen</span>
</div>
{/* Connector 1 */}
<div className={`w-8 sm:w-12 h-1 rounded-full transition-all duration-300 ${
currentStep === 'verification' || currentStep === 'success'
? 'bg-primary'
: 'bg-gray-200'
}`}></div>
{/* Step 2 */}
<div className={`flex flex-col items-center space-y-2 ${
currentStep === 'verification' || currentStep === 'success'
? 'text-primary'
: 'text-gray-400'
}`}>
<div className={`w-10 h-10 rounded-full flex items-center justify-center transition-all duration-300 ${
currentStep === 'verification'
? 'bg-primary text-white shadow-lg scale-110'
: currentStep === 'success'
? 'bg-primary text-white shadow-lg'
: 'bg-gray-200 text-gray-500'
}`}>
<Mail className="w-5 h-5" />
</div>
<span className="text-xs sm:text-sm font-medium text-center">E-Mail bestätigen</span>
</div>
</div>
</div>
{/* Error Display */}
{error && (
<div className="mb-6 p-4 rounded-xl border-2 border-red-500 bg-red-50">
<pre className="text-red-600 text-sm whitespace-pre-wrap">{error}</pre>
</div>
)}
{/* Step Content */}
{currentStep === 'form' && (
<AppointmentForm
onSubmit={handleFormSubmit}
loading={loading}
/>
)}
{currentStep === 'verification' && appointmentData && (
<EmailVerification
email={appointmentData.email}
onVerificationComplete={handleVerificationComplete}
onBack={handleBack}
/>
)}
{currentStep === 'success' && appointmentData && (
<div className="w-full max-w-md mx-auto">
<div
className="p-6 sm:p-8 rounded-3xl shadow-lg backdrop-blur-sm text-center"
style={{ backgroundColor: `${colors.background}F0` }}
>
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-6"
style={{ backgroundColor: `${colors.primary}20` }}>
<CheckCircle className="w-8 h-8" style={{ color: colors.primary }} />
</div>
<h3 className="text-2xl font-bold mb-4" style={{ color: colors.primary }}>
Termin bestätigt!
</h3>
<div className="space-y-4 mb-6">
<p className="text-sm" style={{ color: colors.secondary }}>
{customerAction === 'created'
? 'Vielen Dank für Ihre erste Terminanfrage! Ihr Kundenprofil wurde erstellt.'
: 'Vielen Dank für Ihre weitere Terminanfrage! Ihr bestehendes Kundenprofil wurde aktualisiert.'
}
</p>
<div className="p-4 rounded-xl border-2"
style={{
backgroundColor: `${colors.primary}10`,
borderColor: colors.primary
}}>
<p className="font-semibold mb-2" style={{ color: colors.primary }}>
Ihr Termin:
</p>
<p className="text-sm" style={{ color: colors.secondary }}>
{formatAppointmentDate(appointmentData.termin_datum!, appointmentData.termin_time!)}
</p>
</div>
<div className="p-4 rounded-xl border-2"
style={{
backgroundColor: `${colors.primary}10`,
borderColor: colors.primary
}}>
<p className="font-semibold mb-2" style={{ color: colors.primary }}>
Kontaktdaten:
</p>
<p className="text-sm" style={{ color: colors.secondary }}>
{appointmentData.name}<br />
{appointmentData.firma}<br />
{appointmentData.email}
</p>
</div>
</div>
<p className="text-xs mb-6" style={{ color: colors.secondary }}>
Vielen Dank für Ihre Terminanfrage! Wir werden uns in Kürze bei Ihnen melden, um den Termin zu bestätigen.
</p>
<Button
onClick={handleReset}
className="w-full rounded-xl"
style={{
backgroundColor: colors.primary,
color: colors.background
}}
>
Neuen Termin buchen
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,307 @@
"use client";
import React, { useState, useEffect } from 'react';
import { Button } from "@/components/ui/button";
import { Calendar, Clock, CheckCircle, X } from "lucide-react";
import { colors } from '@/lib/colors';
import { supabase } from '@/lib/supabaseClient';
interface AppointmentSlot {
date: Date;
time: string;
available: boolean;
booked: boolean;
}
interface AppointmentCalendarProps {
onSlotSelect: (date: Date, time: string) => void;
selectedSlot?: { date: Date; time: string } | null;
}
export default function AppointmentCalendar({ onSlotSelect, selectedSlot }: AppointmentCalendarProps) {
const [availableSlots, setAvailableSlots] = useState<AppointmentSlot[]>([]);
const [loading, setLoading] = useState(false);
// Generate available time slots (2-hour slots, Mo-Fr 9-17 Uhr)
const generateTimeSlots = () => {
const slots = [];
for (let hour = 9; hour <= 15; hour += 2) {
slots.push(`${hour.toString().padStart(2, '0')}:00`);
}
return slots;
};
// Generate available dates for the next 4 weeks
const generateAvailableDates = () => {
const dates = [];
const today = new Date();
let currentDate = new Date(today);
// Start from next Monday if today is weekend
const dayOfWeek = currentDate.getDay();
if (dayOfWeek === 0) { // Sunday
currentDate.setDate(currentDate.getDate() + 1);
} else if (dayOfWeek === 6) { // Saturday
currentDate.setDate(currentDate.getDate() + 2);
}
for (let i = 0; i < 28; i++) {
const date = new Date(currentDate);
date.setDate(date.getDate() + i);
// Only include weekdays (Monday = 1, Friday = 5)
if (date.getDay() >= 1 && date.getDay() <= 5) {
dates.push(date);
}
}
return dates;
};
// Generate available slots (with Supabase integration)
const generateAvailableSlots = async () => {
const dates = generateAvailableDates();
const timeSlots = generateTimeSlots();
const slots: AppointmentSlot[] = [];
try {
// Get booked appointments from Supabase - look for termin_datum column
const { data: bookedAppointments, error } = await supabase
.from('kunden_projekte')
.select('termin_datum')
.not('termin_datum', 'is', null);
if (error) {
console.error('Error fetching booked appointments:', error);
console.log('Falling back to all available slots');
// Fallback to all available
dates.forEach(date => {
timeSlots.forEach(time => {
slots.push({
date: new Date(date),
time,
available: true,
booked: false
});
});
});
} else {
console.log('Found booked appointments:', bookedAppointments);
console.log('Number of booked appointments:', bookedAppointments?.length || 0);
// Create a set of booked times for quick lookup
const bookedTimes = new Set();
bookedAppointments?.forEach(appointment => {
if (appointment.termin_datum) {
const appointmentDate = new Date(appointment.termin_datum);
console.log('Processing booked appointment:', appointment.termin_datum);
console.log('Appointment date object:', appointmentDate);
console.log('Appointment hours:', appointmentDate.getHours());
console.log('Appointment local time:', appointmentDate.toLocaleString('de-DE'));
// Use local time consistently - don't convert to UTC
const year = appointmentDate.getFullYear();
const month = appointmentDate.getMonth();
const day = appointmentDate.getDate();
const hours = appointmentDate.getHours();
// Create time string using local time components
const dateString = `${year}-${(month + 1).toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
const hourString = hours.toString().padStart(2, '0') + ':00';
const timeString = `${dateString}T${hourString}:00:00`;
bookedTimes.add(timeString);
console.log('Booked appointment found (local time):', timeString);
}
});
console.log('All booked time strings:', Array.from(bookedTimes));
// Generate slots with availability check
dates.forEach(date => {
timeSlots.forEach(time => {
const [hours] = time.split(':').map(Number);
// Create slot datetime in local time
const slotDateTime = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours, 0, 0, 0);
// Create time string using local time components (same as booked appointments)
const year = slotDateTime.getFullYear();
const month = slotDateTime.getMonth();
const day = slotDateTime.getDate();
const slotHours = slotDateTime.getHours();
const dateString = `${year}-${(month + 1).toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
const hourString = slotHours.toString().padStart(2, '0') + ':00';
const timeString = `${dateString}T${hourString}:00:00`;
const isBooked = bookedTimes.has(timeString);
console.log(`Slot ${dateString} ${time}:`, {
slotDateTime: slotDateTime.toISOString(),
timeString,
isBooked,
bookedTimes: Array.from(bookedTimes)
});
if (isBooked) {
console.log('Slot is booked:', timeString);
}
slots.push({
date: new Date(date),
time,
available: !isBooked,
booked: isBooked
});
});
});
}
} catch (err) {
console.error('Error checking availability:', err);
// Fallback to all available
dates.forEach(date => {
timeSlots.forEach(time => {
slots.push({
date: new Date(date),
time,
available: true,
booked: false
});
});
});
}
setAvailableSlots(slots);
};
useEffect(() => {
setLoading(true);
// Simulate loading time
setTimeout(() => {
generateAvailableSlots();
setLoading(false);
}, 500);
}, []);
// Group slots by date
const groupedSlots = availableSlots.reduce((groups, slot) => {
const dateKey = slot.date.toISOString().split('T')[0];
if (!groups[dateKey]) {
groups[dateKey] = [];
}
groups[dateKey].push(slot);
return groups;
}, {} as Record<string, AppointmentSlot[]>);
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit'
}).format(date);
};
const isSlotSelected = (slot: AppointmentSlot) => {
if (!selectedSlot) return false;
return selectedSlot.date.toISOString().split('T')[0] === slot.date.toISOString().split('T')[0] &&
selectedSlot.time === slot.time;
};
return (
<div className="w-full max-w-md mx-auto">
<div
className="p-6 rounded-2xl shadow-lg backdrop-blur-sm"
style={{ backgroundColor: `${colors.background}F0` }}
>
<div className="flex items-center space-x-3 mb-6">
<Calendar className="w-6 h-6" style={{ color: colors.primary }} />
<h3 className="text-xl font-bold" style={{ color: colors.primary }}>
Termin auswählen
</h3>
</div>
{loading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 mx-auto mb-4"
style={{ borderColor: colors.primary }}></div>
<p style={{ color: colors.secondary }}>Lade verfügbare Termine...</p>
</div>
) : (
<div className="space-y-4">
{Object.entries(groupedSlots).slice(0, 3).map(([dateKey, slots]) => {
const date = new Date(dateKey);
// Show all slots, not just available ones
const allSlotsForDate = slots;
if (allSlotsForDate.length === 0) return null;
return (
<div key={dateKey} className="border rounded-xl p-4"
style={{ borderColor: colors.tertiary }}>
<h4 className="font-semibold mb-3" style={{ color: colors.primary }}>
{formatDate(date)}
</h4>
<div className="grid grid-cols-2 gap-2">
{allSlotsForDate.map((slot, index) => (
<Button
key={index}
variant={isSlotSelected(slot) ? "default" : "outline"}
size="sm"
className="flex items-center space-x-2"
onClick={() => onSlotSelect(slot.date, slot.time)}
disabled={slot.booked || !slot.available}
style={{
backgroundColor: isSlotSelected(slot) ? colors.primary :
slot.booked ? '#f3f4f6' : 'transparent',
color: isSlotSelected(slot) ? colors.background :
slot.booked ? '#9ca3af' : colors.primary,
borderColor: slot.booked ? '#d1d5db' : colors.tertiary
}}
>
<Clock className="w-4 h-4" />
<span>{slot.time}</span>
{isSlotSelected(slot) && <CheckCircle className="w-4 h-4" />}
{slot.booked && <span className="text-xs">(Gebucht)</span>}
</Button>
))}
</div>
</div>
);
})}
</div>
)}
{selectedSlot && (
<div className="mt-6 p-4 rounded-xl border-2"
style={{
backgroundColor: `${colors.primary}20`,
borderColor: colors.primary
}}>
<div className="flex items-center justify-between">
<div>
<p className="font-semibold" style={{ color: colors.primary }}>
Ausgewählter Termin:
</p>
<p className="text-sm" style={{ color: colors.secondary }}>
{formatDate(selectedSlot.date)} um {selectedSlot.time} Uhr
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onSlotSelect(new Date(), '')}
style={{ color: colors.primary }}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
)}
<div className="mt-4 text-xs text-center" style={{ color: colors.secondary }}>
<p> Termine sind 1 Stunden lang</p>
<p> Mo-Fr 9:00-17:00 Uhr</p>
<p> Nur verfügbare Termine werden angezeigt</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,329 @@
"use client";
import React, { useState } from 'react';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { CheckCircle, Mail, Calendar, User, Phone, Building, MessageSquare } from "lucide-react";
import { colors } from '@/lib/colors';
import AppointmentCalendar from './AppointmentCalendar';
interface AppointmentFormData {
name: string;
telefon: string;
firma: string;
email: string;
beschreibung: string;
}
interface AppointmentFormProps {
onSubmit: (data: AppointmentFormData & { termin_datum?: Date; termin_time?: string }) => void;
loading?: boolean;
}
export default function AppointmentForm({ onSubmit, loading = false }: AppointmentFormProps) {
const [formData, setFormData] = useState<AppointmentFormData>({
name: '',
telefon: '',
firma: '',
email: '',
beschreibung: ''
});
const [selectedSlot, setSelectedSlot] = useState<{ date: Date; time: string } | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const handleInputChange = (field: keyof AppointmentFormData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
const handleSlotSelect = (date: Date, time: string) => {
console.log('=== SLOT SELECTION DEBUG ===');
console.log('Selected date:', date);
console.log('Selected time:', time);
console.log('Date type:', typeof date);
console.log('Date instanceof Date:', date instanceof Date);
console.log('Date.toISOString():', date.toISOString());
if (time === '') {
setSelectedSlot(null);
console.log('Slot deselected');
} else {
setSelectedSlot({ date, time });
console.log('Slot selected:', { date, time });
}
};
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = 'Name ist erforderlich';
}
if (!formData.telefon.trim()) {
newErrors.telefon = 'Telefonnummer ist erforderlich';
}
if (!formData.firma.trim()) {
newErrors.firma = 'Unternehmen ist erforderlich';
}
if (!formData.email.trim()) {
newErrors.email = 'E-Mail ist erforderlich';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Bitte geben Sie eine gültige E-Mail-Adresse ein';
}
if (!formData.beschreibung.trim()) {
newErrors.beschreibung = 'Projektbeschreibung ist erforderlich';
}
if (!selectedSlot) {
newErrors.termin_datum = 'Bitte wählen Sie einen Termin aus';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (validateForm()) {
console.log('=== FORM SUBMIT DEBUG ===');
console.log('Form data:', formData);
console.log('Selected slot:', selectedSlot);
console.log('Selected slot date:', selectedSlot?.date);
console.log('Selected slot time:', selectedSlot?.time);
const submitData = {
...formData,
termin_datum: selectedSlot?.date,
termin_time: selectedSlot?.time
};
console.log('Submit data:', submitData);
console.log('Submit termin_datum:', submitData.termin_datum);
console.log('Submit termin_time:', submitData.termin_time);
onSubmit(submitData);
}
};
return (
<div className="w-full max-w-4xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Appointment Form */}
<div
className="p-6 sm:p-8 rounded-3xl shadow-lg backdrop-blur-sm"
style={{ backgroundColor: `${colors.background}F0` }}
>
<div className="flex items-center space-x-3 mb-6">
<Calendar className="w-6 h-6" style={{ color: colors.primary }} />
<h3 className="text-xl font-bold" style={{ color: colors.primary }}>
Termin anfragen
</h3>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium mb-2 flex items-center space-x-2"
style={{ color: colors.primary }}>
<User className="w-4 h-4" />
<span>01 Wie ist dein Name?</span>
</label>
<Input
type="text"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
className={`w-full p-3 rounded-xl border-2 focus:outline-none focus:ring-2 ${
errors.name ? 'border-red-500' : ''
}`}
style={{
borderColor: errors.name ? '#ef4444' : colors.tertiary,
backgroundColor: colors.background,
color: colors.primary
}}
placeholder="Dein vollständiger Name"
/>
{errors.name && (
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-2 flex items-center space-x-2"
style={{ color: colors.primary }}>
<Phone className="w-4 h-4" />
<span>02 Deine Telefonnummer</span>
</label>
<Input
type="tel"
value={formData.telefon}
onChange={(e) => handleInputChange('telefon', e.target.value)}
className={`w-full p-3 rounded-xl border-2 focus:outline-none focus:ring-2 ${
errors.telefon ? 'border-red-500' : ''
}`}
style={{
borderColor: errors.telefon ? '#ef4444' : colors.tertiary,
backgroundColor: colors.background,
color: colors.primary
}}
placeholder="+49 123 456789"
/>
{errors.telefon && (
<p className="text-red-500 text-sm mt-1">{errors.telefon}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-2 flex items-center space-x-2"
style={{ color: colors.primary }}>
<Building className="w-4 h-4" />
<span>03 Dein Unternehmen</span>
</label>
<Input
type="text"
value={formData.firma}
onChange={(e) => handleInputChange('firma', e.target.value)}
className={`w-full p-3 rounded-xl border-2 focus:outline-none focus:ring-2 ${
errors.firma ? 'border-red-500' : ''
}`}
style={{
borderColor: errors.firma ? '#ef4444' : colors.tertiary,
backgroundColor: colors.background,
color: colors.primary
}}
placeholder="Name deines Unternehmens"
/>
{errors.firma && (
<p className="text-red-500 text-sm mt-1">{errors.firma}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-2 flex items-center space-x-2"
style={{ color: colors.primary }}>
<Mail className="w-4 h-4" />
<span>04 Deine E-Mail-Adresse</span>
</label>
<Input
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
className={`w-full p-3 rounded-xl border-2 focus:outline-none focus:ring-2 ${
errors.email ? 'border-red-500' : ''
}`}
style={{
borderColor: errors.email ? '#ef4444' : colors.tertiary,
backgroundColor: colors.background,
color: colors.primary
}}
placeholder="deine@email.de"
/>
{errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-2 flex items-center space-x-2"
style={{ color: colors.primary }}>
<MessageSquare className="w-4 h-4" />
<span>05 Erzähl uns kurz von deinem Vorhaben</span>
</label>
<Textarea
value={formData.beschreibung}
onChange={(e) => handleInputChange('beschreibung', e.target.value)}
rows={4}
className={`w-full p-3 rounded-xl border-2 focus:outline-none focus:ring-2 ${
errors.beschreibung ? 'border-red-500' : ''
}`}
style={{
borderColor: errors.beschreibung ? '#ef4444' : colors.tertiary,
backgroundColor: colors.background,
color: colors.primary
}}
placeholder="Beschreibe dein Projekt, Ziele, Wünsche..."
/>
{errors.beschreibung && (
<p className="text-red-500 text-sm mt-1">{errors.beschreibung}</p>
)}
</div>
{errors.termin_datum && (
<div className="p-3 rounded-xl border-2 border-red-500 bg-red-50">
<p className="text-red-500 text-sm">{errors.termin_datum}</p>
</div>
)}
<Button
type="submit"
disabled={loading}
className="w-full py-3 rounded-xl text-lg font-semibold flex items-center justify-center space-x-2"
style={{
backgroundColor: colors.primary,
color: colors.background
}}
>
{loading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
<span>Wird verarbeitet...</span>
</>
) : (
<>
<CheckCircle className="w-5 h-5" />
<span>Termin anfragen</span>
</>
)}
</Button>
</form>
</div>
{/* Calendar Component */}
<div className="flex flex-col space-y-6">
<AppointmentCalendar
onSlotSelect={handleSlotSelect}
selectedSlot={selectedSlot}
/>
{/* Alternative Contact */}
<div
className="p-6 rounded-2xl backdrop-blur-sm"
style={{ backgroundColor: `${colors.primary}20` }}
>
<h3 className="text-xl font-bold mb-4 flex items-center space-x-2"
style={{ color: colors.background }}>
<Phone className="w-5 h-5" />
<span>Oder direkt anrufen</span>
</h3>
<div className="space-y-3 text-sm" style={{ color: colors.background }}>
<div className="flex items-center">
<Phone className="w-4 h-4 mr-3" />
<span>+49 170 4969375</span>
</div>
<div className="flex items-center">
<Mail className="w-4 h-4 mr-3" />
<span>support@webklar.com</span>
</div>
<div className="flex items-center">
<Building className="w-4 h-4 mr-3" />
<span>Kaiserslautern, Deutschland</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
"use client";
import { useEffect, useState } from 'react';
import { CheckCircle, Calendar, Mail, Clock, User } from "lucide-react";
import { colors } from '@/lib/colors';
import { useAuth } from '@/hooks/useAuth';
export default function AppointmentStatus() {
const { user, loading } = useAuth();
const [showStatus, setShowStatus] = useState(false);
useEffect(() => {
// Show status if user is authenticated and has appointment_booking metadata
if (user && !loading) {
const hasAppointmentBooking = user.user_metadata?.appointment_booking;
if (hasAppointmentBooking) {
setShowStatus(true);
// Hide status after 10 seconds
const timer = setTimeout(() => {
setShowStatus(false);
}, 10000);
return () => clearTimeout(timer);
}
}
}, [user, loading]);
if (!showStatus) return null;
return (
<div className="fixed top-4 right-4 z-50 max-w-sm">
<div
className="p-6 rounded-2xl shadow-lg backdrop-blur-sm border-2"
style={{
backgroundColor: `${colors.background}F0`,
borderColor: colors.primary
}}
>
<div className="flex items-center space-x-3 mb-4">
<CheckCircle className="w-6 h-6" style={{ color: colors.primary }} />
<h3 className="font-semibold" style={{ color: colors.primary }}>
Termin bestätigt!
</h3>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-3">
<User className="w-4 h-4" style={{ color: colors.secondary }} />
<span className="text-sm" style={{ color: colors.secondary }}>
<strong>Status:</strong> Angemeldet
</span>
</div>
<div className="flex items-center space-x-3">
<Mail className="w-4 h-4" style={{ color: colors.secondary }} />
<span className="text-sm" style={{ color: colors.secondary }}>
<strong>E-Mail:</strong> {user?.email}
</span>
</div>
<div className="flex items-center space-x-3">
<Calendar className="w-4 h-4" style={{ color: colors.secondary }} />
<span className="text-sm" style={{ color: colors.secondary }}>
<strong>Termin:</strong> Gespeichert
</span>
</div>
<div className="flex items-center space-x-3">
<Clock className="w-4 h-4" style={{ color: colors.secondary }} />
<span className="text-sm" style={{ color: colors.secondary }}>
<strong>Zeit:</strong> {new Date().toLocaleTimeString('de-DE')}
</span>
</div>
</div>
<button
onClick={() => setShowStatus(false)}
className="mt-4 w-full p-2 rounded-xl text-sm transition-all duration-200 hover:scale-105"
style={{
backgroundColor: colors.primary,
color: colors.background
}}
>
Verstanden
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,280 @@
"use client";
import { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabase';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useRouter } from 'next/navigation';
import { useToast } from '@/hooks/use-toast';
interface Customer {
id: string;
name: string;
email: string;
telefon?: string;
termin_datum?: string;
projekt_beschreibung?: string;
created_at: string;
updated_at: string;
has_appointment: boolean;
appointment_status: 'pending' | 'running' | 'completed';
started_by?: string;
started_at?: string;
}
interface CustomerQuestionnaireProps {
customerId: string;
onSave?: () => void;
}
export default function CustomerQuestionnaire({ customerId, onSave }: CustomerQuestionnaireProps) {
const [customer, setCustomer] = useState<Customer | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({
name: '',
email: '',
telefon: '',
termin_datum: '',
projekt_beschreibung: ''
});
const router = useRouter();
const { toast } = useToast();
useEffect(() => {
fetchCustomer();
}, [customerId]);
const fetchCustomer = async () => {
try {
console.log('🔍 Fetching customer:', customerId);
const { data, error } = await supabase
.from('kunden_projekte')
.select('*')
.eq('id', customerId)
.single();
if (error) {
console.error('❌ Error fetching customer:', error);
toast({
title: "Fehler",
description: "Kunde konnte nicht geladen werden.",
variant: "destructive",
});
return;
}
console.log('✅ Customer fetched:', data);
setCustomer(data);
setFormData({
name: data.name || '',
email: data.email || '',
telefon: data.telefon || '',
termin_datum: data.termin_datum ? new Date(data.termin_datum).toISOString().slice(0, 16) : '',
projekt_beschreibung: data.projekt_beschreibung || ''
});
} catch (error) {
console.error('❌ Error:', error);
toast({
title: "Fehler",
description: "Ein unerwarteter Fehler ist aufgetreten.",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
console.log('💾 Saving customer data:', formData);
const updateData = {
name: formData.name,
email: formData.email,
telefon: formData.telefon || null,
termin_datum: formData.termin_datum ? new Date(formData.termin_datum).toISOString() : null,
projekt_beschreibung: formData.projekt_beschreibung || null,
has_appointment: !!formData.termin_datum,
updated_at: new Date().toISOString()
};
const { error } = await supabase
.from('kunden_projekte')
.update(updateData)
.eq('id', customerId);
if (error) {
console.error('❌ Error updating customer:', error);
toast({
title: "Fehler",
description: "Daten konnten nicht gespeichert werden.",
variant: "destructive",
});
return;
}
console.log('✅ Customer updated successfully');
toast({
title: "Erfolg",
description: "Kundendaten wurden erfolgreich gespeichert.",
});
if (onSave) {
onSave();
} else {
router.push('/kunden-projekte');
}
} catch (error) {
console.error('❌ Error:', error);
toast({
title: "Fehler",
description: "Ein unerwarteter Fehler ist aufgetreten.",
variant: "destructive",
});
} finally {
setSaving(false);
}
};
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({
...prev,
[field]: value
}));
};
if (loading) {
return (
<div className="container mx-auto p-6">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Kunden-Daten bearbeiten</h1>
<p>Lade Kundendaten...</p>
</div>
</div>
);
}
if (!customer) {
return (
<div className="container mx-auto p-6">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4">Kunde nicht gefunden</h1>
<p>Der angeforderte Kunde konnte nicht gefunden werden.</p>
<Button
onClick={() => router.push('/kunden-projekte')}
className="mt-4"
>
Zurück zur Übersicht
</Button>
</div>
</div>
);
}
return (
<div className="container mx-auto p-6">
<div className="max-w-2xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold mb-2">Kunden-Daten bearbeiten</h1>
<p className="text-gray-600">
Bearbeiten Sie die Daten für {customer.name}
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Kundendaten</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
required
placeholder="Vollständiger Name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-Mail *</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
required
placeholder="email@beispiel.de"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="telefon">Telefon</Label>
<Input
id="telefon"
type="tel"
value={formData.telefon}
onChange={(e) => handleInputChange('telefon', e.target.value)}
placeholder="+49 123 456789"
/>
</div>
<div className="space-y-2">
<Label htmlFor="termin_datum">Termin</Label>
<Input
id="termin_datum"
type="datetime-local"
value={formData.termin_datum}
onChange={(e) => handleInputChange('termin_datum', e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="projekt_beschreibung">Projektbeschreibung</Label>
<Textarea
id="projekt_beschreibung"
value={formData.projekt_beschreibung}
onChange={(e) => handleInputChange('projekt_beschreibung', e.target.value)}
placeholder="Beschreiben Sie das Projekt oder die Anforderungen..."
rows={4}
/>
</div>
<div className="flex gap-4 pt-4">
<Button
type="submit"
disabled={saving}
className="flex-1"
>
{saving ? 'Speichern...' : 'Daten speichern'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => router.push('/kunden-projekte')}
disabled={saving}
>
Abbrechen
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,646 @@
'use client';
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { supabase } from '@/lib/supabaseClient';
const steps = [
{ title: 'Unternehmen & Zielgruppe' },
{ title: 'Ziel der Website' },
{ title: 'Inhalte & Struktur' },
{ title: 'Design & Stil' },
{ title: 'Technische Funktionen' },
{ title: 'Pflege & Support' },
{ title: 'Projektzeit & Ansprechpartner' },
{ title: 'Kommunikation & Zusammenarbeit' },
];
const webseitenZieleOptions = [
'Neukunden',
'Vertrauen',
'Produkte erklären',
'Online verkaufen',
'Terminbuchung',
'anderes',
];
const geplanteSeitenOptions = [
'Start',
'Über uns',
'Leistungen',
'Referenzen',
'Kontakt',
'Impressum',
'Datenschutz',
'Blog',
'Karriere',
'FAQ',
'Sonstiges',
];
const stilrichtungOptions = [
'modern',
'klassisch',
'verspielt',
'minimalistisch',
'technisch',
'kreativ',
'seriös',
'freundlich',
'exklusiv',
'andere',
];
const funktionenOptions = [
'Kontaktformular',
'Shop',
'Terminbuchung',
'Newsletter',
'Blog',
'Kalender',
'Benutzerverwaltung',
'andere',
];
const drittanbieterOptions = [
'Stripe',
'Supabase',
'Google Ads',
'Google Analytics',
'Mailchimp',
'andere',
];
const kommunikationswegOptions = [
'E-Mail',
'Telefon',
'WhatsApp',
'Videocall',
];
// Zod schema for all steps (fields optional, step validation below)
const fullSchema = z.object({
// Step 1
firma: z.string().min(1, 'Pflichtfeld'),
beschreibung: z.string().min(1, 'Pflichtfeld'),
zielgruppe: z.string().min(1, 'Pflichtfeld'),
website_vorhanden: z.boolean(),
stilvorbilder: z.string().optional(),
was_gefaellt_gefaellt_nicht: z.string().optional(),
// Step 2
ziel_der_website: z.array(z.string()).min(1, 'Bitte mindestens ein Ziel auswählen'),
// Step 3
seiten_geplant: z.array(z.string()).min(1, 'Mindestens eine Seite angeben'),
texte_bilder_vorhanden: z.boolean(),
fokus_inhalte: z.string().optional(),
// Step 4
logo_farben_vorhanden: z.boolean(),
design_wunsch: z.string().min(1, 'Bitte Stilrichtung wählen'),
beispiellinks: z.string().optional(),
// Step 5
features_gewuenscht: z.array(z.string()).min(1, 'Mindestens eine Funktion wählen'),
drittanbieter: z.array(z.string()).optional(),
// Step 6
selbst_pflegen: z.boolean(),
laufende_betreuung: z.boolean(),
// Step 7
deadline: z.string().min(1, 'Bitte Datum wählen'),
projekt_verantwortlich: z.string().min(1, 'Pflichtfeld'),
budget: z.string().optional(),
// Step 8
kommunikationsweg: z.array(z.string()).min(1, 'Mindestens einen Weg wählen'),
feedback_geschwindigkeit: z.string().optional(),
});
type FullFormType = z.infer<typeof fullSchema>;
const stepFieldMap: Record<number, (keyof FullFormType)[]> = {
0: [
'firma',
'beschreibung',
'zielgruppe',
'website_vorhanden',
'stilvorbilder',
'was_gefaellt_gefaellt_nicht',
],
1: ['ziel_der_website'],
2: ['seiten_geplant', 'texte_bilder_vorhanden', 'fokus_inhalte'],
3: ['logo_farben_vorhanden', 'design_wunsch', 'beispiellinks'],
4: ['features_gewuenscht', 'drittanbieter'],
5: ['selbst_pflegen', 'laufende_betreuung'],
6: ['deadline', 'projekt_verantwortlich', 'budget'],
7: ['kommunikationsweg', 'feedback_geschwindigkeit'],
};
export default function CustomerStepperForm() {
const [currentStep, setCurrentStep] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitSuccess, setSubmitSuccess] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const methods = useForm<FullFormType>({
resolver: zodResolver(fullSchema),
defaultValues: {
firma: '',
beschreibung: '',
zielgruppe: '',
website_vorhanden: false,
stilvorbilder: '',
was_gefaellt_gefaellt_nicht: '',
ziel_der_website: [],
seiten_geplant: [],
texte_bilder_vorhanden: false,
fokus_inhalte: '',
logo_farben_vorhanden: false,
design_wunsch: '',
beispiellinks: '',
features_gewuenscht: [],
drittanbieter: [],
selbst_pflegen: false,
laufende_betreuung: false,
deadline: '',
projekt_verantwortlich: '',
budget: '',
kommunikationsweg: [],
feedback_geschwindigkeit: '',
},
mode: 'onChange',
});
async function nextStep() {
// Validate only fields for this step
const fields = stepFieldMap[currentStep];
const valid = await methods.trigger(fields as any);
if (valid) setCurrentStep((s) => Math.min(s + 1, steps.length - 1));
}
function prevStep() {
setCurrentStep((s) => Math.max(s - 1, 0));
}
async function handleSubmitAll() {
setIsSubmitting(true);
setSubmitError(null);
try {
const data = methods.getValues();
console.log('Form data:', data);
// Convert arrays to comma-separated strings for Supabase
const processedData = {
...data,
// Convert checkbox arrays to comma-separated strings
ziel_der_website: Array.isArray(data.ziel_der_website)
? data.ziel_der_website.join(', ')
: data.ziel_der_website,
seiten_geplant: Array.isArray(data.seiten_geplant)
? data.seiten_geplant.join(', ')
: data.seiten_geplant,
features_gewuenscht: Array.isArray(data.features_gewuenscht)
? data.features_gewuenscht.join(', ')
: data.features_gewuenscht,
drittanbieter: Array.isArray(data.drittanbieter)
? data.drittanbieter.join(', ')
: data.drittanbieter,
kommunikationsweg: Array.isArray(data.kommunikationsweg)
? data.kommunikationsweg.join(', ')
: data.kommunikationsweg,
};
console.log('Processed data for Supabase:', processedData);
const { error } = await supabase.from('kunden_projekte').insert([processedData]);
if (error) {
console.error('Supabase error:', error);
setSubmitError('Fehler beim Speichern: ' + error.message);
} else {
setSubmitSuccess(true);
}
} catch (e: any) {
console.error('Unexpected error:', e);
setSubmitError('Unbekannter Fehler: ' + (e.message || e.toString()));
} finally {
setIsSubmitting(false);
}
}
return (
<Form {...methods}>
{/* Stepper Progress */}
<div className="mb-8">
{/* Desktop Stepper - Horizontal */}
<div className="hidden md:flex items-center justify-between">
{steps.map((step, idx) => (
<div key={step.title} className="flex flex-col items-center" style={{ width: `${100 / steps.length}%` }}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-white mb-1 ${idx === currentStep ? 'bg-blue-600' : 'bg-gray-300'}`}>{idx + 1}</div>
<span className={`text-xs text-center ${idx === currentStep ? 'font-semibold text-blue-700' : 'text-gray-500'}`}>{step.title}</span>
{idx < steps.length - 1 && <div className="h-1 w-full bg-gray-200 mt-2" />}
</div>
))}
</div>
{/* Mobile Stepper - Vertical */}
<div className="md:hidden">
<div className="flex items-center justify-center mb-4">
<div className="text-center">
<div className={`w-12 h-12 rounded-full flex items-center justify-center font-bold text-white mb-2 mx-auto ${currentStep === 0 ? 'bg-blue-600' : 'bg-gray-300'}`}>
{currentStep + 1}
</div>
<span className={`text-sm font-medium ${currentStep === 0 ? 'text-blue-700' : 'text-gray-500'}`}>
{steps[currentStep].title}
</span>
</div>
</div>
{/* Mobile Progress Bar */}
<div className="w-full bg-gray-200 rounded-full h-2 mb-4">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${((currentStep + 1) / steps.length) * 100}%` }}
></div>
</div>
{/* Mobile Step Indicator */}
<div className="flex justify-center space-x-1">
{steps.map((_, idx) => (
<div
key={idx}
className={`w-2 h-2 rounded-full transition-all duration-300 ${
idx <= currentStep ? 'bg-blue-600' : 'bg-gray-300'
}`}
/>
))}
</div>
</div>
</div>
{/* Step Content */}
<div className="mb-8">
{currentStep === 0 && (
<div className="space-y-4">
<FormField name="firma" render={({ field }) => (
<FormItem>
<FormLabel className="text-sm md:text-base">Unternehmensname *</FormLabel>
<FormControl>
<Input {...field} placeholder="z.B. Webklar GmbH" className="text-sm md:text-base py-3 md:py-2" />
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField name="beschreibung" render={({ field }) => (
<FormItem>
<FormLabel>Kurzbeschreibung *</FormLabel>
<FormControl>
<Textarea {...field} placeholder="Was macht das Unternehmen?" />
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField name="zielgruppe" render={({ field }) => (
<FormItem>
<FormLabel>Zielgruppe *</FormLabel>
<FormControl>
<Input {...field} placeholder="Wer soll angesprochen werden?" />
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField name="website_vorhanden" render={({ field }) => (
<FormItem>
<FormLabel>Bestehende Website?</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormDescription>Gibt es bereits eine Website?</FormDescription>
</FormItem>
)} />
{methods.watch('website_vorhanden') && (
<FormField name="stilvorbilder" render={({ field }) => (
<FormItem>
<FormLabel>Link zur bestehenden Website</FormLabel>
<FormControl>
<Input {...field} placeholder="https://..." />
</FormControl>
<FormMessage />
</FormItem>
)} />
)}
<FormField name="was_gefaellt_gefaellt_nicht" render={({ field }) => (
<FormItem>
<FormLabel>Was gefällt/gefällt nicht an der alten Website?</FormLabel>
<FormControl>
<Textarea {...field} placeholder="Stärken und Schwächen der aktuellen Seite" />
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
)}
{currentStep === 1 && (
<div className="space-y-4">
<FormField name="ziel_der_website" render={({ field }) => (
<FormItem>
<FormLabel>Ziele der Website *</FormLabel>
<div className="grid grid-cols-1 sm:grid-cols-2 md:flex md:flex-wrap gap-3">
{webseitenZieleOptions.map((ziel) => (
<label key={ziel} className="flex items-center gap-2 cursor-pointer p-2 rounded-lg hover:bg-gray-50 transition-colors">
<input
type="checkbox"
value={ziel}
checked={field.value?.includes(ziel)}
onChange={(e) => {
if (e.target.checked) {
field.onChange([...(field.value || []), ziel]);
} else {
field.onChange((field.value || []).filter((v: string) => v !== ziel));
}
}}
className="accent-blue-600 w-4 h-4"
/>
<span className="text-sm md:text-base">{ziel}</span>
</label>
))}
</div>
<FormMessage />
</FormItem>
)} />
</div>
)}
{currentStep === 2 && (
<div className="space-y-4">
<FormField name="seiten_geplant" render={({ field }) => (
<FormItem>
<FormLabel>Geplante Seiten *</FormLabel>
<div className="flex flex-wrap gap-3">
{geplanteSeitenOptions.map((seite) => (
<label key={seite} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
value={seite}
checked={field.value?.includes(seite)}
onChange={(e) => {
if (e.target.checked) {
field.onChange([...(field.value || []), seite]);
} else {
field.onChange((field.value || []).filter((v: string) => v !== seite));
}
}}
className="accent-blue-600"
/>
<span>{seite}</span>
</label>
))}
</div>
<FormMessage />
</FormItem>
)} />
<FormField name="texte_bilder_vorhanden" render={({ field }) => (
<FormItem>
<FormLabel>Texte/Bilder vorhanden?</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField name="fokus_inhalte" render={({ field }) => (
<FormItem>
<FormLabel>Fokus-Inhalte (optional)</FormLabel>
<FormControl>
<Textarea {...field} placeholder="Wichtige Schwerpunkte" />
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
)}
{currentStep === 3 && (
<div className="space-y-4">
<FormField name="logo_farben_vorhanden" render={({ field }) => (
<FormItem>
<FormLabel>Logo/Farben vorhanden?</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField name="design_wunsch" render={({ field }) => (
<FormItem>
<FormLabel>Design-Wunsch *</FormLabel>
<select {...field} className="w-full border rounded px-2 py-1">
<option value="">Bitte wählen</option>
{stilrichtungOptions.map((stil) => (
<option key={stil} value={stil}>{stil}</option>
))}
</select>
<FormMessage />
</FormItem>
)} />
<FormField name="beispiellinks" render={({ field }) => (
<FormItem>
<FormLabel>Beispiellinks (optional, mehrere möglich)</FormLabel>
<FormControl>
<Textarea {...field} placeholder="Eine URL pro Zeile" />
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
)}
{currentStep === 4 && (
<div className="space-y-4">
<FormField name="features_gewuenscht" render={({ field }) => (
<FormItem>
<FormLabel>Gewünschte Features *</FormLabel>
<div className="flex flex-wrap gap-3">
{funktionenOptions.map((fkt) => (
<label key={fkt} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
value={fkt}
checked={field.value?.includes(fkt)}
onChange={(e) => {
if (e.target.checked) {
field.onChange([...(field.value || []), fkt]);
} else {
field.onChange((field.value || []).filter((v: string) => v !== fkt));
}
}}
className="accent-blue-600"
/>
<span>{fkt}</span>
</label>
))}
</div>
<FormMessage />
</FormItem>
)} />
<FormField name="drittanbieter" render={({ field }) => (
<FormItem>
<FormLabel>Drittanbieter (optional)</FormLabel>
<div className="flex flex-wrap gap-3">
{drittanbieterOptions.map((anbieter) => (
<label key={anbieter} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
value={anbieter}
checked={field.value?.includes(anbieter)}
onChange={(e) => {
if (e.target.checked) {
field.onChange([...(field.value || []), anbieter]);
} else {
field.onChange((field.value || []).filter((v: string) => v !== anbieter));
}
}}
className="accent-blue-600"
/>
<span>{anbieter}</span>
</label>
))}
</div>
<FormMessage />
</FormItem>
)} />
</div>
)}
{currentStep === 5 && (
<div className="space-y-4">
<FormField name="selbst_pflegen" render={({ field }) => (
<FormItem>
<FormLabel>Selbst pflegen?</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField name="laufende_betreuung" render={({ field }) => (
<FormItem>
<FormLabel>Laufende Betreuung erwünscht?</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
)}
{currentStep === 6 && (
<div className="space-y-4">
<FormField name="deadline" render={({ field }) => (
<FormItem>
<FormLabel>Deadline *</FormLabel>
<FormControl>
<Input {...field} type="date" />
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField name="projekt_verantwortlich" render={({ field }) => (
<FormItem>
<FormLabel>Projektverantwortlicher Name *</FormLabel>
<FormControl>
<Input {...field} placeholder="Name" />
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField name="budget" render={({ field }) => (
<FormItem>
<FormLabel>Budget (optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="z.B. 5.00010.000 €" />
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
)}
{currentStep === 7 && !submitSuccess && (
<>
<div className="space-y-4">
<FormField name="kommunikationsweg" render={({ field }) => (
<FormItem>
<FormLabel>Kommunikationsweg *</FormLabel>
<div className="flex flex-wrap gap-3">
{kommunikationswegOptions.map((weg) => (
<label key={weg} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
value={weg}
checked={field.value?.includes(weg)}
onChange={(e) => {
if (e.target.checked) {
field.onChange([...(field.value || []), weg]);
} else {
field.onChange((field.value || []).filter((v: string) => v !== weg));
}
}}
className="accent-blue-600"
/>
<span>{weg}</span>
</label>
))}
</div>
<FormMessage />
</FormItem>
)} />
<FormField name="feedback_geschwindigkeit" render={({ field }) => (
<FormItem>
<FormLabel>Feedback-Geschwindigkeit (optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="z.B. innerhalb von 24h" />
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<div className="flex flex-col items-center justify-center min-h-[80px] mt-6">
<button
type="button"
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
onClick={handleSubmitAll}
disabled={isSubmitting}
>
{isSubmitting ? 'Speichern ...' : 'Daten speichern & Projekt anlegen'}
</button>
{submitError && <div className="text-red-600 mt-4">{submitError}</div>}
</div>
</>
)}
{submitSuccess && (
<div className="flex flex-col items-center justify-center min-h-[120px]">
<span className="text-green-700 font-bold text-lg mb-2">Kunde erfolgreich angelegt!</span>
<span className="text-gray-500">Du kannst das Fenster jetzt schließen oder einen neuen Kunden anlegen.</span>
</div>
)}
</div>
{/* Navigation Buttons */}
<div className="flex justify-between mt-8">
<button
type="button"
className="px-4 py-3 md:px-6 md:py-2 bg-gray-200 rounded-lg hover:bg-gray-300 disabled:opacity-50 transition-all duration-200 text-sm md:text-base font-medium"
onClick={prevStep}
disabled={currentStep === 0}
>
Zurück
</button>
{currentStep < steps.length - 1 && (
<button
type="button"
className="px-4 py-3 md:px-6 md:py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-all duration-200 text-sm md:text-base font-medium"
onClick={nextStep}
>
Weiter
</button>
)}
</div>
</Form>
);
}

View File

@@ -0,0 +1,246 @@
"use client";
import React, { useState } from 'react';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Mail, CheckCircle, AlertCircle, ArrowLeft } from "lucide-react";
import { supabase } from '@/lib/supabase';
import { colors } from '@/lib/colors';
interface EmailVerificationProps {
email: string;
onVerificationComplete: () => void;
onBack: () => void;
}
export default function EmailVerification({ email, onVerificationComplete, onBack }: EmailVerificationProps) {
const [verificationEmail, setVerificationEmail] = useState(email);
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [rateLimited, setRateLimited] = useState(false);
const [cooldownTime, setCooldownTime] = useState(0);
const handleSendVerification = async () => {
if (!verificationEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(verificationEmail)) {
setError('Bitte geben Sie eine gültige E-Mail-Adresse ein.');
return;
}
if (rateLimited) {
setError(`Rate Limit aktiv. Bitte warten Sie ${cooldownTime} Sekunden oder verwenden Sie die manuelle Bestätigung.`);
return;
}
setLoading(true);
setError(null);
try {
console.log('=== E-MAIL VERIFICATION DEBUG ===');
console.log('Sending verification email to:', verificationEmail);
console.log('Current origin:', window.location.origin);
console.log('Redirect URL:', `${window.location.origin}/auth/callback`);
// Use signInWithOtp with proper configuration for email verification
const { data, error } = await supabase.auth.signInWithOtp({
email: verificationEmail,
options: {
shouldCreateUser: true,
data: {
// Custom metadata for appointment booking
appointment_booking: true,
email: verificationEmail
}
}
});
console.log('Supabase email verification response:', { data, error });
if (error) {
console.error('Supabase email verification error:', error);
if (error.message.includes('rate limit')) {
setRateLimited(true);
setCooldownTime(60); // 60 seconds cooldown
// Start countdown
const countdown = setInterval(() => {
setCooldownTime(prev => {
if (prev <= 1) {
clearInterval(countdown);
setRateLimited(false);
return 0;
}
return prev - 1;
});
}, 1000);
setError(`E-Mail-Rate-Limit erreicht. Bitte warten Sie 60 Sekunden oder verwenden Sie die manuelle Bestätigung.`);
} else if (error.message.includes('expired') || error.message.includes('invalid')) {
setError('Der E-Mail-Link ist abgelaufen oder ungültig. Bitte fordern Sie einen neuen Link an.');
} else {
setError(`E-Mail-Fehler: ${error.message}`);
}
} else {
console.log('Verification email sent successfully');
setSent(true);
}
} catch (err) {
console.error('Unexpected error sending verification email:', err);
setError('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
} finally {
setLoading(false);
}
};
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setVerificationEmail(e.target.value);
if (error) setError(null);
};
const handleResend = () => {
setSent(false);
setError(null);
};
return (
<div className="w-full max-w-md mx-auto">
<div
className="p-6 sm:p-8 rounded-3xl shadow-lg backdrop-blur-sm"
style={{ backgroundColor: `${colors.background}F0` }}
>
<div className="flex items-center space-x-3 mb-6">
<Mail className="w-6 h-6" style={{ color: colors.primary }} />
<h3 className="text-xl font-bold" style={{ color: colors.primary }}>
E-Mail senden
</h3>
</div>
{!sent ? (
<div className="space-y-6">
<p className="text-sm" style={{ color: colors.secondary }}>
Um Ihren Termin zu bestätigen, senden wir Ihnen eine Bestätigungs-E-Mail.
Klicken Sie auf den Link in der E-Mail, um sich zu authentifizieren.
</p>
<div>
<label className="block text-sm font-medium mb-2" style={{ color: colors.primary }}>
E-Mail-Adresse
</label>
<Input
type="email"
value={verificationEmail}
onChange={handleEmailChange}
className="w-full p-3 rounded-xl border-2 focus:outline-none focus:ring-2"
style={{
borderColor: error ? '#ef4444' : colors.tertiary,
backgroundColor: colors.background,
color: colors.primary
}}
placeholder="ihre@email.de"
/>
{error && (
<p className="text-red-500 text-sm mt-1">{error}</p>
)}
{rateLimited && (
<div className="mt-2 p-2 rounded-lg border-2 border-orange-300 bg-orange-50">
<p className="text-xs text-orange-700">
<strong>Rate Limit aktiv:</strong> Bitte warten Sie {cooldownTime} Sekunden oder verwenden Sie die manuelle Bestätigung.
</p>
</div>
)}
</div>
<div className="flex space-x-3">
<Button
onClick={onBack}
variant="outline"
className="flex-1 rounded-xl"
style={{
borderColor: colors.tertiary,
color: colors.primary
}}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Zurück
</Button>
<Button
onClick={handleSendVerification}
disabled={loading || rateLimited}
className="flex-1 rounded-xl flex items-center justify-center space-x-2"
style={{
backgroundColor: colors.primary,
color: colors.background
}}
>
{loading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>E-Mail wird gesendet...</span>
</>
) : rateLimited ? (
<>
<span>Rate Limit ({cooldownTime}s)</span>
</>
) : (
<>
<Mail className="w-4 h-4" />
<span>E-Mail senden</span>
</>
)}
</Button>
</div>
</div>
) : (
<div className="space-y-6">
<div className="text-center">
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4"
style={{ backgroundColor: `${colors.primary}20` }}>
<Mail className="w-8 h-8" style={{ color: colors.primary }} />
</div>
<h4 className="text-lg font-semibold mb-2" style={{ color: colors.primary }}>
E-Mail gesendet!
</h4>
<p className="text-sm mb-4" style={{ color: colors.secondary }}>
Wir haben eine Bestätigungs-E-Mail an <strong>{verificationEmail}</strong> gesendet.
Bitte überprüfen Sie Ihren Posteingang und klicken Sie auf den Link in der E-Mail.
</p>
</div>
<div className="flex space-x-3">
<Button
onClick={handleResend}
variant="outline"
className="flex-1 rounded-xl"
style={{
borderColor: colors.tertiary,
color: colors.primary
}}
>
Erneut senden
</Button>
<Button
onClick={onVerificationComplete}
className="flex-1 rounded-xl flex items-center justify-center space-x-2"
style={{
backgroundColor: colors.primary,
color: colors.background
}}
>
<CheckCircle className="w-4 h-4" />
<span>E-Mail bestätigt</span>
</Button>
</div>
</div>
)}
</div>
</div>
);
}

108
components/GlassSurface.css Normal file
View File

@@ -0,0 +1,108 @@
.glass-surface {
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: opacity 0.26s ease-out;
}
.glass-surface[style*="height: auto"] {
height: auto;
}
.glass-surface__filter {
width: 100%;
height: 100%;
pointer-events: none;
position: absolute;
inset: 0;
opacity: 0;
z-index: -1;
}
.glass-surface__content {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
border-radius: inherit;
position: relative;
z-index: 1;
}
.glass-surface--svg {
background: light-dark(hsl(0 0% 100% / var(--glass-frost, 0)), hsl(0 0% 0% / var(--glass-frost, 0)));
backdrop-filter: var(--filter-id, url(#glass-filter)) saturate(var(--glass-saturation, 1));
box-shadow:
0 0 2px 1px light-dark(color-mix(in oklch, black, transparent 85%), color-mix(in oklch, white, transparent 65%))
inset,
0 0 10px 4px light-dark(color-mix(in oklch, black, transparent 90%), color-mix(in oklch, white, transparent 85%))
inset,
0px 4px 16px rgba(17, 17, 26, 0.05),
0px 8px 24px rgba(17, 17, 26, 0.05),
0px 16px 56px rgba(17, 17, 26, 0.05),
0px 4px 16px rgba(17, 17, 26, 0.05) inset,
0px 8px 24px rgba(17, 17, 26, 0.05) inset,
0px 16px 56px rgba(17, 17, 26, 0.05) inset;
}
.glass-surface--fallback {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(12px) saturate(1.8) brightness(1.1);
-webkit-backdrop-filter: blur(12px) saturate(1.8) brightness(1.1);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 8px 32px 0 rgba(31, 38, 135, 0.2),
0 2px 16px 0 rgba(31, 38, 135, 0.1),
inset 0 1px 0 0 rgba(255, 255, 255, 0.4),
inset 0 -1px 0 0 rgba(255, 255, 255, 0.2);
}
@media (prefers-color-scheme: dark) {
.glass-surface--fallback {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(12px) saturate(1.8) brightness(1.2);
-webkit-backdrop-filter: blur(12px) saturate(1.8) brightness(1.2);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 0 0 rgba(255, 255, 255, 0.1);
}
}
@supports not (backdrop-filter: blur(10px)) {
.glass-surface--fallback {
background: rgba(255, 255, 255, 0.4);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.5),
inset 0 -1px 0 0 rgba(255, 255, 255, 0.3);
}
.glass-surface--fallback::before {
content: '';
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.15);
border-radius: inherit;
z-index: -1;
}
}
@supports not (backdrop-filter: blur(10px)) {
@media (prefers-color-scheme: dark) {
.glass-surface--fallback {
background: rgba(0, 0, 0, 0.4);
}
.glass-surface--fallback::before {
background: rgba(255, 255, 255, 0.05);
}
}
}
.glass-surface:focus-visible {
outline: 2px solid light-dark(#007aff, #0a84ff);
outline-offset: 2px;
}

231
components/GlassSurface.tsx Normal file
View File

@@ -0,0 +1,231 @@
import { useEffect, useRef, useId, ReactNode, CSSProperties } from 'react';
import './GlassSurface.css';
interface GlassSurfaceProps {
children: ReactNode;
width?: number | string;
height?: number | string;
borderRadius?: number;
borderWidth?: number;
brightness?: number;
opacity?: number;
blur?: number;
displace?: number;
backgroundOpacity?: number;
saturation?: number;
distortionScale?: number;
redOffset?: number;
greenOffset?: number;
blueOffset?: number;
xChannel?: string;
yChannel?: string;
mixBlendMode?: string;
className?: string;
style?: CSSProperties;
}
const GlassSurface: React.FC<GlassSurfaceProps> = ({
children,
width = 200,
height = 80,
borderRadius = 20,
borderWidth = 0.07,
brightness = 50,
opacity = 0.93,
blur = 11,
displace = 0,
backgroundOpacity = 0,
saturation = 1,
distortionScale = -180,
redOffset = 0,
greenOffset = 10,
blueOffset = 20,
xChannel = 'R',
yChannel = 'G',
mixBlendMode = 'difference',
className = '',
style = {}
}) => {
const uniqueId = useId().replace(/:/g, '-');
const filterId = `glass-filter-${uniqueId}`;
const redGradId = `red-grad-${uniqueId}`;
const blueGradId = `blue-grad-${uniqueId}`;
const containerRef = useRef<HTMLDivElement>(null);
const feImageRef = useRef<SVGFEImageElement>(null);
const redChannelRef = useRef<SVGFEDisplacementMapElement>(null);
const greenChannelRef = useRef<SVGFEDisplacementMapElement>(null);
const blueChannelRef = useRef<SVGFEDisplacementMapElement>(null);
const gaussianBlurRef = useRef<SVGFEGaussianBlurElement>(null);
const generateDisplacementMap = () => {
const rect = containerRef.current?.getBoundingClientRect();
const actualWidth = rect?.width || 400;
const actualHeight = rect?.height || 200;
const edgeSize = Math.min(actualWidth, actualHeight) * (borderWidth * 0.5);
const svgContent = `
<svg viewBox="0 0 ${actualWidth} ${actualHeight}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="${redGradId}" x1="100%" y1="0%" x2="0%" y2="0%">
<stop offset="0%" stop-color="#0000"/>
<stop offset="100%" stop-color="red"/>
</linearGradient>
<linearGradient id="${blueGradId}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#0000"/>
<stop offset="100%" stop-color="blue"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="${actualWidth}" height="${actualHeight}" fill="black"></rect>
<rect x="0" y="0" width="${actualWidth}" height="${actualHeight}" rx="${borderRadius}" fill="url(#${redGradId})" />
<rect x="0" y="0" width="${actualWidth}" height="${actualHeight}" rx="${borderRadius}" fill="url(#${blueGradId})" style="mix-blend-mode: ${mixBlendMode}" />
<rect x="${edgeSize}" y="${edgeSize}" width="${actualWidth - edgeSize * 2}" height="${actualHeight - edgeSize * 2}" rx="${borderRadius}" fill="hsl(0 0% ${brightness}% / ${opacity})" style="filter:blur(${blur}px)" />
</svg>
`;
return `data:image/svg+xml,${encodeURIComponent(svgContent)}`;
};
const updateDisplacementMap = () => {
if (feImageRef.current) {
feImageRef.current.setAttribute('href', generateDisplacementMap());
}
};
useEffect(() => {
updateDisplacementMap();
[
{ ref: redChannelRef, offset: redOffset },
{ ref: greenChannelRef, offset: greenOffset },
{ ref: blueChannelRef, offset: blueOffset }
].forEach(({ ref, offset }) => {
if (ref.current) {
ref.current.setAttribute('scale', (distortionScale + offset).toString());
ref.current.setAttribute('xChannelSelector', xChannel);
ref.current.setAttribute('yChannelSelector', yChannel);
}
});
if (gaussianBlurRef.current) {
gaussianBlurRef.current.setAttribute('stdDeviation', displace.toString());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
width,
height,
borderRadius,
borderWidth,
brightness,
opacity,
blur,
displace,
distortionScale,
redOffset,
greenOffset,
blueOffset,
xChannel,
yChannel,
mixBlendMode
]);
useEffect(() => {
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver(() => {
setTimeout(updateDisplacementMap, 0);
});
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
setTimeout(updateDisplacementMap, 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [width, height]);
const supportsSVGFilters = () => {
const isWebkit = /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent);
const isFirefox = /Firefox/.test(navigator.userAgent);
if (isWebkit || isFirefox) {
return false;
}
const div = document.createElement('div');
div.style.backdropFilter = `url(#${filterId})`;
return div.style.backdropFilter !== '';
};
const containerStyle: CSSProperties = {
...style,
width: typeof width === 'number' ? `${width}px` : width,
height: typeof height === 'number' ? `${height}px` : height,
borderRadius: `${borderRadius}px`,
'--glass-frost': backgroundOpacity,
'--glass-saturation': saturation,
'--filter-id': `url(#${filterId})`
} as CSSProperties;
return (
<div
ref={containerRef}
className={`glass-surface ${supportsSVGFilters() ? 'glass-surface--svg' : 'glass-surface--fallback'} ${className}`}
style={containerStyle}
>
<svg className="glass-surface__filter" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id={filterId} colorInterpolationFilters="sRGB" x="0%" y="0%" width="100%" height="100%">
<feImage ref={feImageRef} x="0" y="0" width="100%" height="100%" preserveAspectRatio="none" result="map" />
<feDisplacementMap ref={redChannelRef} in="SourceGraphic" in2="map" id="redchannel" result="dispRed" />
<feColorMatrix
in="dispRed"
type="matrix"
values="1 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 1 0"
result="red"
/>
<feDisplacementMap
ref={greenChannelRef}
in="SourceGraphic"
in2="map"
id="greenchannel"
result="dispGreen"
/>
<feColorMatrix
in="dispGreen"
type="matrix"
values="0 0 0 0 0
0 1 0 0 0
0 0 0 0 0
0 0 0 1 0"
result="green"
/>
<feDisplacementMap ref={blueChannelRef} in="SourceGraphic" in2="map" id="bluechannel" result="dispBlue" />
<feColorMatrix
in="dispBlue"
type="matrix"
values="0 0 0 0 0
0 0 0 0 0
0 0 1 0 0
0 0 0 1 0"
result="blue"
/>
<feBlend in="red" in2="green" mode="screen" result="rg" />
<feBlend in="rg" in2="blue" mode="screen" result="output" />
<feGaussianBlur ref={gaussianBlurRef} in="output" stdDeviation="0.7" />
</filter>
</defs>
</svg>
<div className="glass-surface__content">{children}</div>
</div>
);
};
export default GlassSurface;

131
components/LogoLoop.css Normal file
View File

@@ -0,0 +1,131 @@
.logoloop {
position: relative;
overflow-x: hidden;
--logoloop-gap: 32px;
--logoloop-logoHeight: 28px;
--logoloop-fadeColorAuto: #ffffff;
}
.logoloop--scale-hover {
padding-top: calc(var(--logoloop-logoHeight) * 0.1);
padding-bottom: calc(var(--logoloop-logoHeight) * 0.1);
}
@media (prefers-color-scheme: dark) {
.logoloop {
--logoloop-fadeColorAuto: #0b0b0b;
}
}
.logoloop__track {
display: flex;
width: max-content;
will-change: transform;
user-select: none;
}
.logoloop__list {
display: flex;
align-items: center;
}
.logoloop__item {
flex: 0 0 auto;
margin-right: var(--logoloop-gap);
font-size: var(--logoloop-logoHeight);
line-height: 1;
}
.logoloop__item:last-child {
margin-right: var(--logoloop-gap);
}
.logoloop__node {
display: inline-flex;
align-items: center;
}
.logoloop__item img {
height: var(--logoloop-logoHeight);
width: auto;
display: block;
object-fit: contain;
image-rendering: -webkit-optimize-contrast;
-webkit-user-drag: none;
pointer-events: none;
/* Links handle interaction */
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.logoloop--scale-hover .logoloop__item {
overflow: visible;
}
.logoloop--scale-hover .logoloop__item:hover img,
.logoloop--scale-hover .logoloop__item:hover .logoloop__node {
transform: scale(1.2);
transform-origin: center center;
}
.logoloop--scale-hover .logoloop__node {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.logoloop__link {
display: inline-flex;
align-items: center;
text-decoration: none;
border-radius: 4px;
transition: opacity 0.2s ease;
}
.logoloop__link:hover {
opacity: 0.8;
}
.logoloop__link:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.logoloop--fade::before,
.logoloop--fade::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: clamp(24px, 8%, 120px);
pointer-events: none;
z-index: 1;
}
.logoloop--fade::before {
left: 0;
background: linear-gradient(
to right,
var(--logoloop-fadeColor, var(--logoloop-fadeColorAuto)) 0%,
transparent 100%
);
}
.logoloop--fade::after {
right: 0;
background: linear-gradient(
to left,
var(--logoloop-fadeColor, var(--logoloop-fadeColorAuto)) 0%,
transparent 100%
);
}
@media (prefers-reduced-motion: reduce) {
.logoloop__track {
transform: translate3d(0, 0, 0) !important;
}
.logoloop__item img,
.logoloop__node {
transition: none !important;
}
}

325
components/LogoLoop.tsx Normal file
View File

@@ -0,0 +1,325 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState, memo, ReactNode } from 'react';
import './LogoLoop.css';
const ANIMATION_CONFIG = {
SMOOTH_TAU: 0.25,
MIN_COPIES: 2,
COPY_HEADROOM: 2
};
const toCssLength = (value: number | string | undefined): string | undefined =>
(typeof value === 'number' ? `${value}px` : (value ?? undefined));
interface LogoItem {
node?: ReactNode;
src?: string;
srcSet?: string;
sizes?: string;
width?: number;
height?: number;
alt?: string;
title?: string;
href?: string;
ariaLabel?: string;
}
interface LogoLoopProps {
logos: LogoItem[];
speed?: number;
direction?: 'left' | 'right';
width?: number | string;
logoHeight?: number;
gap?: number;
pauseOnHover?: boolean;
fadeOut?: boolean;
fadeOutColor?: string;
scaleOnHover?: boolean;
ariaLabel?: string;
className?: string;
style?: React.CSSProperties;
}
const useResizeObserver = (
callback: () => void,
elements: React.RefObject<HTMLElement>[],
dependencies: unknown[]
) => {
useEffect(() => {
if (!window.ResizeObserver) {
const handleResize = () => callback();
window.addEventListener('resize', handleResize);
callback();
return () => window.removeEventListener('resize', handleResize);
}
const observers = elements.map(ref => {
if (!ref.current) return null;
const observer = new ResizeObserver(callback);
observer.observe(ref.current);
return observer;
});
callback();
return () => {
observers.forEach(observer => observer?.disconnect());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
};
const useImageLoader = (
seqRef: React.RefObject<HTMLElement>,
onLoad: () => void,
dependencies: unknown[]
) => {
useEffect(() => {
const images = seqRef.current?.querySelectorAll('img') ?? [];
if (images.length === 0) {
onLoad();
return;
}
let remainingImages = images.length;
const handleImageLoad = () => {
remainingImages -= 1;
if (remainingImages === 0) {
onLoad();
}
};
images.forEach(img => {
const htmlImg = img as HTMLImageElement;
if (htmlImg.complete) {
handleImageLoad();
} else {
htmlImg.addEventListener('load', handleImageLoad, { once: true });
htmlImg.addEventListener('error', handleImageLoad, { once: true });
}
});
return () => {
images.forEach(img => {
img.removeEventListener('load', handleImageLoad);
img.removeEventListener('error', handleImageLoad);
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
};
const useAnimationLoop = (
trackRef: React.RefObject<HTMLElement>,
targetVelocity: number,
seqWidth: number,
isHovered: boolean,
pauseOnHover: boolean
) => {
const rafRef = useRef<number | null>(null);
const lastTimestampRef = useRef<number | null>(null);
const offsetRef = useRef(0);
const velocityRef = useRef(0);
useEffect(() => {
const track = trackRef.current;
if (!track) return;
if (seqWidth > 0) {
offsetRef.current = ((offsetRef.current % seqWidth) + seqWidth) % seqWidth;
track.style.transform = `translate3d(${-offsetRef.current}px, 0, 0)`;
}
const animate = (timestamp: number) => {
if (lastTimestampRef.current === null) {
lastTimestampRef.current = timestamp;
}
const deltaTime = Math.max(0, timestamp - lastTimestampRef.current) / 1000;
lastTimestampRef.current = timestamp;
const target = pauseOnHover && isHovered ? 0 : targetVelocity;
const easingFactor = 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU);
velocityRef.current += (target - velocityRef.current) * easingFactor;
if (seqWidth > 0) {
let nextOffset = offsetRef.current + velocityRef.current * deltaTime;
nextOffset = ((nextOffset % seqWidth) + seqWidth) % seqWidth;
offsetRef.current = nextOffset;
const translateX = -offsetRef.current;
track.style.transform = `translate3d(${translateX}px, 0, 0)`;
}
rafRef.current = requestAnimationFrame(animate);
};
rafRef.current = requestAnimationFrame(animate);
return () => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
lastTimestampRef.current = null;
};
}, [targetVelocity, seqWidth, isHovered, pauseOnHover, trackRef]);
};
export const LogoLoop = memo<LogoLoopProps>(({
logos,
speed = 120,
direction = 'left',
width = '100%',
logoHeight = 28,
gap = 32,
pauseOnHover = true,
fadeOut = false,
fadeOutColor,
scaleOnHover = false,
ariaLabel = 'Partner logos',
className,
style
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const seqRef = useRef<HTMLUListElement>(null);
const [seqWidth, setSeqWidth] = useState(0);
const [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES);
const [isHovered, setIsHovered] = useState(false);
const targetVelocity = useMemo(() => {
const magnitude = Math.abs(speed);
const directionMultiplier = direction === 'left' ? 1 : -1;
const speedMultiplier = speed < 0 ? -1 : 1;
return magnitude * directionMultiplier * speedMultiplier;
}, [speed, direction]);
const updateDimensions = useCallback(() => {
const containerWidth = containerRef.current?.clientWidth ?? 0;
const sequenceWidth = seqRef.current?.getBoundingClientRect?.()?.width ?? 0;
if (sequenceWidth > 0) {
setSeqWidth(Math.ceil(sequenceWidth));
const copiesNeeded = Math.ceil(containerWidth / sequenceWidth) + ANIMATION_CONFIG.COPY_HEADROOM;
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded));
}
}, []);
useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight]);
useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight]);
useAnimationLoop(trackRef, targetVelocity, seqWidth, isHovered, pauseOnHover);
const cssVariables = useMemo(
() => ({
'--logoloop-gap': `${gap}px`,
'--logoloop-logoHeight': `${logoHeight}px`,
...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor })
}),
[gap, logoHeight, fadeOutColor]
);
const rootClassName = useMemo(
() =>
['logoloop', fadeOut && 'logoloop--fade', scaleOnHover && 'logoloop--scale-hover', className]
.filter(Boolean)
.join(' '),
[fadeOut, scaleOnHover, className]
);
const handleMouseEnter = useCallback(() => {
if (pauseOnHover) setIsHovered(true);
}, [pauseOnHover]);
const handleMouseLeave = useCallback(() => {
if (pauseOnHover) setIsHovered(false);
}, [pauseOnHover]);
const renderLogoItem = useCallback((item: LogoItem, key: string) => {
const isNodeItem = 'node' in item;
const content = isNodeItem ? (
<span className="logoloop__node" aria-hidden={!!item.href && !item.ariaLabel}>
{item.node}
</span>
) : (
<img
src={item.src}
srcSet={item.srcSet}
sizes={item.sizes}
width={item.width}
height={item.height}
alt={item.alt ?? ''}
title={item.title}
loading="lazy"
decoding="async"
draggable={false}
/>
);
const itemAriaLabel = isNodeItem ? (item.ariaLabel ?? item.title) : (item.alt ?? item.title);
const itemContent = item.href ? (
<a
className="logoloop__link"
href={item.href}
aria-label={itemAriaLabel || 'logo link'}
target="_blank"
rel="noreferrer noopener"
>
{content}
</a>
) : (
content
);
return (
<li className="logoloop__item" key={key} role="listitem">
{itemContent}
</li>
);
}, []);
const logoLists = useMemo(
() =>
Array.from({ length: copyCount }, (_, copyIndex) => (
<ul
className="logoloop__list"
key={`copy-${copyIndex}`}
role="list"
aria-hidden={copyIndex > 0}
ref={copyIndex === 0 ? seqRef : undefined}
>
{logos.map((item, itemIndex) => renderLogoItem(item, `${copyIndex}-${itemIndex}`))}
</ul>
)),
[copyCount, logos, renderLogoItem]
);
const containerStyle = useMemo(
() => ({
width: toCssLength(width) ?? '100%',
...cssVariables,
...style
}),
[width, cssVariables, style]
);
return (
<div
ref={containerRef}
className={rootClassName}
style={containerStyle}
role="region"
aria-label={ariaLabel}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="logoloop__track" ref={trackRef}>
{logoLists}
</div>
</div>
);
});
LogoLoop.displayName = 'LogoLoop';
export default LogoLoop;

View File

@@ -0,0 +1,125 @@
import { useState } from 'react'
import { supabase } from '@/lib/supabase'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { colors } from '@/lib/colors'
import { Mail, CheckCircle } from 'lucide-react'
export default function EmailAuth() {
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [messageType, setMessageType] = useState<'success' | 'error'>('success')
const handleSendEmail = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setMessage('')
try {
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`
}
})
if (error) {
setMessage(`Fehler: ${error.message}`)
setMessageType('error')
} else {
setMessage('E-Mail wurde an deine E-Mail-Adresse gesendet! Bitte prüfe dein Postfach.')
setMessageType('success')
}
} catch (error) {
setMessage('Ein unerwarteter Fehler ist aufgetreten.')
setMessageType('error')
} finally {
setLoading(false)
}
}
return (
<div className="w-full max-w-md mx-auto">
<div
className="p-6 sm:p-8 rounded-3xl shadow-lg backdrop-blur-sm"
style={{ backgroundColor: `${colors.background}F0` }}
>
<div className="flex items-center space-x-3 mb-6">
<Mail className="w-6 h-6" style={{ color: colors.primary }} />
<h3 className="text-xl font-bold" style={{ color: colors.primary }}>
Anmelden
</h3>
</div>
<form onSubmit={handleSendEmail} className="space-y-6">
<div>
<label className="block text-sm font-medium mb-2" style={{ color: colors.primary }}>
E-Mail-Adresse
</label>
<Input
type="email"
placeholder="deine@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full p-3 rounded-xl border-2 focus:outline-none focus:ring-2"
style={{
borderColor: colors.tertiary,
backgroundColor: colors.background,
color: colors.primary
}}
/>
</div>
<Button
type="submit"
disabled={loading}
className="w-full py-3 rounded-xl text-lg font-semibold flex items-center justify-center space-x-2"
style={{
backgroundColor: colors.primary,
color: colors.background
}}
>
{loading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
<span>Wird gesendet...</span>
</>
) : (
<>
<Mail className="w-5 h-5" />
<span>E-Mail senden</span>
</>
)}
</Button>
</form>
{message && (
<div className={`mt-6 p-4 rounded-xl border-2 ${
messageType === 'success'
? 'border-green-500 bg-green-50'
: 'border-red-500 bg-red-50'
}`}>
<div className="flex items-center space-x-2">
<CheckCircle className={`w-5 h-5 ${
messageType === 'success' ? 'text-green-600' : 'text-red-600'
}`} />
<p className={`text-sm ${
messageType === 'success' ? 'text-green-700' : 'text-red-700'
}`}>
{message}
</p>
</div>
</div>
)}
<div className="mt-6 text-xs text-center" style={{ color: colors.secondary }}>
<p> Du erhältst eine E-Mail zur Bestätigung</p>
<p> Klicke auf den Link in der E-Mail, um dich anzumelden</p>
<p> Kein Passwort erforderlich</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,112 @@
import React from "react";
const partners = [
{
name: "Webflow",
svg: (
<svg className="partner-spin" viewBox="0 0 48 48" fill="none" width={40} height={40} xmlns="http://www.w3.org/2000/svg">
<path d="M7 12L15.5 36L24 12L32.5 36L41 12" stroke="#FEFAE0" strokeWidth="3" strokeLinejoin="round"/>
</svg>
),
},
{
name: "Stripe",
svg: (
<svg className="partner-spin" viewBox="0 0 48 48" fill="none" width={40} height={40} xmlns="http://www.w3.org/2000/svg">
<rect x="8" y="12" width="32" height="24" rx="6" stroke="#FEFAE0" strokeWidth="3"/>
<rect x="14" y="20" width="20" height="4" rx="2" fill="#FEFAE0"/>
</svg>
),
},
{
name: "Supabase",
svg: (
<svg className="partner-spin" viewBox="0 0 48 48" fill="none" width={40} height={40} xmlns="http://www.w3.org/2000/svg">
<rect x="12" y="8" width="24" height="32" rx="4" stroke="#FEFAE0" strokeWidth="3"/>
<rect x="18" y="16" width="12" height="4" rx="2" fill="#FEFAE0"/>
<rect x="18" y="24" width="12" height="4" rx="2" fill="#FEFAE0"/>
</svg>
),
},
{
name: "GitHub",
svg: (
<svg className="partner-spin" viewBox="0 0 48 48" fill="none" width={40} height={40} xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="20" stroke="#FEFAE0" strokeWidth="3"/>
<path d="M18 34c-1-2-2-4-2-7 0-4 3-7 8-7s8 3 8 7c0 3-1 5-2 7" stroke="#FEFAE0" strokeWidth="3" strokeLinecap="round"/>
<circle cx="18" cy="20" r="2" fill="#FEFAE0"/>
<circle cx="30" cy="20" r="2" fill="#FEFAE0"/>
</svg>
),
},
{
name: "Cursor AI",
svg: (
<svg className="partner-spin" viewBox="0 0 48 48" fill="none" width={40} height={40} xmlns="http://www.w3.org/2000/svg">
<rect x="12" y="12" width="24" height="24" rx="8" stroke="#FEFAE0" strokeWidth="3"/>
<circle cx="24" cy="24" r="6" stroke="#FEFAE0" strokeWidth="3"/>
<circle cx="24" cy="24" r="2" fill="#FEFAE0"/>
</svg>
),
},
{
name: "Google Ads",
svg: (
<svg className="partner-spin" viewBox="0 0 48 48" fill="none" width={40} height={40} xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="30" width="8" height="8" rx="2" fill="#FEFAE0"/>
<rect x="20" y="18" width="8" height="20" rx="2" fill="#FEFAE0"/>
<rect x="30" y="10" width="8" height="28" rx="2" fill="#FEFAE0"/>
</svg>
),
},
];
export default function PartnerMarquee() {
return (
<div className="w-full overflow-hidden py-8">
{/* Desktop/Tablet: Marquee */}
<div className="relative hidden sm:block">
<div className="marquee flex items-center gap-8" style={{ animation: 'marquee 30s linear infinite' }}>
{[...partners, ...partners].map((partner, idx) => (
<div
key={partner.name + idx}
className="flex flex-col items-center justify-center min-w-[140px] px-6 py-4 rounded-2xl bg-white/5 backdrop-blur-sm"
>
{partner.svg}
<span className="mt-2 font-bold text-[#FEFAE0] text-base md:text-lg text-center whitespace-nowrap" style={{ letterSpacing: 0.5 }}>{partner.name}</span>
</div>
))}
</div>
<style>{`
@keyframes marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
.marquee {
width: 200%;
}
@keyframes partner-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.partner-spin {
animation: partner-spin 8s linear infinite;
display: block;
}
`}</style>
</div>
{/* Mobile: Grid */}
<div className="sm:hidden grid grid-cols-2 gap-4 justify-items-center">
{partners.map((partner) => (
<div
key={partner.name}
className="flex flex-col items-center justify-center w-full px-4 py-4 rounded-2xl bg-white/5 backdrop-blur-sm"
>
{partner.svg}
<span className="mt-2 font-bold text-[#FEFAE0] text-base text-center whitespace-nowrap" style={{ letterSpacing: 0.5 }}>{partner.name}</span>
</div>
))}
</div>
</div>
);
}

240
components/PillNav.css Normal file
View File

@@ -0,0 +1,240 @@
.pill-nav-container {
position: relative;
z-index: 99;
width: max-content;
max-width: 100%;
}
@media (max-width: 768px) {
.pill-nav-container {
width: 100%;
left: 0;
}
}
.pill-nav {
--nav-h: 42px;
--logo: 36px;
--pill-pad-x: 18px;
--pill-gap: 3px;
width: max-content;
display: flex;
align-items: center;
box-sizing: border-box;
}
@media (max-width: 768px) {
.pill-nav {
width: 100%;
justify-content: space-between;
padding: 0 1rem;
background: transparent;
}
}
.pill-nav-items {
position: relative;
display: flex;
align-items: center;
height: var(--nav-h);
background: var(--base, #000);
border-radius: 9999px;
}
.pill-logo {
width: var(--nav-h);
height: var(--nav-h);
border-radius: 50%;
background: var(--base, #000);
padding: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.pill-logo img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.pill-list {
list-style: none;
display: flex;
align-items: stretch;
gap: var(--pill-gap);
margin: 0;
padding: 3px;
height: 100%;
}
.pill-list > li {
display: flex;
height: 100%;
}
.pill {
display: inline-flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 0 var(--pill-pad-x);
background: var(--pill-bg, #fff);
color: var(--pill-text, var(--base, #000));
text-decoration: none;
border-radius: 9999px;
box-sizing: border-box;
font-weight: 600;
font-size: 16px;
line-height: 0;
text-transform: uppercase;
letter-spacing: 0.2px;
white-space: nowrap;
cursor: pointer;
position: relative;
overflow: hidden;
}
.pill .hover-circle {
position: absolute;
left: 50%;
bottom: 0;
border-radius: 50%;
background: var(--base, #000);
z-index: 1;
display: block;
pointer-events: none;
will-change: transform;
}
.pill .label-stack {
position: relative;
display: inline-block;
line-height: 1;
z-index: 2;
}
.pill .pill-label {
position: relative;
z-index: 2;
display: inline-block;
line-height: 1;
will-change: transform;
}
.pill .pill-label-hover {
position: absolute;
left: 0;
top: 0;
color: var(--hover-text, #fff);
z-index: 3;
display: inline-block;
will-change: transform, opacity;
}
.pill.is-active::after {
content: '';
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 12px;
height: 12px;
background: var(--base, #000);
border-radius: 50px;
z-index: 4;
}
.desktop-only {
display: block;
}
.mobile-only {
display: none;
}
@media (max-width: 768px) {
.desktop-only {
display: none;
}
.mobile-only {
display: block;
}
}
.mobile-menu-button {
width: var(--nav-h);
height: var(--nav-h);
border-radius: 50%;
background: var(--base, #000);
border: none;
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
cursor: pointer;
padding: 0;
position: relative;
}
@media (max-width: 768px) {
.mobile-menu-button {
display: flex;
}
}
.hamburger-line {
width: 16px;
height: 2px;
background: var(--pill-bg, #fff);
border-radius: 1px;
transition: all 0.01s ease;
transform-origin: center;
}
.mobile-menu-popover {
position: absolute;
top: 3em;
left: 1rem;
right: 1rem;
background: var(--base, #f0f0f0);
border-radius: 27px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
z-index: 998;
opacity: 0;
transform-origin: top center;
visibility: hidden;
}
.mobile-menu-list {
list-style: none;
margin: 0;
padding: 3px;
display: flex;
flex-direction: column;
gap: 3px;
}
.mobile-menu-popover .mobile-menu-link {
display: block;
padding: 12px 16px;
color: var(--pill-text, #fff);
background-color: var(--pill-bg, #fff);
text-decoration: none;
font-size: 16px;
font-weight: 500;
border-radius: 50px;
transition: all 0.2s ease;
}
.mobile-menu-popover .mobile-menu-link:hover {
cursor: pointer;
background-color: var(--base);
color: var(--hover-text, #fff);
}

365
components/PillNav.tsx Normal file
View File

@@ -0,0 +1,365 @@
"use client";
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { gsap } from 'gsap';
import './PillNav.css';
interface PillNavItem {
label: string;
href: string;
ariaLabel?: string;
}
interface PillNavProps {
logo?: string;
logoAlt?: string;
items: PillNavItem[];
activeHref?: string;
className?: string;
ease?: string;
baseColor?: string;
pillColor?: string;
hoveredPillTextColor?: string;
pillTextColor?: string;
onMobileMenuClick?: () => void;
initialLoadAnimation?: boolean;
}
const PillNav = ({
logo,
logoAlt = 'Logo',
items,
activeHref,
className = '',
ease = 'power3.easeOut',
baseColor = '#fff',
pillColor = '#060010',
hoveredPillTextColor = '#060010',
pillTextColor,
onMobileMenuClick,
initialLoadAnimation = true
}: PillNavProps) => {
const resolvedPillTextColor = pillTextColor ?? baseColor;
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const circleRefs = useRef<(HTMLSpanElement | null)[]>([]);
const tlRefs = useRef<gsap.core.Timeline[]>([]);
const activeTweenRefs = useRef<gsap.core.Tween[]>([]);
const logoImgRef = useRef<HTMLImageElement | null>(null);
const logoTweenRef = useRef<gsap.core.Tween | null>(null);
const hamburgerRef = useRef<HTMLButtonElement | null>(null);
const mobileMenuRef = useRef<HTMLDivElement | null>(null);
const navItemsRef = useRef<HTMLDivElement | null>(null);
const logoRef = useRef<HTMLAnchorElement | null>(null);
const hasAnimatedRef = useRef(false);
useEffect(() => {
const layout = () => {
circleRefs.current.forEach(circle => {
if (!circle?.parentElement) return;
const pill = circle.parentElement;
const rect = pill.getBoundingClientRect();
const { width: w, height: h } = rect;
const R = ((w * w) / 4 + h * h) / (2 * h);
const D = Math.ceil(2 * R) + 2;
const delta = Math.ceil(R - Math.sqrt(Math.max(0, R * R - (w * w) / 4))) + 1;
const originY = D - delta;
circle.style.width = `${D}px`;
circle.style.height = `${D}px`;
circle.style.bottom = `-${delta}px`;
gsap.set(circle, {
xPercent: -50,
scale: 0,
transformOrigin: `50% ${originY}px`
});
const label = pill.querySelector('.pill-label');
const white = pill.querySelector('.pill-label-hover');
if (label) gsap.set(label, { y: 0 });
if (white) gsap.set(white, { y: h + 12, opacity: 0 });
const index = circleRefs.current.indexOf(circle);
if (index === -1) return;
tlRefs.current[index]?.kill();
const tl = gsap.timeline({ paused: true });
tl.to(circle, { scale: 1.2, xPercent: -50, duration: 2, ease, overwrite: 'auto' }, 0);
if (label) {
tl.to(label, { y: -(h + 8), duration: 2, ease, overwrite: 'auto' }, 0);
}
if (white) {
gsap.set(white, { y: Math.ceil(h + 100), opacity: 0 });
tl.to(white, { y: 0, opacity: 1, duration: 2, ease, overwrite: 'auto' }, 0);
}
tlRefs.current[index] = tl;
});
};
layout();
const onResize = () => layout();
window.addEventListener('resize', onResize);
if (document.fonts?.ready) {
document.fonts.ready.then(layout).catch(() => {});
}
const menu = mobileMenuRef.current;
if (menu) {
gsap.set(menu, { visibility: 'hidden', opacity: 0, scaleY: 1 });
}
if (initialLoadAnimation && !hasAnimatedRef.current) {
const logoEl = logoRef.current;
const navItems = navItemsRef.current;
if (logoEl) {
gsap.set(logoEl, { scale: 0 });
gsap.to(logoEl, {
scale: 1,
duration: 0.6,
ease
});
}
if (navItems) {
gsap.set(navItems, { width: 0, overflow: 'hidden' });
gsap.to(navItems, {
width: 'auto',
duration: 0.6,
ease
});
}
hasAnimatedRef.current = true;
} else if (navItemsRef.current && !hasAnimatedRef.current) {
// Wenn initialLoadAnimation false ist, setze die Items sofort auf sichtbar
const navItems = navItemsRef.current;
gsap.set(navItems, { width: 'auto', overflow: 'visible' });
if (logoRef.current) {
gsap.set(logoRef.current, { scale: 1 });
}
hasAnimatedRef.current = true;
}
return () => window.removeEventListener('resize', onResize);
}, [items, ease]); // initialLoadAnimation entfernt, damit es nur einmal läuft
const handleEnter = (i: number) => {
const tl = tlRefs.current[i];
if (!tl) return;
activeTweenRefs.current[i]?.kill();
activeTweenRefs.current[i] = tl.tweenTo(tl.duration(), {
duration: 0.3,
ease,
overwrite: 'auto'
});
};
const handleLeave = (i: number) => {
const tl = tlRefs.current[i];
if (!tl) return;
activeTweenRefs.current[i]?.kill();
activeTweenRefs.current[i] = tl.tweenTo(0, {
duration: 0.2,
ease,
overwrite: 'auto'
});
};
const handleLogoEnter = () => {
const img = logoImgRef.current;
if (!img) return;
logoTweenRef.current?.kill();
gsap.set(img, { rotate: 0 });
logoTweenRef.current = gsap.to(img, {
rotate: 360,
duration: 0.2,
ease,
overwrite: 'auto'
});
};
const toggleMobileMenu = () => {
const newState = !isMobileMenuOpen;
setIsMobileMenuOpen(newState);
const hamburger = hamburgerRef.current;
const menu = mobileMenuRef.current;
if (hamburger) {
const lines = hamburger.querySelectorAll('.hamburger-line');
if (newState) {
gsap.to(lines[0], { rotation: 45, y: 3, duration: 0.3, ease });
gsap.to(lines[1], { rotation: -45, y: -3, duration: 0.3, ease });
} else {
gsap.to(lines[0], { rotation: 0, y: 0, duration: 0.3, ease });
gsap.to(lines[1], { rotation: 0, y: 0, duration: 0.3, ease });
}
}
if (menu) {
if (newState) {
gsap.set(menu, { visibility: 'visible' });
gsap.fromTo(
menu,
{ opacity: 0, y: 10, scaleY: 1 },
{
opacity: 1,
y: 0,
scaleY: 1,
duration: 0.3,
ease,
transformOrigin: 'top center'
}
);
} else {
gsap.to(menu, {
opacity: 0,
y: 10,
scaleY: 1,
duration: 0.2,
ease,
transformOrigin: 'top center',
onComplete: () => {
gsap.set(menu, { visibility: 'hidden' });
}
});
}
}
onMobileMenuClick?.();
};
const isExternalLink = (href: string) =>
href.startsWith('http://') ||
href.startsWith('https://') ||
href.startsWith('//') ||
href.startsWith('mailto:') ||
href.startsWith('tel:') ||
href.startsWith('#');
const cssVars = {
['--base']: baseColor,
['--pill-bg']: pillColor,
['--hover-text']: hoveredPillTextColor,
['--pill-text']: resolvedPillTextColor
} as React.CSSProperties;
return (
<div className="pill-nav-container">
<nav className={`pill-nav ${className}`} aria-label="Primary" style={cssVars}>
{logo && (
<Link
className="pill-logo"
href="#"
aria-label="Home"
onMouseEnter={handleLogoEnter}
role="menuitem"
ref={logoRef}
onClick={(e) => {
e.preventDefault();
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
>
<img src={logo} alt={logoAlt} ref={logoImgRef} />
</Link>
)}
<div className="pill-nav-items desktop-only" ref={navItemsRef}>
<ul className="pill-list" role="menubar">
{items.map((item, i) => (
<li key={item.href || `item-${i}`} role="none">
{isExternalLink(item.href) ? (
<a
role="menuitem"
href={item.href}
className={`pill${activeHref === item.href ? ' is-active' : ''}`}
aria-label={item.ariaLabel || item.label}
onMouseEnter={() => handleEnter(i)}
onMouseLeave={() => handleLeave(i)}
>
<span
className="hover-circle"
aria-hidden="true"
ref={el => {
circleRefs.current[i] = el;
}}
/>
<span className="label-stack">
<span className="pill-label">{item.label}</span>
<span className="pill-label-hover" aria-hidden="true">
{item.label}
</span>
</span>
</a>
) : (
<Link
role="menuitem"
href={item.href}
className={`pill${activeHref === item.href ? ' is-active' : ''}`}
aria-label={item.ariaLabel || item.label}
onMouseEnter={() => handleEnter(i)}
onMouseLeave={() => handleLeave(i)}
onClick={(e) => {
if (item.href.startsWith('#')) {
e.preventDefault();
const element = document.getElementById(item.href.substring(1));
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
}}
>
<span
className="hover-circle"
aria-hidden="true"
ref={el => {
circleRefs.current[i] = el;
}}
/>
<span className="label-stack">
<span className="pill-label">{item.label}</span>
<span className="pill-label-hover" aria-hidden="true">
{item.label}
</span>
</span>
</Link>
)}
</li>
))}
</ul>
</div>
<button
className="mobile-menu-button mobile-only"
onClick={toggleMobileMenu}
aria-label="Toggle menu"
ref={hamburgerRef}
>
<span className="hamburger-line" />
<span className="hamburger-line" />
</button>
</nav>
<div className="mobile-menu-popover mobile-only" ref={mobileMenuRef} style={cssVars}>
<ul className="mobile-menu-list">
{items.map((item, i) => (
<li key={item.href || `mobile-item-${i}`}>
{isExternalLink(item.href) ? (
<a
href={item.href}
className={`mobile-menu-link${activeHref === item.href ? ' is-active' : ''}`}
onClick={() => setIsMobileMenuOpen(false)}
>
{item.label}
</a>
) : (
<Link
href={item.href}
className={`mobile-menu-link${activeHref === item.href ? ' is-active' : ''}`}
onClick={() => {
setIsMobileMenuOpen(false);
if (item.href.startsWith('#')) {
const element = document.getElementById(item.href.substring(1));
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
}}
>
{item.label}
</Link>
)}
</li>
))}
</ul>
</div>
</div>
);
};
export default PillNav;

View File

@@ -0,0 +1,7 @@
.pixel-blast-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}

705
components/PixelBlast.tsx Normal file
View File

@@ -0,0 +1,705 @@
'use client';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { EffectComposer, EffectPass, RenderPass, Effect } from 'postprocessing';
import './PixelBlast.css';
const createTouchTexture = () => {
const size = 64;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('2D context not available');
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const texture = new THREE.Texture(canvas);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
const trail: Array<{ x: number; y: number; age: number; force: number; vx: number; vy: number }> = [];
let last: { x: number; y: number } | null = null;
const maxAge = 64;
let radius = 0.1 * size;
const speed = 1 / maxAge;
const clear = () => {
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
};
const drawPoint = (p: { x: number; y: number; age: number; force: number; vx: number; vy: number }) => {
const pos = { x: p.x * size, y: (1 - p.y) * size };
let intensity = 1;
const easeOutSine = (t: number) => Math.sin((t * Math.PI) / 2);
const easeOutQuad = (t: number) => -t * (t - 2);
if (p.age < maxAge * 0.3) intensity = easeOutSine(p.age / (maxAge * 0.3));
else intensity = easeOutQuad(1 - (p.age - maxAge * 0.3) / (maxAge * 0.7)) || 0;
intensity *= p.force;
const color = `${((p.vx + 1) / 2) * 255}, ${((p.vy + 1) / 2) * 255}, ${intensity * 255}`;
const offset = size * 5;
ctx.shadowOffsetX = offset;
ctx.shadowOffsetY = offset;
ctx.shadowBlur = radius;
ctx.shadowColor = `rgba(${color},${0.22 * intensity})`;
ctx.beginPath();
ctx.fillStyle = 'rgba(255,0,0,1)';
ctx.arc(pos.x - offset, pos.y - offset, radius, 0, Math.PI * 2);
ctx.fill();
};
const addTouch = (norm: { x: number; y: number }) => {
let force = 0;
let vx = 0;
let vy = 0;
if (last) {
const dx = norm.x - last.x;
const dy = norm.y - last.y;
if (dx === 0 && dy === 0) return;
const dd = dx * dx + dy * dy;
const d = Math.sqrt(dd);
vx = dx / (d || 1);
vy = dy / (d || 1);
force = Math.min(dd * 10000, 1);
}
last = { x: norm.x, y: norm.y };
trail.push({ x: norm.x, y: norm.y, age: 0, force, vx, vy });
};
const update = () => {
clear();
for (let i = trail.length - 1; i >= 0; i--) {
const point = trail[i];
const f = point.force * speed * (1 - point.age / maxAge);
point.x += point.vx * f;
point.y += point.vy * f;
point.age++;
if (point.age > maxAge) trail.splice(i, 1);
}
for (let i = 0; i < trail.length; i++) drawPoint(trail[i]);
texture.needsUpdate = true;
};
return {
canvas,
texture,
addTouch,
update,
set radiusScale(v: number) {
radius = 0.1 * size * v;
},
get radiusScale() {
return radius / (0.1 * size);
},
size
};
};
const createLiquidEffect = (texture: THREE.Texture, opts?: { strength?: number; freq?: number }) => {
const fragment = `
uniform sampler2D uTexture;
uniform float uStrength;
uniform float uTime;
uniform float uFreq;
void mainUv(inout vec2 uv) {
vec4 tex = texture2D(uTexture, uv);
float vx = tex.r * 2.0 - 1.0;
float vy = tex.g * 2.0 - 1.0;
float intensity = tex.b;
float wave = 0.5 + 0.5 * sin(uTime * uFreq + intensity * 6.2831853);
float amt = uStrength * intensity * wave;
uv += vec2(vx, vy) * amt;
}
`;
return new Effect('LiquidEffect', fragment, {
uniforms: new Map([
['uTexture', new THREE.Uniform(texture)],
['uStrength', new THREE.Uniform(opts?.strength ?? 0.025)],
['uTime', new THREE.Uniform(0)],
['uFreq', new THREE.Uniform(opts?.freq ?? 4.5)]
])
});
};
const SHAPE_MAP: Record<string, number> = {
square: 0,
circle: 1,
triangle: 2,
diamond: 3
};
const VERTEX_SRC = `
void main() {
gl_Position = vec4(position, 1.0);
}
`;
const FRAGMENT_SRC = `
precision highp float;
uniform vec3 uColor;
uniform vec2 uResolution;
uniform float uTime;
uniform float uPixelSize;
uniform float uScale;
uniform float uDensity;
uniform float uPixelJitter;
uniform int uEnableRipples;
uniform float uRippleSpeed;
uniform float uRippleThickness;
uniform float uRippleIntensity;
uniform float uEdgeFade;
uniform int uShapeType;
const int SHAPE_SQUARE = 0;
const int SHAPE_CIRCLE = 1;
const int SHAPE_TRIANGLE = 2;
const int SHAPE_DIAMOND = 3;
const int MAX_CLICKS = 10;
uniform vec2 uClickPos [MAX_CLICKS];
uniform float uClickTimes[MAX_CLICKS];
out vec4 fragColor;
float Bayer2(vec2 a) {
a = floor(a);
return fract(a.x / 2. + a.y * a.y * .75);
}
#define Bayer4(a) (Bayer2(.5*(a))*0.25 + Bayer2(a))
#define Bayer8(a) (Bayer4(.5*(a))*0.25 + Bayer2(a))
#define FBM_OCTAVES 5
#define FBM_LACUNARITY 1.25
#define FBM_GAIN 1.0
float hash11(float n){ return fract(sin(n)*43758.5453); }
float vnoise(vec3 p){
vec3 ip = floor(p);
vec3 fp = fract(p);
float n000 = hash11(dot(ip + vec3(0.0,0.0,0.0), vec3(1.0,57.0,113.0)));
float n100 = hash11(dot(ip + vec3(1.0,0.0,0.0), vec3(1.0,57.0,113.0)));
float n010 = hash11(dot(ip + vec3(0.0,1.0,0.0), vec3(1.0,57.0,113.0)));
float n110 = hash11(dot(ip + vec3(1.0,1.0,0.0), vec3(1.0,57.0,113.0)));
float n001 = hash11(dot(ip + vec3(0.0,0.0,1.0), vec3(1.0,57.0,113.0)));
float n101 = hash11(dot(ip + vec3(1.0,0.0,1.0), vec3(1.0,57.0,113.0)));
float n011 = hash11(dot(ip + vec3(0.0,1.0,1.0), vec3(1.0,57.0,113.0)));
float n111 = hash11(dot(ip + vec3(1.0,1.0,1.0), vec3(1.0,57.0,113.0)));
vec3 w = fp*fp*fp*(fp*(fp*6.0-15.0)+10.0);
float x00 = mix(n000, n100, w.x);
float x10 = mix(n010, n110, w.x);
float x01 = mix(n001, n101, w.x);
float x11 = mix(n011, n111, w.x);
float y0 = mix(x00, x10, w.y);
float y1 = mix(x01, x11, w.y);
return mix(y0, y1, w.z) * 2.0 - 1.0;
}
float fbm2(vec2 uv, float t){
vec3 p = vec3(uv * uScale, t);
float amp = 1.0;
float freq = 1.0;
float sum = 1.0;
for (int i = 0; i < FBM_OCTAVES; ++i){
sum += amp * vnoise(p * freq);
freq *= FBM_LACUNARITY;
amp *= FBM_GAIN;
}
return sum * 0.5 + 0.5;
}
float maskCircle(vec2 p, float cov){
float r = sqrt(cov) * .25;
float d = length(p - 0.5) - r;
float aa = 0.5 * fwidth(d);
return cov * (1.0 - smoothstep(-aa, aa, d * 2.0));
}
float maskTriangle(vec2 p, vec2 id, float cov){
bool flip = mod(id.x + id.y, 2.0) > 0.5;
if (flip) p.x = 1.0 - p.x;
float r = sqrt(cov);
float d = p.y - r*(1.0 - p.x);
float aa = fwidth(d);
return cov * clamp(0.5 - d/aa, 0.0, 1.0);
}
float maskDiamond(vec2 p, float cov){
float r = sqrt(cov) * 0.564;
return step(abs(p.x - 0.49) + abs(p.y - 0.49), r);
}
void main(){
float pixelSize = uPixelSize;
vec2 fragCoord = gl_FragCoord.xy - uResolution * .5;
float aspectRatio = uResolution.x / uResolution.y;
vec2 pixelId = floor(fragCoord / pixelSize);
vec2 pixelUV = fract(fragCoord / pixelSize);
float cellPixelSize = 8.0 * pixelSize;
vec2 cellId = floor(fragCoord / cellPixelSize);
vec2 cellCoord = cellId * cellPixelSize;
vec2 uv = cellCoord / uResolution * vec2(aspectRatio, 1.0);
float base = fbm2(uv, uTime * 0.05);
base = base * 0.5 - 0.65;
float feed = base + (uDensity - 0.5) * 0.3;
float speed = uRippleSpeed;
float thickness = uRippleThickness;
const float dampT = 1.0;
const float dampR = 10.0;
if (uEnableRipples == 1) {
for (int i = 0; i < MAX_CLICKS; ++i){
vec2 pos = uClickPos[i];
if (pos.x < 0.0) continue;
float cellPixelSize = 8.0 * pixelSize;
vec2 cuv = (((pos - uResolution * .5 - cellPixelSize * .5) / (uResolution))) * vec2(aspectRatio, 1.0);
float t = max(uTime - uClickTimes[i], 0.0);
float r = distance(uv, cuv);
float waveR = speed * t;
float ring = exp(-pow((r - waveR) / thickness, 2.0));
float atten = exp(-dampT * t) * exp(-dampR * r);
feed = max(feed, ring * atten * uRippleIntensity);
}
}
float bayer = Bayer8(fragCoord / uPixelSize) - 0.5;
float bw = step(0.5, feed + bayer);
float h = fract(sin(dot(floor(fragCoord / uPixelSize), vec2(127.1, 311.7))) * 43758.5453);
float jitterScale = 1.0 + (h - 0.5) * uPixelJitter;
float coverage = bw * jitterScale;
float M;
if (uShapeType == SHAPE_CIRCLE) M = maskCircle (pixelUV, coverage);
else if (uShapeType == SHAPE_TRIANGLE) M = maskTriangle(pixelUV, pixelId, coverage);
else if (uShapeType == SHAPE_DIAMOND) M = maskDiamond(pixelUV, coverage);
else M = coverage;
if (uEdgeFade > 0.0) {
vec2 norm = gl_FragCoord.xy / uResolution;
float edge = min(min(norm.x, norm.y), min(1.0 - norm.x, 1.0 - norm.y));
float fade = smoothstep(0.0, uEdgeFade, edge);
M *= fade;
}
vec3 color = uColor;
fragColor = vec4(color, M);
}
`;
const MAX_CLICKS = 10;
interface PixelBlastProps {
variant?: 'square' | 'circle' | 'triangle' | 'diamond';
pixelSize?: number;
color?: string;
className?: string;
style?: React.CSSProperties;
antialias?: boolean;
patternScale?: number;
patternDensity?: number;
liquid?: boolean;
liquidStrength?: number;
liquidRadius?: number;
pixelSizeJitter?: number;
enableRipples?: boolean;
rippleIntensityScale?: number;
rippleThickness?: number;
rippleSpeed?: number;
liquidWobbleSpeed?: number;
autoPauseOffscreen?: boolean;
speed?: number;
transparent?: boolean;
edgeFade?: number;
noiseAmount?: number;
}
const PixelBlast = ({
variant = 'square',
pixelSize = 3,
color = '#B19EEF',
className,
style,
antialias = true,
patternScale = 2,
patternDensity = 1,
liquid = false,
liquidStrength = 0.1,
liquidRadius = 1,
pixelSizeJitter = 0,
enableRipples = true,
rippleIntensityScale = 1,
rippleThickness = 0.1,
rippleSpeed = 0.3,
liquidWobbleSpeed = 4.5,
autoPauseOffscreen = true,
speed = 0.5,
transparent = true,
edgeFade = 0.5,
noiseAmount = 0
}: PixelBlastProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const visibilityRef = useRef({ visible: true });
const speedRef = useRef(speed);
const threeRef = useRef<{
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.OrthographicCamera;
material: THREE.ShaderMaterial;
clock: THREE.Clock;
clickIx: number;
uniforms: Record<string, THREE.IUniform>;
resizeObserver: ResizeObserver;
raf: number;
quad: THREE.Mesh;
timeOffset: number;
composer?: EffectComposer;
touch?: ReturnType<typeof createTouchTexture>;
liquidEffect?: Effect;
} | null>(null);
const prevConfigRef = useRef<{ antialias: boolean; liquid: boolean; noiseAmount: number } | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
speedRef.current = speed;
const needsReinitKeys: Array<keyof { antialias: boolean; liquid: boolean; noiseAmount: number }> = ['antialias', 'liquid', 'noiseAmount'];
const cfg = { antialias, liquid, noiseAmount };
let mustReinit = false;
if (!threeRef.current) mustReinit = true;
else if (prevConfigRef.current) {
for (const k of needsReinitKeys) {
if (prevConfigRef.current[k] !== cfg[k]) {
mustReinit = true;
break;
}
}
}
if (mustReinit) {
if (threeRef.current) {
const t = threeRef.current;
t.resizeObserver?.disconnect();
cancelAnimationFrame(t.raf);
t.quad?.geometry.dispose();
t.material.dispose();
t.composer?.dispose();
t.renderer.dispose();
if (t.renderer.domElement.parentElement === container) {
container.removeChild(t.renderer.domElement);
}
threeRef.current = null;
}
const canvas = document.createElement('canvas');
const renderer = new THREE.WebGLRenderer({
canvas,
antialias,
alpha: true,
powerPreference: 'high-performance'
});
renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%';
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
container.appendChild(renderer.domElement);
if (transparent) renderer.setClearAlpha(0);
else renderer.setClearColor(0x000000, 1);
const uniforms: Record<string, THREE.IUniform> = {
uResolution: { value: new THREE.Vector2(0, 0) },
uTime: { value: 0 },
uColor: { value: new THREE.Color(color) },
uClickPos: {
value: Array.from({ length: MAX_CLICKS }, () => new THREE.Vector2(-1, -1))
},
uClickTimes: { value: new Float32Array(MAX_CLICKS) },
uShapeType: { value: SHAPE_MAP[variant] ?? 0 },
uPixelSize: { value: pixelSize * renderer.getPixelRatio() },
uScale: { value: patternScale },
uDensity: { value: patternDensity },
uPixelJitter: { value: pixelSizeJitter },
uEnableRipples: { value: enableRipples ? 1 : 0 },
uRippleSpeed: { value: rippleSpeed },
uRippleThickness: { value: rippleThickness },
uRippleIntensity: { value: rippleIntensityScale },
uEdgeFade: { value: edgeFade }
};
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const material = new THREE.ShaderMaterial({
vertexShader: VERTEX_SRC,
fragmentShader: FRAGMENT_SRC,
uniforms,
transparent: true,
depthTest: false,
depthWrite: false,
glslVersion: THREE.GLSL3
});
const quadGeom = new THREE.PlaneGeometry(2, 2);
const quad = new THREE.Mesh(quadGeom, material);
scene.add(quad);
const clock = new THREE.Clock();
const setSize = () => {
const w = container.clientWidth || 1;
const h = container.clientHeight || 1;
renderer.setSize(w, h, false);
uniforms.uResolution.value.set(renderer.domElement.width, renderer.domElement.height);
if (threeRef.current?.composer) {
threeRef.current.composer.setSize(renderer.domElement.width, renderer.domElement.height);
}
uniforms.uPixelSize.value = pixelSize * renderer.getPixelRatio();
};
setSize();
const ro = new ResizeObserver(setSize);
ro.observe(container);
const randomFloat = () => {
if (typeof window !== 'undefined' && window.crypto?.getRandomValues) {
const u32 = new Uint32Array(1);
window.crypto.getRandomValues(u32);
return u32[0] / 0xffffffff;
}
return Math.random();
};
const timeOffset = randomFloat() * 1000;
let composer: EffectComposer | undefined;
let touch: ReturnType<typeof createTouchTexture> | undefined;
let liquidEffect: Effect | undefined;
if (liquid) {
touch = createTouchTexture();
touch.radiusScale = liquidRadius;
composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
liquidEffect = createLiquidEffect(touch.texture, {
strength: liquidStrength,
freq: liquidWobbleSpeed
});
const effectPass = new EffectPass(camera, liquidEffect);
effectPass.renderToScreen = true;
composer.addPass(renderPass);
composer.addPass(effectPass);
}
if (noiseAmount > 0) {
if (!composer) {
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
}
const noiseEffect = new Effect(
'NoiseEffect',
`uniform float uTime; uniform float uAmount; float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453);} void mainUv(inout vec2 uv){} void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor){ float n=hash(floor(uv*vec2(1920.0,1080.0))+floor(uTime*60.0)); float g=(n-0.5)*uAmount; outputColor=inputColor+vec4(vec3(g),0.0);} `,
{
uniforms: new Map([
['uTime', new THREE.Uniform(0)],
['uAmount', new THREE.Uniform(noiseAmount)]
])
}
);
const noisePass = new EffectPass(camera, noiseEffect);
noisePass.renderToScreen = true;
if (composer && composer.passes.length > 0) {
composer.passes.forEach(p => {
p.renderToScreen = false;
});
}
composer.addPass(noisePass);
}
if (composer) composer.setSize(renderer.domElement.width, renderer.domElement.height);
const mapToPixels = (e: PointerEvent) => {
const rect = renderer.domElement.getBoundingClientRect();
const scaleX = renderer.domElement.width / rect.width;
const scaleY = renderer.domElement.height / rect.height;
const fx = (e.clientX - rect.left) * scaleX;
const fy = (rect.height - (e.clientY - rect.top)) * scaleY;
return { fx, fy, w: renderer.domElement.width, h: renderer.domElement.height };
};
const onPointerDown = (e: PointerEvent) => {
const { fx, fy } = mapToPixels(e);
const ix = threeRef.current?.clickIx ?? 0;
(uniforms.uClickPos.value as THREE.Vector2[])[ix].set(fx, fy);
(uniforms.uClickTimes.value as Float32Array)[ix] = uniforms.uTime.value as number;
if (threeRef.current) threeRef.current.clickIx = (ix + 1) % MAX_CLICKS;
};
const onPointerMove = (e: PointerEvent) => {
if (!touch) return;
const { fx, fy, w, h } = mapToPixels(e);
touch.addTouch({ x: fx / w, y: fy / h });
};
renderer.domElement.addEventListener('pointerdown', onPointerDown, { passive: true });
renderer.domElement.addEventListener('pointermove', onPointerMove, { passive: true });
let raf = 0;
const animate = () => {
if (autoPauseOffscreen && !visibilityRef.current.visible) {
raf = requestAnimationFrame(animate);
return;
}
uniforms.uTime.value = timeOffset + clock.getElapsedTime() * speedRef.current;
if (liquidEffect) {
const uTime = liquidEffect.uniforms.get('uTime');
if (uTime) uTime.value = uniforms.uTime.value as number;
}
if (composer) {
if (touch) touch.update();
composer.passes.forEach(p => {
const effs = p.effects;
if (effs) {
effs.forEach(eff => {
const u = eff.uniforms?.get('uTime');
if (u) u.value = uniforms.uTime.value as number;
});
}
});
composer.render();
} else {
renderer.render(scene, camera);
}
raf = requestAnimationFrame(animate);
};
raf = requestAnimationFrame(animate);
if (autoPauseOffscreen) {
const io = new IntersectionObserver(
entries => {
visibilityRef.current.visible = entries[0].isIntersecting;
},
{ threshold: 0 }
);
io.observe(container);
}
threeRef.current = {
renderer,
scene,
camera,
material,
clock,
clickIx: 0,
uniforms,
resizeObserver: ro,
raf,
quad,
timeOffset,
composer,
touch,
liquidEffect
};
} else {
const t = threeRef.current;
if (!t) return;
t.uniforms.uShapeType.value = SHAPE_MAP[variant] ?? 0;
t.uniforms.uPixelSize.value = pixelSize * t.renderer.getPixelRatio();
(t.uniforms.uColor.value as THREE.Color).set(color);
t.uniforms.uScale.value = patternScale;
t.uniforms.uDensity.value = patternDensity;
t.uniforms.uPixelJitter.value = pixelSizeJitter;
t.uniforms.uEnableRipples.value = enableRipples ? 1 : 0;
t.uniforms.uRippleIntensity.value = rippleIntensityScale;
t.uniforms.uRippleThickness.value = rippleThickness;
t.uniforms.uRippleSpeed.value = rippleSpeed;
t.uniforms.uEdgeFade.value = edgeFade;
if (transparent) t.renderer.setClearAlpha(0);
else t.renderer.setClearColor(0x000000, 1);
if (t.liquidEffect) {
const uStrength = t.liquidEffect.uniforms.get('uStrength');
if (uStrength) (uStrength as THREE.Uniform).value = liquidStrength;
const uFreq = t.liquidEffect.uniforms.get('uFreq');
if (uFreq) (uFreq as THREE.Uniform).value = liquidWobbleSpeed;
}
if (t.touch) t.touch.radiusScale = liquidRadius;
}
prevConfigRef.current = cfg;
return () => {
if (mustReinit && threeRef.current) return;
if (!threeRef.current) return;
const t = threeRef.current;
t.resizeObserver?.disconnect();
cancelAnimationFrame(t.raf);
t.quad?.geometry.dispose();
t.material.dispose();
t.composer?.dispose();
t.renderer.dispose();
if (t.renderer.domElement.parentElement === container) {
container.removeChild(t.renderer.domElement);
}
threeRef.current = null;
};
}, [
antialias,
liquid,
noiseAmount,
pixelSize,
patternScale,
patternDensity,
enableRipples,
rippleIntensityScale,
rippleThickness,
rippleSpeed,
pixelSizeJitter,
edgeFade,
transparent,
liquidStrength,
liquidRadius,
liquidWobbleSpeed,
autoPauseOffscreen,
variant,
color,
speed
]);
return (
<div
ref={containerRef}
className={`pixel-blast-container ${className ?? ''}`}
style={style}
aria-label="PixelBlast interactive background"
/>
);
};
export default PixelBlast;

View File

@@ -0,0 +1,520 @@
"use client";
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Textarea } from '@/components/ui/textarea';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Calculator, ChevronDown, ChevronUp, Loader2, Info, HelpCircle } from 'lucide-react';
import { colors } from '@/lib/colors';
interface PriceCalculatorProps {
onPriceCalculated?: (price: string, details: string) => void;
}
const services = [
{
id: 'seo',
label: 'SEO-Optimierung',
tooltip: 'Suchmaschinenoptimierung für bessere Sichtbarkeit in Google & Co.'
},
{
id: 'database',
label: 'Datenbank-Anbindung',
tooltip: 'Verbindung zu bestehenden Datenbanken oder neue Datenbankstrukturen'
},
{
id: 'ai',
label: 'KI-Integration',
tooltip: 'Künstliche Intelligenz für Chatbots, Empfehlungssysteme oder Automatisierung'
},
{
id: 'responsive',
label: 'Responsives Design',
tooltip: 'Optimale Darstellung auf allen Geräten: Desktop, Tablet, Smartphone'
},
{
id: 'social',
label: 'Social-Media-Verknüpfung',
tooltip: 'Integration von Social Media Feeds und Sharing-Funktionen'
},
{
id: 'payment',
label: 'Zahlungsmethoden',
tooltip: 'Sichere Online-Zahlungen mit PayPal, Kreditkarte, SEPA & Co.'
},
{
id: 'analytics',
label: 'Nutzeranalyse & Tracking',
tooltip: 'Google Analytics, Conversion-Tracking und detaillierte Besucherstatistiken'
},
{
id: 'domain',
label: 'Domainverwaltung',
tooltip: 'Professionelle Domain-Einrichtung und DNS-Konfiguration'
},
{
id: 'strategy',
label: 'Strategieberatung',
tooltip: 'Digitale Strategie, Zielgruppenanalyse und Wettbewerbsanalyse'
},
{
id: 'app',
label: 'App-Entwicklung',
tooltip: 'Native oder Progressive Web Apps für iOS und Android'
}
];
const subscriptionServices = [
{
id: 'content',
label: 'Content-Pflege',
tooltip: 'Regelmäßige Aktualisierung von Texten, Bildern und Inhalten'
},
{
id: 'newsletter',
label: 'Newsletter-Versand & Pflege',
tooltip: 'Professionelle Newsletter-Erstellung, Versand und Analyse'
},
{
id: 'landingpage',
label: 'Landingpage-Erstellung (1x/Monat)',
tooltip: 'Conversion-optimierte Landingpages für Kampagnen und Aktionen'
},
{
id: 'blog',
label: 'Blog-Pflege',
tooltip: 'Regelmäßige Blogartikel, SEO-Optimierung und Community-Management'
},
{
id: 'domain_redirects',
label: 'Domainverwaltung & Umleitungen',
tooltip: 'Professionelle Domain-Verwaltung, Weiterleitungen und DNS-Management'
}
];
export default function PriceCalculator({ onPriceCalculated }: PriceCalculatorProps) {
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({
pages: 1,
services: [] as string[],
express: false,
subscription: [] as string[],
specialRequirements: ''
});
const [result, setResult] = useState<{ price: string; details: string } | null>(null);
const handleServiceChange = (serviceId: string, checked: boolean) => {
setFormData(prev => ({
...prev,
services: checked
? [...prev.services, serviceId]
: prev.services.filter(id => id !== serviceId)
}));
};
const handleSubscriptionChange = (serviceId: string, checked: boolean) => {
setFormData(prev => ({
...prev,
subscription: checked
? [...prev.subscription, serviceId]
: prev.subscription.filter(id => id !== serviceId)
}));
};
const generatePrompt = () => {
const selectedServices = services.filter(service => formData.services.includes(service.id));
const selectedSubscriptions = subscriptionServices.filter(service => formData.subscription.includes(service.id));
const additionalServices = selectedServices.length > 0
? selectedServices.map(s => s.label).join(', ')
: 'Keine zusätzlichen Leistungen ausgewählt';
const ongoingServices = selectedSubscriptions.length > 0
? selectedSubscriptions.map(s => s.label).join(', ')
: 'Keine laufenden Services ausgewählt';
return `Du bist eine intelligente Webprojekt-KI, die Kunden bei der Preisabschätzung für professionelle Websites unterstützt. Analysiere die vom Kunden angegebenen Wünsche und gib einen geschätzten Preisrahmen in Euro aus, basierend auf Komplexität, Umfang und Zusatzleistungen.
Wichtige Regeln:
Die Basiswebsite beginnt bei 300 €
Mehr Seiten erhöhen den Preis moderat
Express-Lieferung kostet +30 % Aufpreis
Je mehr Leistungen ausgewählt werden, desto effizienter kann gearbeitet werden ⇒ kleine Mengenrabatte möglich
Laufende Services bedeuten monatliche Zusatzkosten (nicht im Grundpreis enthalten)
Alle Preise sind unverbindliche Schätzungen
Ziel: Der Kunde soll eine ehrliche und transparente Einschätzung bekommen nicht zu hoch, aber auch nicht unter Wert.
Kundeneingaben
Seitenanzahl: ${formData.pages}
Zusätzliche Leistungen:
${additionalServices}
Laufende Services:
${ongoingServices}
Express-Lieferung gewünscht?
${formData.express ? 'Ja' : 'Nein'}
Besondere Wünsche / Anforderungen:
${formData.specialRequirements || 'Keine besonderen Wünsche angegeben'}
Bitte liefere als Antwort:
Eine realistische Preisspanne in Euro (z. B. 750950 €)
Eine kurze Begründung (12 Sätze)
Hinweis auf Beratungsgespräch für verbindliches Angebot`;
};
const calculateLocalPrice = () => {
let basePrice = 300; // Basis-Website
// Preis pro Seite (ab der 2. Seite)
if (formData.pages > 1) {
basePrice += (formData.pages - 1) * 50;
}
// Zusatzleistungen
const selectedServices = services.filter(service => formData.services.includes(service.id));
const servicePrices = {
'seo': 150,
'database': 200,
'ai': 300,
'responsive': 100,
'social': 80,
'payment': 120,
'analytics': 90,
'domain': 60,
'strategy': 180,
'app': 400
};
let servicesCost = 0;
selectedServices.forEach(service => {
servicesCost += servicePrices[service.id as keyof typeof servicePrices] || 0;
});
// Mengenrabatt für mehrere Leistungen
if (selectedServices.length > 2) {
servicesCost *= 0.9; // 10% Rabatt ab 3 Leistungen
}
// Express-Lieferung
let totalPrice = basePrice + servicesCost;
if (formData.express) {
totalPrice *= 1.3; // +30% für Express
}
// Preisspanne (10% Variation)
const variation = totalPrice * 0.1;
const minPrice = Math.round(totalPrice - variation);
const maxPrice = Math.round(totalPrice + variation);
return {
price: `${minPrice}${maxPrice}`,
details: `Basierend auf ${formData.pages} Seiten und ${selectedServices.length} Zusatzleistungen. ${formData.express ? 'Express-Lieferung inklusive.' : ''}`
};
};
const calculatePrice = async () => {
setIsLoading(true);
try {
const prompt = generatePrompt();
console.log('Sending API request with prompt:', prompt);
const response = await fetch('/api/calculate-price', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
prompt: prompt
})
});
console.log('Response status:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('API Error Response:', errorText);
// Fallback zur lokalen Preisberechnung
console.log('Using local price calculation as fallback');
const localResult = calculateLocalPrice();
setResult(localResult);
onPriceCalculated?.(localResult.price, localResult.details);
return;
}
const data = await response.json();
console.log('API Response data:', data);
const aiResponse = data.choices?.[0]?.message?.content || data.choices?.[0]?.text || 'Preis konnte nicht berechnet werden';
console.log('AI Response:', aiResponse);
// Extract price range and details
const priceMatch = aiResponse.match(/(\d+[-]\d+€)/);
const price = priceMatch ? priceMatch[1] : 'Preis auf Anfrage';
const details = aiResponse.replace(priceMatch?.[0] || '', '').trim();
const result = { price, details };
setResult(result);
onPriceCalculated?.(price, details);
} catch (error) {
console.error('Error calculating price:', error);
// Fallback zur lokalen Preisberechnung
console.log('Using local price calculation as fallback due to error');
const localResult = calculateLocalPrice();
setResult(localResult);
onPriceCalculated?.(localResult.price, localResult.details);
} finally {
setIsLoading(false);
}
};
return (
<div className="w-full max-w-4xl mx-auto">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<Button
size="lg"
className="w-full px-8 py-4 rounded-full text-lg font-semibold shadow-xl hover:scale-105 transition-all duration-300 flex items-center justify-center space-x-2"
style={{
backgroundColor: colors.primary,
color: colors.background
}}
>
<Calculator className="w-5 h-5" />
<span>Preis kalkulieren</span>
{isOpen ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-8">
<div className="relative overflow-hidden rounded-3xl shadow-2xl">
{/* Upper section with light background */}
<div className="p-8 sm:p-12" style={{ backgroundColor: `${colors.background}F0` }}>
<h3 className="text-2xl sm:text-3xl font-bold mb-8 text-center" style={{ color: colors.primary }}>
Kalkuliere deinen individuellen Preis mit wenigen Klicks
</h3>
<div className="space-y-8">
{/* Anzahl Seiten - Slider */}
<div>
<label className="block text-lg font-semibold mb-4" style={{ color: colors.primary }}>
Anzahl der Seiten: <span className="font-bold" style={{ color: colors.secondary }}>{formData.pages}</span>
</label>
<div className="flex items-center space-x-4">
<input
type="range"
min="1"
max="10"
value={formData.pages}
onChange={(e) => setFormData(prev => ({ ...prev, pages: parseInt(e.target.value) }))}
className="flex-1 h-3 rounded-lg appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, ${colors.secondary} 0%, ${colors.secondary} ${(formData.pages - 1) * 11.11}%, ${colors.tertiary} ${(formData.pages - 1) * 11.11}%, ${colors.tertiary} 100%)`
}}
/>
<span className="text-lg font-bold min-w-[3rem] text-center" style={{ color: colors.secondary }}>
{formData.pages}
</span>
</div>
<div className="flex justify-between text-sm mt-2" style={{ color: colors.primary }}>
<span>1</span>
<span>10</span>
</div>
</div>
{/* Leistungen */}
<div>
<label className="block text-lg font-semibold mb-4" style={{ color: colors.primary }}>
Zusätzliche Leistungen
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{services.map((service) => (
<TooltipProvider key={service.id}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-3 p-3 rounded-lg hover:bg-white/20 transition-colors cursor-pointer">
<Checkbox
id={service.id}
checked={formData.services.includes(service.id)}
onCheckedChange={(checked) => handleServiceChange(service.id, checked as boolean)}
className="w-5 h-5"
/>
<label htmlFor={service.id} className="text-lg cursor-pointer flex-1" style={{ color: colors.primary }}>
{service.label}
</label>
<HelpCircle className="w-4 h-4 opacity-60" style={{ color: colors.secondary }} />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>{service.tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
</div>
{/* Express-Lieferung */}
<div className="flex items-center space-x-3 p-4 rounded-lg hover:bg-white/20 transition-colors">
<Checkbox
id="express"
checked={formData.express}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, express: checked as boolean }))}
className="w-5 h-5"
/>
<label htmlFor="express" className="text-lg cursor-pointer" style={{ color: colors.primary }}>
Express-Lieferung
</label>
</div>
{/* Laufende Services */}
<div>
<label className="block text-lg font-semibold mb-4" style={{ color: colors.primary }}>
Laufende Services (optional)
</label>
<div className="space-y-3">
{subscriptionServices.map((service) => (
<TooltipProvider key={service.id}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-3 p-3 rounded-lg hover:bg-white/20 transition-colors cursor-pointer">
<Checkbox
id={service.id}
checked={formData.subscription.includes(service.id)}
onCheckedChange={(checked) => handleSubscriptionChange(service.id, checked as boolean)}
className="w-5 h-5"
/>
<label htmlFor={service.id} className="text-lg cursor-pointer flex-1" style={{ color: colors.primary }}>
{service.label}
</label>
<HelpCircle className="w-4 h-4 opacity-60" style={{ color: colors.secondary }} />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>{service.tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
</div>
</div>
</div>
{/* Wave transition */}
<div className="relative h-8" style={{ backgroundColor: `${colors.background}F0` }}>
<svg
className="absolute bottom-0 w-full h-full"
viewBox="0 0 1200 120"
preserveAspectRatio="none"
style={{ color: colors.primary }}
>
<path
d="M0,0 C300,120 900,120 1200,0 L1200,120 L0,120 Z"
fill="currentColor"
fillOpacity="0.1"
/>
</svg>
</div>
{/* Lower section with darker background */}
<div className="p-8 sm:p-12" style={{ backgroundColor: `${colors.primary}F0` }}>
<div className="space-y-8">
{/* Individuelle Wünsche */}
<div>
<label className="block text-lg font-semibold mb-4" style={{ color: colors.background }}>
Haben Sie besondere Wünsche oder Anforderungen (optional, nicht verpflichtend)?
</label>
<Textarea
value={formData.specialRequirements}
onChange={(e) => setFormData(prev => ({ ...prev, specialRequirements: e.target.value }))}
placeholder="Welche Funktionen oder Strukturen sind Ihnen wichtig? Beispiele: Mitgliederbereich, Projektverwaltung, dynamischer Produktfilter, interne Datenbankpflege, mehrsprachige Inhalte."
className="w-full p-4 text-lg resize-none"
rows={4}
style={{
backgroundColor: `${colors.background}20`,
borderColor: colors.background,
color: colors.background
}}
/>
</div>
{/* Standard-Leistungen Info */}
<div className="p-4 rounded-lg" style={{ backgroundColor: `${colors.background}20` }}>
<div className="flex items-start space-x-3">
<Info className="w-5 h-5 mt-1 flex-shrink-0" style={{ color: colors.tertiary }} />
<p className="text-sm" style={{ color: colors.background }}>
Diese Leistungen sind bei jeder Website automatisch enthalten: Hosting, Wartung, technischer Support, SEO-Grundoptimierung, Performance-Check und eine persönliche Quartalsberatung.
</p>
</div>
</div>
{/* Berechnen Button */}
<Button
onClick={calculatePrice}
disabled={isLoading}
size="lg"
className="w-full px-8 py-4 rounded-full text-lg font-semibold shadow-xl hover:scale-105 transition-all duration-300 flex items-center justify-center space-x-2"
style={{
backgroundColor: colors.tertiary,
color: colors.primary
}}
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Berechne Preis...
</>
) : (
<>
<Calculator className="w-5 h-5" />
Preis berechnen
</>
)}
</Button>
{/* Ergebnis */}
{result && (
<div className="mt-8 p-6 rounded-xl border-2"
style={{
backgroundColor: `${colors.tertiary}20`,
borderColor: colors.tertiary
}}>
<h4 className="text-xl font-semibold mb-4" style={{ color: colors.tertiary }}>
Geschätzter Preis: {result.price}
</h4>
<p className="text-lg mb-4" style={{ color: colors.background }}>
{result.details}
</p>
{formData.subscription.length > 0 && (
<p className="text-lg font-medium mb-4" style={{ color: colors.tertiary }}>
+200/Monat für laufende Services
</p>
)}
<p className="text-sm" style={{ color: colors.background }}>
Dies ist ein Richtwert. Im Beratungsgespräch klären wir alle Details und finden eine Lösung, die zu Ihrem Budget passt.
</p>
</div>
)}
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}

View File

@@ -0,0 +1,33 @@
"use client"
import { useAuth } from '@/hooks/useAuth'
import AppointmentBooking from './AppointmentBooking'
import MagicLinkAuth from './MagicLinkAuth'
import { colors } from '@/lib/colors'
import { User, Shield, Info } from 'lucide-react'
export default function ProtectedAppointmentBooking() {
const { user, loading } = useAuth()
if (loading) {
return (
<div className="w-full max-w-md mx-auto">
<div
className="p-6 sm:p-8 rounded-3xl shadow-lg backdrop-blur-sm text-center"
style={{ backgroundColor: `${colors.background}F0` }}
>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 mx-auto mb-4"
style={{ borderColor: colors.primary }}></div>
<p style={{ color: colors.secondary }}>Lade...</p>
</div>
</div>
)
}
return (
<div className="w-full max-w-4xl mx-auto">
{/* Appointment Booking */}
<AppointmentBooking />
</div>
)
}

View File

@@ -0,0 +1,115 @@
"use client";
import { useEffect, useRef } from 'react';
import { colors } from '@/lib/colors';
function lerp(a: number, b: number, t: number) {
return (b - a) * t + a;
}
// Convert hex to RGB
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: { r: 0, g: 0, b: 0 };
}
// Interpolate between two hex colors
function interpolateColor(color1: string, color2: string, t: number): string {
const rgb1 = hexToRgb(color1);
const rgb2 = hexToRgb(color2);
const r = Math.round(lerp(rgb1.r, rgb2.r, t));
const g = Math.round(lerp(rgb1.g, rgb2.g, t));
const b = Math.round(lerp(rgb1.b, rgb2.b, t));
return `rgb(${r}, ${g}, ${b})`;
}
function color(i: number, total: number) {
const t = i / total;
// Interpolate between Webklar colors: primary (dark green) -> secondary (medium green) -> tertiary (light green-beige)
if (t < 0.5) {
return interpolateColor(colors.primary, colors.secondary, t * 2);
} else {
return interpolateColor(colors.secondary, colors.tertiary, (t - 0.5) * 2);
}
}
function createWheel(i: number, total: number) {
const distance = i + 3.5; // Slightly increased distance
const charWidth = 0.85;
const speed = 1;
const circum = distance * 2 * Math.PI;
const numbers = Math.floor(circum / charWidth);
const time = speed * numbers;
const t = i / total;
return {
time,
numbers,
distance,
color: color(i, total),
scale: lerp(1, 0.25, t * t * 0.5), // Medium scale
};
}
export default function SpinningNumbers() {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
const total = 13;
const wheels = Array.from({ length: total }, (_, i) => createWheel(i, total));
wheels.forEach((wheel) => {
const { time, numbers, distance, color: wheelColor, scale } = wheel;
const angleDiff = (Math.PI * 2) / numbers;
const divs = [];
for (let i = 0; i < numbers; i++) {
divs.push(angleDiff * i);
}
const wheelDiv = document.createElement('div');
wheelDiv.className = 'wheel';
wheelDiv.style.color = wheelColor;
wheelDiv.style.setProperty('--l', `${distance}em`);
wheelDiv.style.setProperty('--m', `${numbers}`);
wheelDiv.style.setProperty('--t', `${time}s`);
wheelDiv.style.setProperty('--r1', Math.random() < 0.5 ? 'reverse' : 'normal');
wheelDiv.style.setProperty('--s', `${scale}`);
divs.forEach((angle, i) => {
if (Math.sqrt(Math.random()) < scale) {
const numberDiv = document.createElement('div');
numberDiv.className = 'number';
numberDiv.style.setProperty('--a', `${(angle * 180) / Math.PI}deg`);
numberDiv.style.setProperty('--i', `${i}`);
numberDiv.style.setProperty('--r', Math.random() < 0.5 ? 'reverse' : 'normal');
wheelDiv.appendChild(numberDiv);
}
});
container.appendChild(wheelDiv);
});
// Cleanup function
return () => {
if (container) {
container.innerHTML = '';
}
};
}, []);
return (
<div className="spinning-number" ref={containerRef} />
);
}

View File

@@ -0,0 +1,69 @@
import Link from 'next/link';
import { colors } from '@/lib/colors';
export default function WebklarFooter() {
return (
<footer
className="hidden md:block relative py-8 sm:py-12 border-t rounded-t-[2rem] sm:rounded-t-[3rem] mx-2 sm:mx-4 backdrop-blur-sm"
style={{
backgroundColor: `${colors.primary}F0`,
borderColor: colors.secondary
}}
>
<div className="max-w-7xl mx-auto px-4 sm:px-8">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6 sm:gap-8">
<div className="col-span-1 sm:col-span-2">
<div
className="text-2xl sm:text-3xl font-bold mb-4 relative"
style={{ color: colors.tertiary }}
>
<span className="relative z-10">Webklar</span>
<div
className="absolute -inset-2 rounded-xl blur-sm opacity-20"
style={{ backgroundColor: colors.secondary }}
></div>
</div>
<p
className="mb-6 leading-relaxed text-sm sm:text-base"
style={{ color: colors.background }}
>
Ihr Partner für Web & Support. Moderne Websites. Klare Kommunikation. Persönlicher Support.
</p>
</div>
<div>
<h4 className="text-base sm:text-lg font-semibold mb-4" style={{ color: colors.tertiary }}>
Services
</h4>
<ul className="space-y-2 text-sm sm:text-base" style={{ color: colors.background }}>
{['Webdesign', 'E-Commerce', 'SEO', 'Hosting'].map((item) => (
<li key={item}>
<a href="#" className="hover:opacity-80 transition-opacity">{item}</a>
</li>
))}
</ul>
</div>
<div>
<h4 className="text-base sm:text-lg font-semibold mb-4" style={{ color: colors.tertiary }}>
Kontakt
</h4>
<ul className="space-y-2 text-sm sm:text-base" style={{ color: colors.background }}>
<li><Link href="/impressum" className="hover:opacity-80 transition-opacity">Impressum</Link></li>
<li><Link href="/datenschutz" className="hover:opacity-80 transition-opacity">Datenschutz</Link></li>
<li><Link href="/agb" className="hover:opacity-80 transition-opacity">AGB</Link></li>
<li><Link href="/kontakte" className="hover:opacity-80 transition-opacity">Kontakte</Link></li>
</ul>
</div>
</div>
<div
className="border-t mt-6 sm:mt-8 pt-6 sm:pt-8 text-center text-sm"
style={{
borderColor: colors.secondary,
color: colors.background
}}
>
<p>&copy; 2025 Webklar. Alle Rechte vorbehalten.</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,15 @@
import Link from 'next/link';
import { colors } from '@/lib/colors';
export default function WebklarLogoHeader() {
return (
<div className="w-full flex justify-center pt-8 pb-4">
<Link href="/">
<div className="relative text-3xl sm:text-4xl font-bold cursor-pointer select-none" style={{ color: colors.primary }}>
<span className="relative z-10">Webklar</span>
<div className="absolute -inset-2 rounded-xl blur-sm opacity-20" style={{ backgroundColor: colors.secondary }}></div>
</div>
</Link>
</div>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b', className)}
{...props}
/>
));
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,141 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
className
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

59
components/ui/alert.tsx Normal file
View File

@@ -0,0 +1,59 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: 'default',
},
}
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
));
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,7 @@
'use client';
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };

50
components/ui/avatar.tsx Normal file
View File

@@ -0,0 +1,50 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

36
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,36 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,115 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<'nav'> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = 'Breadcrumb';
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<'ol'>
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
className
)}
{...props}
/>
));
BreadcrumbList.displayName = 'BreadcrumbList';
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<'li'>
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
));
BreadcrumbItem.displayName = 'BreadcrumbItem';
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<'a'> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'a';
return (
<Comp
ref={ref}
className={cn('transition-colors hover:text-foreground', className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = 'BreadcrumbLink';
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<'span'>
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn('font-normal text-foreground', className)}
{...props}
/>
));
BreadcrumbPage.displayName = 'BreadcrumbPage';
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<'li'>) => (
<li
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
role="presentation"
aria-hidden="true"
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

56
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,56 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@@ -0,0 +1,66 @@
'use client';
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker } from 'react-day-picker';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
nav_button: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
),
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell:
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
),
day_range_end: 'day-range-end',
day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside:
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
export { Calendar };

View File

@@ -0,0 +1,143 @@
import { cn } from "@/lib/utils";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { colors } from "@/lib/colors";
export const HoverEffect = ({
items,
className,
}: {
items: {
title: string;
description: string;
link?: string;
}[];
className?: string;
}) => {
let [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
return (
<div
className={cn(
"grid grid-cols-1 md:grid-cols-2 gap-6",
className
)}
>
{items.map((item, idx) => {
const key = item?.link ?? item.title ?? idx;
const cardContent = (
<>
<AnimatePresence>
{hoveredIndex === idx && (
<motion.span
className="absolute inset-0 h-full w-full block rounded-3xl"
style={{ backgroundColor: `${colors.secondary}33` }}
layoutId="hoverBackground"
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: { duration: 0.15 },
}}
exit={{
opacity: 0,
transition: { duration: 0.15, delay: 0.2 },
}}
/>
)}
</AnimatePresence>
<Card>
<CardTitle>{item.title}</CardTitle>
<CardDescription>{item.description}</CardDescription>
</Card>
</>
);
if (item?.link) {
return (
<a
key={key}
href={item.link}
className="relative group block p-2 h-full w-full"
onMouseEnter={() => setHoveredIndex(idx)}
onMouseLeave={() => setHoveredIndex(null)}
>
{cardContent}
</a>
);
}
return (
<div
key={key}
className="relative group block p-2 h-full w-full"
onMouseEnter={() => setHoveredIndex(idx)}
onMouseLeave={() => setHoveredIndex(null)}
>
{cardContent}
</div>
);
})}
</div>
);
};
export const Card = ({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) => {
return (
<div
className={cn(
"rounded-3xl h-full w-full overflow-hidden border backdrop-blur-sm transition-all duration-300 group-hover:-translate-y-1 group-hover:shadow-lg relative z-20",
className
)}
style={{
background: `linear-gradient(135deg, ${colors.background}F2, ${colors.secondary}26)`,
borderColor: `${colors.secondary}55`,
}}
>
<div className="relative z-50 p-6">
{children}
</div>
</div>
);
};
export const CardTitle = ({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) => {
return (
<h4
className={cn("text-xl font-semibold tracking-wide", className)}
style={{ color: colors.primary }}
>
{children}
</h4>
);
};
export const CardDescription = ({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) => {
return (
<p
className={cn(
"mt-3 leading-relaxed text-base",
className
)}
style={{ color: colors.secondary }}
>
{children}
</p>
);
};

86
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,86 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter';
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

262
components/ui/carousel.tsx Normal file
View File

@@ -0,0 +1,262 @@
'use client';
import * as React from 'react';
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: 'horizontal' | 'vertical';
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error('useCarousel must be used within a <Carousel />');
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = 'horizontal',
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollPrev();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext]
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on('reInit', onSelect);
api.on('select', onSelect);
return () => {
api?.off('select', onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn('relative', className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
);
Carousel.displayName = 'Carousel';
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className
)}
{...props}
/>
</div>
);
});
CarouselContent.displayName = 'CarouselContent';
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className
)}
{...props}
/>
);
});
CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-left-12 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
});
CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-right-12 top-1/2 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
});
CarouselNext.displayName = 'CarouselNext';
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

365
components/ui/chart.tsx Normal file
View File

@@ -0,0 +1,365 @@
'use client';
import * as React from 'react';
import * as RechartsPrimitive from 'recharts';
import { cn } from '@/lib/utils';
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />');
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>['children'];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = 'Chart';
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join('\n')}
}
`
)
.join('\n'),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: 'line' | 'dot' | 'dashed';
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn('font-medium', labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== 'dot';
return (
<div
ref={ref}
className={cn(
'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl',
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
indicator === 'dot' && 'items-center'
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]',
{
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
}
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center'
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
);
ChartTooltipContent.displayName = 'ChartTooltip';
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean;
nameKey?: string;
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey },
ref
) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground'
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
);
ChartLegendContent.displayName = 'ChartLegend';
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== 'object' || payload === null) {
return undefined;
}
const payloadPayload =
'payload' in payload &&
typeof payload.payload === 'object' &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === 'string'
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -0,0 +1,30 @@
'use client';
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('flex items-center justify-center text-current')}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -0,0 +1,11 @@
'use client';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

155
components/ui/command.tsx Normal file
View File

@@ -0,0 +1,155 @@
'use client';
import * as React from 'react';
import { type DialogProps } from '@radix-ui/react-dialog';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent } from '@/components/ui/dialog';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
);
};
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,32 @@
"use client";
import { ContainerScroll } from "@/components/ui/container-scroll-animation";
export default function HeroScrollDemo() {
return (
<div className="flex flex-col overflow-hidden">
<ContainerScroll
titleComponent={
<>
<p className="text-base font-medium uppercase tracking-[0.3em] text-neutral-500 dark:text-neutral-400">
Strategieberatung & Konzeption
</p>
<h1 className="text-4xl font-semibold text-black dark:text-white">
Gemeinsam entwickeln wir die richtige digitale Strategie für Ihr Unternehmen
</h1>
</>
}
>
<img
src="/Domain-Einrichtung%20%26%20Verwaltung.gif"
alt="Domain-Einrichtung und Verwaltung"
height={720}
width={1400}
className="mx-auto h-full max-h-[540px] w-full rounded-2xl object-cover object-center"
draggable={false}
/>
</ContainerScroll>
</div>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import React, { useRef } from "react";
import { motion, MotionValue, useScroll, useTransform } from "motion/react";
export const ContainerScroll = ({
titleComponent,
children,
}: {
titleComponent: string | React.ReactNode;
children: React.ReactNode;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: containerRef,
});
const [isMobile, setIsMobile] = React.useState(false);
React.useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth <= 768);
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => {
window.removeEventListener("resize", checkMobile);
};
}, []);
const scaleDimensions = () => {
return isMobile ? [0.7, 0.9] : [1.05, 1];
};
const rotate = useTransform(scrollYProgress, [0, 1], [20, 0]);
const scale = useTransform(scrollYProgress, [0, 1], scaleDimensions());
const translate = useTransform(scrollYProgress, [0, 1], [0, -100]);
return (
<div
className="relative flex h-[60rem] items-center justify-center p-2 md:h-[80rem] md:p-20"
ref={containerRef}
>
<div
className="relative w-full py-10 md:py-40"
style={{
perspective: "1000px",
}}
>
<Header translate={translate} titleComponent={titleComponent} />
<Card rotate={rotate} translate={translate} scale={scale}>
{children}
</Card>
</div>
</div>
);
};
export const Header = ({ translate, titleComponent }: any) => {
return (
<motion.div
style={{
translateY: translate,
}}
className="div mx-auto max-w-5xl text-center"
>
{titleComponent}
</motion.div>
);
};
export const Card = ({
rotate,
scale,
children,
}: {
rotate: MotionValue<number>;
scale: MotionValue<number>;
translate: MotionValue<number>;
children: React.ReactNode;
}) => {
return (
<motion.div
style={{
rotateX: rotate,
scale,
boxShadow:
"0 0 #0000004d, 0 9px 20px #0000004a, 0 37px 37px #00000042, 0 84px 50px #00000026, 0 149px 60px #0000000a, 0 233px 65px #00000003",
}}
className="mx-auto -mt-12 h-[30rem] w-full max-w-5xl rounded-[30px] border-4 border-[#6C6C6C] bg-[#222222] p-2 shadow-2xl md:h-[40rem] md:p-6"
>
<div className=" h-full w-full overflow-hidden rounded-2xl bg-gray-100 p-3 dark:bg-zinc-900 md:rounded-2xl md:p-4 ">
{children}
</div>
</motion.div>
);
};

View File

@@ -0,0 +1,200 @@
'use client';
import * as React from 'react';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold text-foreground',
inset && 'pl-8',
className
)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

122
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,122 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

118
components/ui/drawer.tsx Normal file
View File

@@ -0,0 +1,118 @@
'use client';
import * as React from 'react';
import { Drawer as DrawerPrimitive } from 'vaul';
import { cn } from '@/lib/utils';
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
);
Drawer.displayName = 'Drawer';
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-black/80', className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background',
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = 'DrawerContent';
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('grid gap-1.5 p-4 text-center sm:text-left', className)}
{...props}
/>
);
DrawerHeader.displayName = 'DrawerHeader';
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
DrawerFooter.displayName = 'DrawerFooter';
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@@ -0,0 +1,200 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

179
components/ui/form.tsx Normal file
View File

@@ -0,0 +1,179 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from 'react-hook-form';
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label';
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = 'FormItem';
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && 'text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = 'FormLabel';
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = 'FormControl';
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
});
FormDescription.displayName = 'FormDescription';
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn('text-sm font-medium text-destructive', className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = 'FormMessage';
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@@ -0,0 +1,29 @@
'use client';
import * as React from 'react';
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
import { cn } from '@/lib/utils';
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@@ -0,0 +1,71 @@
'use client';
import * as React from 'react';
import { OTPInput, OTPInputContext } from 'input-otp';
import { Dot } from 'lucide-react';
import { cn } from '@/lib/utils';
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
'flex items-center gap-2 has-[:disabled]:opacity-50',
containerClassName
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
));
InputOTP.displayName = 'InputOTP';
const InputOTPGroup = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center', className)} {...props} />
));
InputOTPGroup.displayName = 'InputOTPGroup';
const InputOTPSlot = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
'relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md',
isActive && 'z-10 ring-2 ring-ring ring-offset-background',
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = 'InputOTPSlot';
const InputOTPSeparator = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
));
InputOTPSeparator.displayName = 'InputOTPSeparator';
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

25
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,25 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };

26
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,26 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

236
components/ui/menubar.tsx Normal file
View File

@@ -0,0 +1,236 @@
'use client';
import * as React from 'react';
import * as MenubarPrimitive from '@radix-ui/react-menubar';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const MenubarMenu = MenubarPrimitive.Menu;
const MenubarGroup = MenubarPrimitive.Group;
const MenubarPortal = MenubarPrimitive.Portal;
const MenubarSub = MenubarPrimitive.Sub;
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
'flex h-10 items-center space-x-1 rounded-md border bg-background p-1',
className
)}
{...props}
/>
));
Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
className
)}
{...props}
/>
));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = 'start', alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
);
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
);
};
MenubarShortcut.displayname = 'MenubarShortcut';
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
};

View File

@@ -0,0 +1,128 @@
import * as React from 'react';
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
import { cva } from 'class-variance-authority';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
'relative z-10 flex max-w-max flex-1 items-center justify-center',
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
'group flex flex-1 list-none items-center justify-center space-x-1',
className
)}
{...props}
/>
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva(
'group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50'
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), 'group', className)}
{...props}
>
{children}{' '}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
'left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ',
className
)}
{...props}
/>
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn('absolute left-0 top-full flex justify-center')}>
<NavigationMenuPrimitive.Viewport
className={cn(
'origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]',
className
)}
ref={ref}
{...props}
/>
</div>
));
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
'top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in',
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
));
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName;
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
};

View File

@@ -0,0 +1,117 @@
import * as React from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ButtonProps, buttonVariants } from '@/components/ui/button';
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav
role="navigation"
aria-label="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
Pagination.displayName = 'Pagination';
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
));
PaginationContent.displayName = 'PaginationContent';
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<'li'>
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn('', className)} {...props} />
));
PaginationItem.displayName = 'PaginationItem';
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, 'size'> &
React.ComponentProps<'a'>;
const PaginationLink = ({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? 'page' : undefined}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
className
)}
{...props}
/>
);
PaginationLink.displayName = 'PaginationLink';
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn('gap-1 pl-2.5', className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = 'PaginationPrevious';
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn('gap-1 pr-2.5', className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = 'PaginationNext';
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
aria-hidden
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = 'PaginationEllipsis';
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};

31
components/ui/popover.tsx Normal file
View File

@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

View File

@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '@/lib/utils';
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@@ -0,0 +1,44 @@
'use client';
import * as React from 'react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn('grid gap-2', className)}
{...props}
ref={ref}
/>
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@@ -0,0 +1,45 @@
'use client';
import { GripVertical } from 'lucide-react';
import * as ResizablePrimitive from 'react-resizable-panels';
import { cn } from '@/lib/utils';
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
className
)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@@ -0,0 +1,48 @@
'use client';
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

160
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,160 @@
'use client';
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
)
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

140
components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,140 @@
'use client';
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
{
variants: {
side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
},
},
defaultVariants: {
side: 'right',
},
}
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
);
SheetHeader.displayName = 'SheetHeader';
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
SheetFooter.displayName = 'SheetFooter';
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -0,0 +1,15 @@
import { cn } from '@/lib/utils';
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
);
}
export { Skeleton };

28
components/ui/slider.tsx Normal file
View File

@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '@/lib/utils';
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

31
components/ui/sonner.tsx Normal file
View File

@@ -0,0 +1,31 @@
'use client';
import { useTheme } from 'next-themes';
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
);
};
export { Toaster };

29
components/ui/switch.tsx Normal file
View File

@@ -0,0 +1,29 @@
'use client';
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

117
components/ui/table.tsx Normal file
View File

@@ -0,0 +1,117 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
));
Table.displayName = 'Table';
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
));
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
className
)}
{...props}
/>
));
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className
)}
{...props}
/>
));
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
));
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props}
/>
));
TableCaption.displayName = 'TableCaption';
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

55
components/ui/tabs.tsx Normal file
View File

@@ -0,0 +1,55 @@
'use client';
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,24 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = 'Textarea';
export { Textarea };

View File

@@ -0,0 +1,147 @@
"use client";
import React from "react";
import { Timeline } from "@/components/ui/timeline";
import { colors } from "@/lib/colors";
export default function TimelineDemo() {
const data = [
{
title: "01",
content: (
<div
className="rounded-2xl p-6 transition-transform duration-300 hover:-translate-y-1"
style={{
background: `linear-gradient(135deg, ${colors.background}F5, ${colors.background}E0)`,
minHeight: "260px",
}}
>
<h3
className="text-lg font-semibold"
style={{ color: colors.primary }}
>
Kennenlernen & Beratung
</h3>
<p
className="mt-4 text-sm leading-relaxed"
style={{ color: colors.secondary }}
>
Wir lernen Sie und Ihr Unternehmen kennen, sprechen über Ziele und Wünsche und schaffen
eine klare Basis für das gemeinsame Projekt.
</p>
</div>
),
},
{
title: "02",
content: (
<div
className="rounded-2xl p-6 transition-transform duration-300 hover:-translate-y-1"
style={{
background: `linear-gradient(135deg, ${colors.background}F5, ${colors.background}E0)`,
minHeight: "260px",
}}
>
<h3
className="text-lg font-semibold"
style={{ color: colors.primary }}
>
Konzept & Struktur
</h3>
<p
className="mt-4 text-sm leading-relaxed"
style={{ color: colors.secondary }}
>
Wir entwickeln Struktur, Sitemap und Content-Plan, damit jede Seite logisch aufgebaut ist
und Nutzer:innen schnell die passenden Informationen finden.
</p>
</div>
),
},
{
title: "03",
content: (
<div
className="rounded-2xl p-6 transition-transform duration-300 hover:-translate-y-1"
style={{
background: `linear-gradient(135deg, ${colors.background}F5, ${colors.background}E0)`,
minHeight: "260px",
}}
>
<h3
className="text-lg font-semibold"
style={{ color: colors.primary }}
>
Design & Umsetzung
</h3>
<p
className="mt-4 text-sm leading-relaxed"
style={{ color: colors.secondary }}
>
Wir gestalten Ihr digitales Erscheinungsbild und setzen das technische Fundament um ganz
nach Ihren Vorstellungen und abgestimmt auf Ihre Marke.
</p>
</div>
),
},
{
title: "04",
content: (
<div
className="rounded-2xl p-6 transition-transform duration-300 hover:-translate-y-1"
style={{
background: `linear-gradient(135deg, ${colors.background}F5, ${colors.background}E0)`,
minHeight: "260px",
}}
>
<h3
className="text-lg font-semibold"
style={{ color: colors.primary }}
>
Feinschliff & Go-Live
</h3>
<p
className="mt-4 text-sm leading-relaxed"
style={{ color: colors.secondary }}
>
Wir finalisieren Inhalte, testen Performance und Sicherheit und begleiten Sie beim Launch,
damit alles reibungslos läuft.
</p>
</div>
),
},
{
title: "05",
content: (
<div
className="rounded-2xl p-6 transition-transform duration-300 hover:-translate-y-1"
style={{
background: `linear-gradient(135deg, ${colors.background}F5, ${colors.background}E0)`,
minHeight: "260px",
}}
>
<h3
className="text-lg font-semibold"
style={{ color: colors.primary }}
>
Support & Pflege
</h3>
<p
className="mt-4 text-sm leading-relaxed"
style={{ color: colors.secondary }}
>
Auch nach dem Launch bleiben wir an Ihrer Seite und kümmern uns um Updates, Wartung und
Optimierungen für nachhaltigen Erfolg.
</p>
</div>
),
},
];
return (
<div className="relative w-full overflow-clip">
<Timeline data={data} />
</div>
);
}

110
components/ui/timeline.tsx Normal file
View File

@@ -0,0 +1,110 @@
"use client";
import { useScroll, useTransform, motion } from "motion/react";
import React, { useEffect, useRef, useState } from "react";
import { colors } from "@/lib/colors";
interface TimelineEntry {
title: string;
content: React.ReactNode;
}
export const Timeline = ({ data }: { data: TimelineEntry[] }) => {
const ref = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
useEffect(() => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
setHeight(rect.height);
}
}, [ref]);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start 10%", "end 50%"],
});
const heightTransform = useTransform(scrollYProgress, [0, 1], [0, height]);
const opacityTransform = useTransform(scrollYProgress, [0, 0.1], [0, 1]);
return (
<div
className="w-full font-sans md:px-10"
ref={containerRef}
style={{ backgroundColor: colors.background }}
>
<div className="max-w-7xl mx-auto py-20 px-4 md:px-8 lg:px-10">
<h2
className="text-lg md:text-4xl mb-4 max-w-4xl"
style={{ color: colors.primary }}
>
Unser Projektfahrplan im Überblick
</h2>
<p
className="text-sm md:text-base max-w-sm"
style={{ color: colors.secondary }}
>
Schritt für Schritt zur erfolgreichen Website transparent,
strukturiert und begleitet von unserem Team.
</p>
</div>
<div ref={ref} className="relative max-w-7xl mx-auto pb-20">
{data.map((item, index) => (
<div
key={index}
className="flex justify-start pt-10 md:pt-40 md:gap-10 min-h-[320px]"
>
<div className="sticky flex flex-col md:flex-row z-40 items-center top-40 self-start max-w-xs lg:max-w-sm md:w-full">
<div
className="h-10 absolute left-3 md:left-3 w-10 rounded-full flex items-center justify-center"
style={{ backgroundColor: colors.background }}
>
<div
className="h-4 w-4 rounded-full border"
style={{
backgroundColor: `${colors.secondary}4D`,
borderColor: `${colors.secondary}66`,
}}
/>
</div>
<h3
className="hidden md:block text-xl md:pl-20 md:text-5xl font-bold"
style={{ color: colors.primary }}
>
{item.title}
</h3>
</div>
<div className="relative pl-20 pr-4 md:pl-4 w-full">
<h3
className="md:hidden block text-2xl mb-4 text-left font-bold"
style={{ color: colors.primary }}
>
{item.title}
</h3>
{item.content}{" "}
</div>
</div>
))}
<div
style={{
height: height + "px",
}}
className="absolute md:left-8 left-8 top-0 overflow-hidden w-[2px] bg-[linear-gradient(to_bottom,var(--tw-gradient-stops))] from-transparent from-[0%] via-neutral-200 dark:via-neutral-700 to-transparent to-[99%] [mask-image:linear-gradient(to_bottom,transparent_0%,black_10%,black_90%,transparent_100%)] "
>
<motion.div
style={{
height: heightTransform,
opacity: opacityTransform,
}}
className="absolute inset-x-0 top-0 w-[2px] bg-gradient-to-t from-purple-500 via-blue-500 to-transparent from-[0%] via-[10%] rounded-full"
/>
</div>
</div>
</div>
);
};

129
components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,129 @@
'use client';
import * as React from 'react';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn('text-sm font-semibold', className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

35
components/ui/toaster.tsx Normal file
View File

@@ -0,0 +1,35 @@
'use client';
import { useToast } from '@/hooks/use-toast';
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from '@/components/ui/toast';
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@@ -0,0 +1,61 @@
'use client';
import * as React from 'react';
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
import { type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { toggleVariants } from '@/components/ui/toggle';
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: 'default',
variant: 'default',
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn('flex items-center justify-center gap-1', className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };

45
components/ui/toggle.tsx Normal file
View File

@@ -0,0 +1,45 @@
'use client';
import * as React from 'react';
import * as TogglePrimitive from '@radix-ui/react-toggle';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const toggleVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-10 px-3',
sm: 'h-9 px-2.5',
lg: 'h-11 px-5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants };

Some files were not shown because too many files have changed in this diff Show More