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

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 };

30
components/ui/tooltip.tsx Normal file
View File

@@ -0,0 +1,30 @@
'use client';
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };