feat(branding): add global dynamic theme system and fix 2fa mail
This commit is contained in:
@@ -2,29 +2,6 @@
|
||||
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
|
||||
interface OKLCHColor {
|
||||
l: number
|
||||
c: number
|
||||
h: number
|
||||
}
|
||||
|
||||
// Monochromes Farbkonzept im OKLCH-Raum
|
||||
const BASE_BG = { l: 0.145, c: 0.01, h: 148 }
|
||||
const BASE_DIM = { l: 0.50, c: 0.01, h: 148 }
|
||||
const HIGHLIGHT_BRIGHT = { l: 0.95, c: 0.01, h: 148 }
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t
|
||||
}
|
||||
|
||||
function lerpOKLCH(c1: OKLCHColor, c2: OKLCHColor, t: number): OKLCHColor {
|
||||
return {
|
||||
l: lerp(c1.l, c2.l, t),
|
||||
c: lerp(c1.c, c2.c, t),
|
||||
h: lerp(c1.h, c2.h, t)
|
||||
}
|
||||
}
|
||||
|
||||
export function AsciiShaderBackground({ className = '' }: { className?: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
|
||||
@@ -47,6 +24,32 @@ export function AsciiShaderBackground({ className = '' }: { className?: string }
|
||||
let scrollProgress = 0
|
||||
let targetScrollProgress = 0
|
||||
|
||||
const getDynamicHue = (): number => {
|
||||
try {
|
||||
const hex = getComputedStyle(document.documentElement).getPropertyValue('--primary-custom').trim() || '#2563eb'
|
||||
let c = hex.replace('#', '')
|
||||
if (c.length === 3) c = c.split('').map(x => x + x).join('')
|
||||
const r = parseInt(c.substring(0, 2), 16) / 255
|
||||
const g = parseInt(c.substring(2, 4), 16) / 255
|
||||
const b = parseInt(c.substring(4, 6), 16) / 255
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
let h = 0
|
||||
if (max !== min) {
|
||||
const d = max - min
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break
|
||||
case g: h = (b - r) / d + 2; break
|
||||
case b: h = (r - g) / d + 4; break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
return Math.round(h * 360)
|
||||
} catch (e) {
|
||||
return 217
|
||||
}
|
||||
}
|
||||
|
||||
const resize = () => {
|
||||
const width = window.innerWidth
|
||||
const height = window.innerHeight
|
||||
@@ -99,54 +102,40 @@ export function AsciiShaderBackground({ className = '' }: { className?: string }
|
||||
return
|
||||
}
|
||||
|
||||
// Scroll-gesteuerter Dimmer
|
||||
const scrollDimFactor = (1 - scrollProgress * 0.4) * scrollFade
|
||||
const currentDimColor = {
|
||||
...BASE_DIM,
|
||||
l: BASE_DIM.l * scrollDimFactor
|
||||
}
|
||||
|
||||
ctx.font = `${fontSize}px monospace`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
const timeSec = time * 0.001
|
||||
const activeHue = getDynamicHue()
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const x = c * fontSize + fontSize / 2
|
||||
const y = r * fontSize + fontSize / 2
|
||||
|
||||
// Abstandsvektor zur gedämpften Mausposition
|
||||
const dx = x - mouse.x
|
||||
const dy = y - mouse.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
// Maus-Störung (Vektorfeld-Ablenkung): Phase & Amplitude lokal beeinflussen
|
||||
const mouseInfluence = Math.exp(-dist / 250)
|
||||
const mouseDistortion = (Math.atan2(dy, dx) + (dx + dy) * 0.003) * mouseInfluence * 3.0
|
||||
|
||||
// Diagonale 2D-Strömung / Wellen-Synthese aus Sinus & Kosinus
|
||||
const waveX = Math.sin(c * 0.08 + timeSec * 0.8 + mouseDistortion)
|
||||
const waveY = Math.cos(r * 0.08 + timeSec * 0.6 + mouseDistortion)
|
||||
const flowValue = Math.sin(waveX + waveY + (c + r) * 0.04 + timeSec * 0.4)
|
||||
|
||||
// Zeichenauswahl basierend auf Strömungsfeld
|
||||
const normalizedFlow = (flowValue + 1) * 0.5 // Range 0..1
|
||||
const normalizedFlow = (flowValue + 1) * 0.5
|
||||
const charIndex = Math.floor(normalizedFlow * chars.length) % chars.length
|
||||
const char = chars[charIndex]
|
||||
|
||||
// Leerzeichen für ruhiges Raster überspringen
|
||||
if (char === ' ') continue
|
||||
|
||||
// Helligkeits-Highlighting durch Maus-Störung und Wellenkamm
|
||||
const blendFactor = Math.min(Math.max(mouseInfluence * 0.85 + normalizedFlow * 0.15, 0), 1)
|
||||
const currentColor = lerpOKLCH(currentDimColor, HIGHLIGHT_BRIGHT, blendFactor)
|
||||
const alpha = (0.35 + blendFactor * 0.6) * scrollFade
|
||||
const lightness = 40 + Math.round(blendFactor * 50)
|
||||
|
||||
// Stärkerer Kontrast: min 0.35, max 0.95 Deckkraft
|
||||
const alpha = (0.35 + blendFactor * 0.6) * scrollDimFactor
|
||||
|
||||
ctx.fillStyle = `oklch(${currentColor.l.toFixed(3)} ${currentColor.c.toFixed(3)} ${currentColor.h.toFixed(1)} / ${alpha.toFixed(2)})`
|
||||
ctx.fillStyle = `hsl(${activeHue} 80% ${lightness}% / ${alpha.toFixed(2)})`
|
||||
ctx.fillText(char, x, y)
|
||||
}
|
||||
}
|
||||
@@ -166,7 +155,7 @@ export function AsciiShaderBackground({ className = '' }: { className?: string }
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute top-0 left-0 right-0 h-[500px] z-0 overflow-hidden bg-background pointer-events-none ${className}`}
|
||||
className={`fixed inset-0 pointer-events-none z-0 overflow-hidden ${className}`}
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 250px, rgba(0,0,0,0) 500px)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 250px, rgba(0,0,0,0) 500px)'
|
||||
@@ -181,8 +170,8 @@ export function AsciiShaderBackground({ className = '' }: { className?: string }
|
||||
style={{
|
||||
backgroundSize: '40px 40px',
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, oklch(0.31 0.012 148 / 0.4) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, oklch(0.31 0.012 148 / 0.4) 1px, transparent 1px)
|
||||
linear-gradient(to right, var(--card-border-glow, rgba(37, 99, 235, 0.15)) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--card-border-glow, rgba(37, 99, 235, 0.15)) 1px, transparent 1px)
|
||||
`
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -35,12 +35,14 @@ import { AsciiShaderBackground } from '@/components/AsciiShaderBackground'
|
||||
import { ProcessSteps } from '@/components/ProcessSteps'
|
||||
import { ScrollIndicator } from '@/components/ScrollIndicator'
|
||||
import { WorkspaceZentrale } from '@/components/WorkspaceZentrale'
|
||||
import { useTheme } from '@/components/ThemeProvider'
|
||||
|
||||
interface HomeClientProps {
|
||||
initialUser: User | null
|
||||
}
|
||||
|
||||
export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
const { branding } = useTheme()
|
||||
const [user, setUser] = useState<User | null>(initialUser)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [calendarOpen, setCalendarOpen] = useState(false)
|
||||
@@ -265,7 +267,9 @@ export function HomeClient({ initialUser }: HomeClientProps) {
|
||||
{/* Footer */}
|
||||
<footer className="w-full border-t border-slate-900 bg-slate-950 py-8 px-4 md:px-6 relative z-10">
|
||||
<div className="container max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-xs text-slate-500">© 2026 CASPOS GmbH. Alle Rechte vorbehalten.</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{branding?.developerFooter || (branding?.companyName ? `© ${new Date().getFullYear()} ${branding.companyName}. Alle Rechte vorbehalten.` : `© ${new Date().getFullYear()} B2B Shop. Alle Rechte vorbehalten.`)}
|
||||
</p>
|
||||
<nav className="flex gap-6">
|
||||
<Link className="text-xs text-slate-500 hover:text-slate-300 transition-colors" href="/impressum">Impressum</Link>
|
||||
<Link className="text-xs text-slate-500 hover:text-slate-300 transition-colors" href="/datenschutz">Datenschutz</Link>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { signOut } from "@/lib/actions/auth";
|
||||
import { resolveSupabaseUrl } from "@/lib/utils";
|
||||
import { DemoWrapper } from "./DemoWrapper";
|
||||
import { useTheme } from "@/components/ThemeProvider";
|
||||
|
||||
interface NavbarClientProps {
|
||||
user: User | null;
|
||||
@@ -18,6 +19,7 @@ interface NavbarClientProps {
|
||||
|
||||
export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||
const router = useRouter();
|
||||
const { branding } = useTheme();
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(user);
|
||||
const [userRole, setUserRole] = useState<string>(role);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
@@ -98,7 +100,15 @@ export function NavbarClient({ user, role = "partner" }: NavbarClientProps) {
|
||||
<>
|
||||
<header className="px-4 lg:px-6 h-16 flex items-center justify-between border-b border-white/5 backdrop-blur-md sticky top-0 z-50 bg-[#020617]/85">
|
||||
<Link className="flex items-center justify-center gap-2 group" href="/">
|
||||
<img src="/assets/CASPOS-logo.webp" alt="CASPOS Logo" className="h-8" />
|
||||
{branding?.logoUrl ? (
|
||||
<img src={branding.logoUrl} alt={branding.companyName || "Logo"} className="h-8 max-w-[180px] object-contain" />
|
||||
) : branding?.companyName ? (
|
||||
<span className="font-extrabold text-base tracking-tight text-white group-hover:text-primary transition">
|
||||
{branding.companyName}
|
||||
</span>
|
||||
) : (
|
||||
<img src="/assets/CASPOS-logo.webp" alt="CASPOS Logo" className="h-8" />
|
||||
)}
|
||||
<DemoWrapper>
|
||||
<span className="text-[10px] bg-amber-500/20 text-amber-400 px-2 py-0.5 rounded-full font-semibold uppercase tracking-wider border border-amber-500/30">
|
||||
Demo
|
||||
|
||||
@@ -13,8 +13,12 @@ export function ScrollIndicator() {
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-blue-600 via-cyan-400 to-emerald-400 origin-left z-50 pointer-events-none shadow-[0_0_12px_rgba(59,130,246,0.8)]"
|
||||
style={{ scaleX }}
|
||||
className="fixed top-0 left-0 right-0 h-[3px] origin-left z-50 pointer-events-none"
|
||||
style={{
|
||||
scaleX,
|
||||
background: 'linear-gradient(90deg, var(--primary-custom, #2563eb) 0%, var(--accent-custom, #38bdf8) 100%)',
|
||||
boxShadow: '0 0 12px var(--primary-custom, rgba(37,99,235,0.8))'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,30 +3,47 @@
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Sparkles, ShieldCheck, Mail, ArrowRight, Loader2, Check, Lock, Building, User } from 'lucide-react'
|
||||
import { Sparkles, ShieldCheck, Mail, ArrowRight, Loader2, Check, Lock, Building, User, MapPin, Receipt, Palette, Send } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { completeSetup } from '@/lib/actions/setup'
|
||||
import { completeSetup, testSmtpConfig } from '@/lib/actions/setup'
|
||||
import { ColorThemePicker } from '@/components/admin/ColorThemePicker'
|
||||
|
||||
export function SetupWizard() {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [testSmtpLoading, setTestSmtpLoading] = useState(false)
|
||||
const [testSmtpResult, setTestSmtpResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
|
||||
// Step 2 Form: Admin account
|
||||
// Step 2: Admin Account
|
||||
const [adminForm, setAdminForm] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
companyName: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
})
|
||||
|
||||
// Step 3 Form: SMTP Config
|
||||
// Step 3: Firmendaten & Rechnungsadresse
|
||||
const [brandingForm, setBrandingForm] = useState({
|
||||
companyName: '',
|
||||
street: '',
|
||||
zip: '',
|
||||
city: '',
|
||||
billingStreet: '',
|
||||
billingZip: '',
|
||||
billingCity: '',
|
||||
sameBillingAddress: true,
|
||||
colorScheme: 'modern_blue',
|
||||
primaryColor: '#2563eb',
|
||||
accentColor: '#38bdf8',
|
||||
})
|
||||
|
||||
// Step 5: SMTP Config
|
||||
const [smtpForm, setSmtpForm] = useState({
|
||||
host: '',
|
||||
port: '587',
|
||||
@@ -35,36 +52,67 @@ export function SetupWizard() {
|
||||
pass: '',
|
||||
})
|
||||
|
||||
// Validation checks for Admin form
|
||||
// Validations
|
||||
const isAdminFormValid =
|
||||
adminForm.email.includes('@') &&
|
||||
adminForm.password.length >= 6 &&
|
||||
adminForm.password === adminForm.confirmPassword &&
|
||||
adminForm.companyName.trim().length > 0 &&
|
||||
adminForm.firstName.trim().length > 0 &&
|
||||
adminForm.lastName.trim().length > 0
|
||||
|
||||
// Validation checks for SMTP form
|
||||
const isSmtpFormValid =
|
||||
smtpForm.host.trim().length > 0 &&
|
||||
!isNaN(Number(smtpForm.port)) &&
|
||||
smtpForm.user.trim().length > 0
|
||||
const isCompanyFormValid =
|
||||
brandingForm.companyName.trim().length > 0 &&
|
||||
brandingForm.street.trim().length > 0 &&
|
||||
brandingForm.zip.trim().length > 0 &&
|
||||
brandingForm.city.trim().length > 0 &&
|
||||
(brandingForm.sameBillingAddress ||
|
||||
(brandingForm.billingStreet.trim().length > 0 &&
|
||||
brandingForm.billingZip.trim().length > 0 &&
|
||||
brandingForm.billingCity.trim().length > 0))
|
||||
|
||||
const handleTestSmtp = async () => {
|
||||
setTestSmtpLoading(true)
|
||||
setTestSmtpResult(null)
|
||||
const res = await testSmtpConfig(
|
||||
{
|
||||
host: smtpForm.host,
|
||||
port: Number(smtpForm.port),
|
||||
secure: smtpForm.secure,
|
||||
user: smtpForm.user,
|
||||
pass: smtpForm.pass,
|
||||
},
|
||||
adminForm.email
|
||||
)
|
||||
setTestSmtpResult(res)
|
||||
setTestSmtpLoading(false)
|
||||
}
|
||||
|
||||
const handleFinishSetup = async () => {
|
||||
if (!isAdminFormValid || !isSmtpFormValid) return
|
||||
if (!isAdminFormValid || !isCompanyFormValid) return
|
||||
setLoading(true)
|
||||
setErrorMsg('')
|
||||
|
||||
try {
|
||||
// 1. Submit details via server action
|
||||
const res = await completeSetup(
|
||||
{
|
||||
email: adminForm.email,
|
||||
password: adminForm.password,
|
||||
companyName: adminForm.companyName,
|
||||
companyName: brandingForm.companyName,
|
||||
firstName: adminForm.firstName,
|
||||
lastName: adminForm.lastName,
|
||||
},
|
||||
{
|
||||
street: brandingForm.street,
|
||||
zip: brandingForm.zip,
|
||||
city: brandingForm.city,
|
||||
billingStreet: brandingForm.billingStreet,
|
||||
billingZip: brandingForm.billingZip,
|
||||
billingCity: brandingForm.billingCity,
|
||||
sameBillingAddress: brandingForm.sameBillingAddress,
|
||||
colorScheme: brandingForm.colorScheme,
|
||||
primaryColor: brandingForm.primaryColor,
|
||||
accentColor: brandingForm.accentColor,
|
||||
},
|
||||
{
|
||||
host: smtpForm.host,
|
||||
port: Number(smtpForm.port),
|
||||
@@ -80,20 +128,8 @@ export function SetupWizard() {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Automatischer Login für flüssiges Erlebnis
|
||||
const supabase = createClient()
|
||||
const { error: loginError } = await supabase.auth.signInWithPassword({
|
||||
email: adminForm.email,
|
||||
password: adminForm.password,
|
||||
})
|
||||
|
||||
if (loginError) {
|
||||
console.error('Auto login failed:', loginError)
|
||||
// Redirect anyway since setup is done
|
||||
}
|
||||
|
||||
// 3. Weiterleitung
|
||||
router.push('/')
|
||||
// Setup erfolgreich – Weiterleitung zur Login-Seite
|
||||
router.push('/auth/login?setup=success')
|
||||
router.refresh()
|
||||
} catch (e: any) {
|
||||
setErrorMsg(e.message || 'Ein unerwarteter Fehler ist aufgetreten.')
|
||||
@@ -103,19 +139,19 @@ export function SetupWizard() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#020617] text-white flex flex-col items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* Dynamic Background Glow */}
|
||||
{/* Background Glow */}
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] rounded-full bg-blue-500/10 blur-[120px]" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] rounded-full bg-purple-500/10 blur-[120px]" />
|
||||
|
||||
<div className="w-full max-w-xl relative z-10">
|
||||
<div className="w-full max-w-2xl relative z-10 my-8">
|
||||
{/* Step Indicator Header */}
|
||||
<div className="flex items-center justify-between mb-8 px-2">
|
||||
<div className="flex items-center justify-between mb-6 px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold tracking-tight text-blue-400">CASPOS</span>
|
||||
<span className="text-xs px-2.5 py-0.5 rounded-full bg-slate-900 text-slate-400 border border-slate-800 font-medium">Initialisierung</span>
|
||||
<span className="text-xl font-bold tracking-tight text-blue-400">B2B Shop</span>
|
||||
<span className="text-xs px-2.5 py-0.5 rounded-full bg-slate-900 text-slate-400 border border-slate-800 font-medium">made by hephex</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{[1, 2, 3].map((s) => (
|
||||
{[1, 2, 3, 4, 5].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${s === step ? 'w-8 bg-blue-500' : 'w-2 bg-slate-800'
|
||||
@@ -126,13 +162,13 @@ export function SetupWizard() {
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{/* STEP 1: Willkommen */}
|
||||
{step === 1 && (
|
||||
<motion.div
|
||||
key="step1"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-2xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-400 mx-auto shadow-inner">
|
||||
@@ -140,9 +176,9 @@ export function SetupWizard() {
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-3xl font-extrabold tracking-tight">Willkommen bei CASPOS!</h1>
|
||||
<p className="text-slate-400 leading-relaxed">
|
||||
Richten Sie Ihren persönlichen Lizenz- und Anfrage-Shop in wenigen Schritten ein. Wir konfigurieren Ihr Administrator-Konto und die Mailverbindung.
|
||||
<h1 className="text-3xl font-extrabold tracking-tight">B2B Shop made by hephex</h1>
|
||||
<p className="text-slate-400 leading-relaxed text-sm">
|
||||
Richten Sie Ihren persönlichen Lizenz- und Anfrage-Shop ein. Wir konfigurieren Administrator-Zugang, Firmendaten, Rechnungsadresse und Ihr persönliches Farbschema.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -153,11 +189,19 @@ export function SetupWizard() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<span>Automatische Schema-Updates auf dem neuesten Stand</span>
|
||||
<span>9 vordefinierte Farbpaletten + Custom Farbwähler</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<span>SMTP Mailversand für direkte Anfragebestätigungen</span>
|
||||
<span>Lizenzserver-Anbindung</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<span>Stammdatenverwaltung</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<span>Automatische Adress- & Rechnungsverwaltung</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,13 +215,13 @@ export function SetupWizard() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* STEP 2: Admin-Konto */}
|
||||
{step === 2 && (
|
||||
<motion.div
|
||||
key="step2"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
@@ -186,7 +230,7 @@ export function SetupWizard() {
|
||||
Admin-Konto anlegen
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Erstellen Sie den ersten Administrator-Benutzer. Dieser erhält vollen Zugriff auf das System.
|
||||
Erstellen Sie das erste Administrator-Konto für vollen Systemzugriff.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -218,19 +262,6 @@ export function SetupWizard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Unternehmen / Partnername *</Label>
|
||||
<div className="relative">
|
||||
<Building className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Input
|
||||
value={adminForm.companyName}
|
||||
onChange={(e) => setAdminForm({ ...adminForm, companyName: e.target.value })}
|
||||
placeholder="Name Ihrer Firma"
|
||||
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">E-Mail-Adresse *</Label>
|
||||
<div className="relative">
|
||||
@@ -275,7 +306,7 @@ export function SetupWizard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="ghost" onClick={() => setStep(1)} className="text-white hover:bg-white/5">
|
||||
Zurück
|
||||
</Button>
|
||||
@@ -283,6 +314,203 @@ export function SetupWizard() {
|
||||
onClick={() => setStep(3)}
|
||||
disabled={!isAdminFormValid}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
|
||||
>
|
||||
Weiter zu Firmendaten
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* STEP 3: Firmendaten & Rechnungsadresse */}
|
||||
{step === 3 && (
|
||||
<motion.div
|
||||
key="step3"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Building className="w-6 h-6 text-blue-400" />
|
||||
Firmendaten & Adressen
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Tragen Sie Ihren Firmennamen, die Anschrift und die Rechnungsadresse ein.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Firmenname *</Label>
|
||||
<div className="relative">
|
||||
<Building className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Input
|
||||
value={brandingForm.companyName}
|
||||
onChange={(e) => setBrandingForm({ ...brandingForm, companyName: e.target.value })}
|
||||
placeholder="z. B. CASPOS Software GmbH"
|
||||
className="pl-9 bg-white/5 border-white/10 text-white placeholder:text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anschrift */}
|
||||
<div className="space-y-3 pt-1 border-t border-slate-800">
|
||||
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 text-blue-400" /> Firmenanschrift
|
||||
</span>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Straße & Hausnummer *</Label>
|
||||
<Input
|
||||
value={brandingForm.street}
|
||||
onChange={(e) => setBrandingForm({ ...brandingForm, street: e.target.value })}
|
||||
placeholder="Musterstraße 12"
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-slate-600 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">PLZ *</Label>
|
||||
<Input
|
||||
value={brandingForm.zip}
|
||||
onChange={(e) => setBrandingForm({ ...brandingForm, zip: e.target.value })}
|
||||
placeholder="12345"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Ort *</Label>
|
||||
<Input
|
||||
value={brandingForm.city}
|
||||
onChange={(e) => setBrandingForm({ ...brandingForm, city: e.target.value })}
|
||||
placeholder="Musterstadt"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rechnungsadresse */}
|
||||
<div className="space-y-3 pt-2 border-t border-slate-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-slate-300 flex items-center gap-1.5">
|
||||
<Receipt className="w-3.5 h-3.5 text-blue-400" /> Rechnungsadresse
|
||||
</span>
|
||||
<label className="flex items-center gap-2 text-xs text-slate-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={brandingForm.sameBillingAddress}
|
||||
onChange={(e) =>
|
||||
setBrandingForm({ ...brandingForm, sameBillingAddress: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-slate-700 bg-white/5 text-blue-500"
|
||||
/>
|
||||
Gleiche wie Anschrift
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!brandingForm.sameBillingAddress && (
|
||||
<div className="space-y-3 p-3 rounded-xl bg-white/5 border border-white/10">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Rechnungsstraße & Nr. *</Label>
|
||||
<Input
|
||||
value={brandingForm.billingStreet}
|
||||
onChange={(e) =>
|
||||
setBrandingForm({ ...brandingForm, billingStreet: e.target.value })
|
||||
}
|
||||
placeholder="Rechnungsstraße 45"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">PLZ *</Label>
|
||||
<Input
|
||||
value={brandingForm.billingZip}
|
||||
onChange={(e) =>
|
||||
setBrandingForm({ ...brandingForm, billingZip: e.target.value })
|
||||
}
|
||||
placeholder="54321"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Ort *</Label>
|
||||
<Input
|
||||
value={brandingForm.billingCity}
|
||||
onChange={(e) =>
|
||||
setBrandingForm({ ...brandingForm, billingCity: e.target.value })
|
||||
}
|
||||
placeholder="Rechnungsstadt"
|
||||
className="bg-white/5 border-white/10 text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="ghost" onClick={() => setStep(2)} className="text-white hover:bg-white/5">
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setStep(4)}
|
||||
disabled={!isCompanyFormValid}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
|
||||
>
|
||||
Weiter zum Farbschema
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* STEP 4: Farbschema & Live-Vorschau */}
|
||||
{step === 4 && (
|
||||
<motion.div
|
||||
key="step4"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Palette className="w-6 h-6 text-blue-400" />
|
||||
Farbschema & Webshop Styling
|
||||
</h2>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Wählen Sie aus 9 vorgegebenen Farbpaletten oder erstellen Sie eine eigene Farbkombination mit Live-Vorschau.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ColorThemePicker
|
||||
colorScheme={brandingForm.colorScheme}
|
||||
primaryColor={brandingForm.primaryColor}
|
||||
accentColor={brandingForm.accentColor}
|
||||
companyName={brandingForm.companyName}
|
||||
onChange={(scheme, primary, accent) =>
|
||||
setBrandingForm({
|
||||
...brandingForm,
|
||||
colorScheme: scheme,
|
||||
primaryColor: primary,
|
||||
accentColor: accent,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="ghost" onClick={() => setStep(3)} className="text-white hover:bg-white/5">
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setStep(5)}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold"
|
||||
>
|
||||
Weiter zu SMTP
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
@@ -291,22 +519,27 @@ export function SetupWizard() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
{/* STEP 5: SMTP Mailserver */}
|
||||
{step === 5 && (
|
||||
<motion.div
|
||||
key="step3"
|
||||
key="step5"
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -15 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="glass-dark border border-white/10 rounded-3xl p-8 space-y-6 shadow-2xl"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Mail className="w-6 h-6 text-blue-400" />
|
||||
SMTP-Mailserver konfigurieren
|
||||
</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Mail className="w-6 h-6 text-blue-400" />
|
||||
SMTP-Mailserver
|
||||
</h2>
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider px-2.5 py-1 rounded-full bg-slate-800 text-amber-400 border border-amber-500/20">
|
||||
Optional
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Tragen Sie Ihre Mailserver-Daten ein, um automatisierte E-Mails an Partner und Kunden senden zu können.
|
||||
Tragen Sie Ihre Mailserver-Daten ein oder überspringen Sie diesen Schritt. Sie können die Einstellungen jederzeit im Admin-Bereich anpassen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -319,7 +552,7 @@ export function SetupWizard() {
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">SMTP Host *</Label>
|
||||
<Label className="text-slate-300 text-xs">SMTP Host</Label>
|
||||
<Input
|
||||
value={smtpForm.host}
|
||||
onChange={(e) => setSmtpForm({ ...smtpForm, host: e.target.value })}
|
||||
@@ -328,7 +561,7 @@ export function SetupWizard() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">SMTP Port *</Label>
|
||||
<Label className="text-slate-300 text-xs">SMTP Port</Label>
|
||||
<Input
|
||||
value={smtpForm.port}
|
||||
onChange={(e) => setSmtpForm({ ...smtpForm, port: e.target.value })}
|
||||
@@ -339,7 +572,7 @@ export function SetupWizard() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300 text-xs">Benutzername *</Label>
|
||||
<Label className="text-slate-300 text-xs">Benutzername</Label>
|
||||
<Input
|
||||
value={smtpForm.user}
|
||||
onChange={(e) => setSmtpForm({ ...smtpForm, user: e.target.value })}
|
||||
@@ -359,32 +592,73 @@ export function SetupWizard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="smtp_secure"
|
||||
checked={smtpForm.secure}
|
||||
onChange={(e) => setSmtpForm({ ...smtpForm, secure: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-slate-700 bg-white/5 text-blue-500 focus:ring-0 focus:ring-offset-0"
|
||||
/>
|
||||
<Label htmlFor="smtp_secure" className="text-slate-300 text-sm cursor-pointer select-none">
|
||||
Sichere Verbindung (SSL/TLS anstelle STARTTLS)
|
||||
</Label>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="smtp_secure"
|
||||
checked={smtpForm.secure}
|
||||
onChange={(e) => setSmtpForm({ ...smtpForm, secure: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-slate-700 bg-white/5 text-blue-500 focus:ring-0 focus:ring-offset-0"
|
||||
/>
|
||||
<Label htmlFor="smtp_secure" className="text-slate-300 text-sm cursor-pointer select-none">
|
||||
Sichere Verbindung (SSL/TLS)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleTestSmtp}
|
||||
disabled={testSmtpLoading || !smtpForm.host || !smtpForm.user}
|
||||
className="bg-white/10 hover:bg-white/20 text-white text-xs border border-white/10 flex items-center gap-1.5"
|
||||
>
|
||||
{testSmtpLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Testen...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-3.5 h-3.5 text-blue-400" /> Test-E-Mail senden
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{testSmtpResult && (
|
||||
<div
|
||||
className={`p-3 rounded-lg border text-xs font-medium ${
|
||||
testSmtpResult.success
|
||||
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400'
|
||||
: 'bg-red-500/10 border-red-500/30 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{testSmtpResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setStep(2)}
|
||||
onClick={() => setStep(4)}
|
||||
disabled={loading}
|
||||
className="text-white hover:bg-white/5"
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleFinishSetup}
|
||||
disabled={!isSmtpFormValid || loading}
|
||||
disabled={loading}
|
||||
className="border-slate-700 text-slate-300 hover:text-white"
|
||||
>
|
||||
Überspringen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFinishSetup}
|
||||
disabled={loading}
|
||||
className="flex-1 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white font-bold"
|
||||
>
|
||||
{loading ? (
|
||||
|
||||
111
shop/components/ThemeProvider.tsx
Normal file
111
shop/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, createContext, useContext } from 'react'
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
import { getBrandingSettings } from '@/lib/actions/branding'
|
||||
import { PRESET_COLOR_SCHEMES, BrandingSettings } from '@/lib/constants/branding'
|
||||
|
||||
function hexToHsl(hex: string): string {
|
||||
try {
|
||||
let c = hex.replace('#', '')
|
||||
if (c.length === 3) c = c.split('').map(x => x + x).join('')
|
||||
const r = parseInt(c.substring(0, 2), 16) / 255
|
||||
const g = parseInt(c.substring(2, 4), 16) / 255
|
||||
const b = parseInt(c.substring(4, 6), 16) / 255
|
||||
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
let h = 0, s = 0, l = (max + min) / 2
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break
|
||||
case g: h = (b - r) / d + 2; break
|
||||
case b: h = (r - g) / d + 4; break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
|
||||
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`
|
||||
} catch (e) {
|
||||
return '217 91% 60%'
|
||||
}
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<{
|
||||
branding: BrandingSettings | null
|
||||
refreshBranding: () => Promise<void>
|
||||
}>({
|
||||
branding: null,
|
||||
refreshBranding: async () => {},
|
||||
})
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
[key: string]: any
|
||||
}) {
|
||||
const [branding, setBranding] = useState<BrandingSettings | null>(null)
|
||||
|
||||
const loadTheme = async () => {
|
||||
try {
|
||||
const settings = await getBrandingSettings()
|
||||
setBranding(settings)
|
||||
|
||||
// Preset Ermittlung
|
||||
const preset = PRESET_COLOR_SCHEMES.find(p => p.id === settings.colorScheme) || PRESET_COLOR_SCHEMES[3]
|
||||
const isCustom = settings.colorScheme === 'custom'
|
||||
|
||||
const primary = isCustom ? (settings.primaryColor || '#2563eb') : preset.primary
|
||||
const accent = isCustom ? (settings.accentColor || '#38bdf8') : preset.accent
|
||||
|
||||
const primaryHsl = hexToHsl(primary)
|
||||
const accentHsl = hexToHsl(accent)
|
||||
|
||||
const root = document.documentElement
|
||||
|
||||
// 10 Color CSS Tokens applied globally onto :root
|
||||
root.style.setProperty('--primary', primaryHsl)
|
||||
root.style.setProperty('--ring', primaryHsl)
|
||||
root.style.setProperty('--accent', accentHsl)
|
||||
|
||||
root.style.setProperty('--primary-custom', primary)
|
||||
root.style.setProperty('--accent-custom', accent)
|
||||
|
||||
root.style.setProperty('--success-custom', settings.successColor || preset.success)
|
||||
root.style.setProperty('--warning-custom', settings.warningColor || preset.warning)
|
||||
root.style.setProperty('--destructive-custom', settings.destructiveColor || preset.destructive)
|
||||
|
||||
root.style.setProperty('--bg-glow-1', isCustom ? primary : preset.bgGlow1)
|
||||
root.style.setProperty('--bg-glow-2', isCustom ? accent : preset.bgGlow2)
|
||||
root.style.setProperty('--gradient-from', isCustom ? primary : preset.gradientFrom)
|
||||
root.style.setProperty('--gradient-to', isCustom ? accent : preset.gradientTo)
|
||||
root.style.setProperty('--card-border-glow', isCustom ? `${accent}40` : preset.cardBorder)
|
||||
root.style.setProperty('--text-highlight', isCustom ? accent : preset.textHighlight)
|
||||
root.style.setProperty('--button-bg', isCustom ? primary : preset.buttonBg)
|
||||
root.style.setProperty('--ring-color', isCustom ? primary : preset.ringColor)
|
||||
} catch (e) {
|
||||
console.error('Failed to load branding theme:', e)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTheme()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<NextThemesProvider {...props}>
|
||||
<ThemeContext.Provider value={{ branding, refreshBranding: loadTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext)
|
||||
}
|
||||
267
shop/components/admin/ColorThemePicker.tsx
Normal file
267
shop/components/admin/ColorThemePicker.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { PRESET_COLOR_SCHEMES, ColorPreset } from '@/lib/constants/branding'
|
||||
import { Check, Palette, Sparkles, Building2, ShoppingCart, Tag, Layers } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface ColorThemePickerProps {
|
||||
colorScheme: string
|
||||
primaryColor: string
|
||||
accentColor: string
|
||||
companyName?: string
|
||||
onChange: (scheme: string, primary: string, accent: string) => void
|
||||
}
|
||||
|
||||
export function ColorThemePicker({
|
||||
colorScheme,
|
||||
primaryColor,
|
||||
accentColor,
|
||||
companyName = 'CASPOS Shop',
|
||||
onChange,
|
||||
}: ColorThemePickerProps) {
|
||||
const [customPrimary, setCustomPrimary] = useState(primaryColor || '#2563eb')
|
||||
const [customAccent, setCustomAccent] = useState(accentColor || '#38bdf8')
|
||||
|
||||
const isCustom = colorScheme === 'custom'
|
||||
|
||||
const activePreset = PRESET_COLOR_SCHEMES.find(p => p.id === colorScheme) || PRESET_COLOR_SCHEMES[3]
|
||||
|
||||
const handleSelectPreset = (preset: ColorPreset) => {
|
||||
onChange(preset.id, preset.primary, preset.accent)
|
||||
}
|
||||
|
||||
const handleCustomPrimaryChange = (val: string) => {
|
||||
setCustomPrimary(val)
|
||||
onChange('custom', val, customAccent)
|
||||
}
|
||||
|
||||
const handleCustomAccentChange = (val: string) => {
|
||||
setCustomAccent(val)
|
||||
onChange('custom', customPrimary, val)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Grid of Presets */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Label className="text-sm font-bold text-slate-200 flex items-center gap-2">
|
||||
<Palette className="w-4 h-4 text-blue-400" />
|
||||
Vorgegebene Farbschemata (9 Stile mit je 10 Farbtokens)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{PRESET_COLOR_SCHEMES.map((preset) => {
|
||||
const isSelected = colorScheme === preset.id
|
||||
|
||||
const tokenSwatches = [
|
||||
preset.primary,
|
||||
preset.accent,
|
||||
preset.bgGlow1,
|
||||
preset.bgGlow2,
|
||||
preset.gradientFrom,
|
||||
preset.gradientTo,
|
||||
preset.textHighlight,
|
||||
preset.buttonBg,
|
||||
preset.ringColor,
|
||||
preset.cardBorder,
|
||||
]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectPreset(preset)}
|
||||
className={`relative p-3.5 rounded-2xl border text-left transition-all duration-200 group flex flex-col justify-between space-y-3 ${
|
||||
isSelected
|
||||
? 'bg-slate-900/90 border-blue-500 shadow-lg shadow-blue-500/10 ring-2 ring-blue-500/30'
|
||||
: 'bg-slate-950/60 border-slate-800 hover:border-slate-700 hover:bg-slate-900/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-xs text-white truncate pr-2">
|
||||
{preset.name}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<span className="w-5 h-5 rounded-full bg-blue-500 text-white flex items-center justify-center shrink-0">
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-400 line-clamp-1">
|
||||
{preset.description}
|
||||
</p>
|
||||
|
||||
{/* 10 Color Token Swatches Bar */}
|
||||
<div className="space-y-1 pt-1 border-t border-slate-800/60">
|
||||
<div className="flex items-center gap-1 justify-between">
|
||||
{tokenSwatches.map((color, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="w-3 h-3 rounded-full border border-white/10 shadow-sm shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
title={`Token ${i + 1}: ${color}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Color Picker */}
|
||||
<div
|
||||
className={`p-4 rounded-2xl border transition-all duration-200 space-y-4 ${
|
||||
isCustom
|
||||
? 'bg-slate-900/90 border-blue-500 ring-2 ring-blue-500/30'
|
||||
: 'bg-slate-950/40 border-slate-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('custom', customPrimary, customAccent)}
|
||||
className="flex items-center gap-2.5 font-bold text-xs text-white hover:text-blue-400 transition"
|
||||
>
|
||||
<span className={`w-4 h-4 rounded-full border flex items-center justify-center ${
|
||||
isCustom ? 'border-blue-500 bg-blue-500/20 text-blue-400' : 'border-slate-700'
|
||||
}`}>
|
||||
{isCustom && <Check className="w-3 h-3" />}
|
||||
</span>
|
||||
<span>Benutzerdefiniertes Farbschema (Custom Hex Picker)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isCustom && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2 border-t border-slate-800">
|
||||
{/* Primary Color Picker */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-slate-300">Hauptfarbe (Primary)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customPrimary}
|
||||
onChange={(e) => handleCustomPrimaryChange(e.target.value)}
|
||||
className="w-9 h-9 rounded-xl border border-slate-700 bg-transparent cursor-pointer shrink-0"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={customPrimary}
|
||||
onChange={(e) => handleCustomPrimaryChange(e.target.value)}
|
||||
placeholder="#2563eb"
|
||||
className="font-mono text-xs uppercase bg-slate-950 border-slate-800 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accent Color Picker */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-slate-300">Akzentfarbe (Accent)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customAccent}
|
||||
onChange={(e) => handleCustomAccentChange(e.target.value)}
|
||||
className="w-9 h-9 rounded-xl border border-slate-700 bg-transparent cursor-pointer shrink-0"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={customAccent}
|
||||
onChange={(e) => handleCustomAccentChange(e.target.value)}
|
||||
placeholder="#38bdf8"
|
||||
className="font-mono text-xs uppercase bg-slate-950 border-slate-800 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live Micro-Preview Card with Animated Background Orbs */}
|
||||
<div className="p-5 rounded-2xl bg-slate-950 border border-slate-800 space-y-3 relative overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-2.5 relative z-10">
|
||||
<span className="text-xs font-bold text-slate-400 flex items-center gap-1.5 uppercase tracking-wider">
|
||||
<Sparkles className="w-3.5 h-3.5 text-amber-400" />
|
||||
Live Vorschau & Animierter Background Glow
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-slate-500">
|
||||
{primaryColor} / {accentColor}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800/80 space-y-4 relative overflow-hidden z-10 backdrop-blur-md">
|
||||
{/* Animated Background Orbs Preview */}
|
||||
<div
|
||||
className="absolute -top-10 -left-10 w-40 h-40 rounded-full opacity-30 blur-2xl pointer-events-none transition-all duration-500"
|
||||
style={{ backgroundColor: activePreset.bgGlow1 || primaryColor }}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-10 -right-10 w-40 h-40 rounded-full opacity-30 blur-2xl pointer-events-none transition-all duration-500"
|
||||
style={{ backgroundColor: activePreset.bgGlow2 || accentColor }}
|
||||
/>
|
||||
|
||||
{/* Header Preview */}
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-slate-950/80 border border-slate-800 relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center text-white text-xs font-bold shadow-md"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
<Building2 className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<span className="font-bold text-xs text-white">
|
||||
{companyName || 'Ihr Unternehmen'}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-[10px] font-semibold px-2 py-0.5 rounded-full text-slate-950 font-bold"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
>
|
||||
Aktiv
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Buttons & Badges Preview */}
|
||||
<div className="flex flex-wrap items-center gap-2.5 relative z-10">
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-lg text-xs font-bold text-white shadow-md flex items-center gap-1.5 transition-all"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${primaryColor} 0%, ${activePreset.gradientTo || primaryColor} 100%)`,
|
||||
}}
|
||||
>
|
||||
<ShoppingCart className="w-3.5 h-3.5" />
|
||||
In den Warenkorb
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="px-3.5 py-2 rounded-lg text-xs font-semibold bg-slate-800/90 text-slate-200 border"
|
||||
style={{ borderColor: accentColor }}
|
||||
>
|
||||
Details anzeigen
|
||||
</button>
|
||||
|
||||
<span
|
||||
className="text-[10px] font-bold px-2.5 py-1 rounded-full border flex items-center gap-1"
|
||||
style={{
|
||||
borderColor: `${accentColor}40`,
|
||||
backgroundColor: `${accentColor}15`,
|
||||
color: activePreset.textHighlight || accentColor,
|
||||
}}
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
Empfohlen
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -137,8 +137,11 @@ export function LoginForm({
|
||||
}
|
||||
};
|
||||
|
||||
const isVerifyingRef = useRef(false);
|
||||
|
||||
const handleVerify2FA = async (code: string) => {
|
||||
if (!userId) return;
|
||||
if (!userId || isVerifyingRef.current) return;
|
||||
isVerifyingRef.current = true;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -160,6 +163,7 @@ export function LoginForm({
|
||||
inputRefs.current[0]?.focus();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isVerifyingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user