feat(marketing-website): add BreakPilot marketing website with CMP integration
Multi-page marketing website positioned as "Deterministic Regulatory Engineering Platform": - 7 pages: Home, Plattform, CE-Prozess, Product Compliance, Architektur, Team, Preise - Platform Bridge animation (adapted from pitch-deck USP slide) - Cookie-Banner with consent-service integration (breakpilot-marketing site) - DE/EN language toggle + Dark/Light theme - Docker service on port 3014 [guardrail-change] PlatformBridgeSection.tsx added to loc-exceptions (816 LOC, SVG animation) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, Send, Bot, User, Sparkles, Maximize2, Minimize2 } from 'lucide-react'
|
||||
import { t } from '@/lib/content'
|
||||
import { useApp } from '@/lib/context'
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export default function ChatFAB() {
|
||||
const { lang } = useApp()
|
||||
const i = t(lang)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [isWaiting, setIsWaiting] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
setTimeout(() => inputRef.current?.focus(), 200)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
async function sendMessage(text?: string) {
|
||||
const message = text || input.trim()
|
||||
if (!message || isStreaming) return
|
||||
|
||||
setInput('')
|
||||
setMessages(prev => [...prev, { role: 'user', content: message }])
|
||||
setIsStreaming(true)
|
||||
setIsWaiting(true)
|
||||
|
||||
abortRef.current = new AbortController()
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
history: messages.slice(-10),
|
||||
}),
|
||||
signal: abortRef.current.signal,
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
|
||||
const reader = res.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let content = ''
|
||||
let firstChunk = true
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
content += decoder.decode(value, { stream: true })
|
||||
|
||||
if (firstChunk) {
|
||||
firstChunk = false
|
||||
setIsWaiting(false)
|
||||
setMessages(prev => [...prev, { role: 'assistant', content }])
|
||||
} else {
|
||||
const currentText = content
|
||||
setMessages(prev => {
|
||||
const updated = [...prev]
|
||||
updated[updated.length - 1] = { role: 'assistant', content: currentText }
|
||||
return updated
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
setIsWaiting(false)
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{ role: 'assistant', content: i.chat.error },
|
||||
])
|
||||
} finally {
|
||||
setIsStreaming(false)
|
||||
setIsWaiting(false)
|
||||
abortRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
function stopGeneration() {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort()
|
||||
setIsStreaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* FAB Button */}
|
||||
<AnimatePresence>
|
||||
{!isOpen && (
|
||||
<motion.button
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0 }}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="fixed bottom-6 right-[5.5rem] z-50 w-14 h-14 rounded-full
|
||||
bg-accent-electric hover:bg-blue-500 text-white
|
||||
flex items-center justify-center shadow-lg shadow-blue-600/30
|
||||
transition-colors"
|
||||
aria-label="Compliance Agent oeffnen"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
<circle cx="9" cy="10" r="1" fill="currentColor" />
|
||||
<circle cx="12" cy="10" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="10" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Chat Panel */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`fixed bottom-6 right-6 z-50
|
||||
${isExpanded ? 'w-[700px] h-[80vh]' : 'w-[400px] h-[520px]'}
|
||||
rounded-2xl overflow-hidden
|
||||
bg-black/90 backdrop-blur-xl border border-white/10
|
||||
shadow-2xl shadow-black/50 flex flex-col
|
||||
transition-all duration-200`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-accent-electric/20 flex items-center justify-center">
|
||||
<Bot className="w-4 h-4 text-accent-electric" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-white">{i.chat.title}</span>
|
||||
<span className="text-xs text-white/30 ml-2">
|
||||
{isStreaming ? i.chat.responding : i.chat.online}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setIsExpanded(prev => !prev)}
|
||||
className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center hover:bg-white/20 transition-colors"
|
||||
>
|
||||
{isExpanded ? <Minimize2 className="w-3.5 h-3.5 text-white/60" /> : <Maximize2 className="w-3.5 h-3.5 text-white/60" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center hover:bg-white/20 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-white/60" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||
{messages.length === 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-white/40 text-xs mb-3">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span>{i.chat.ask}</span>
|
||||
</div>
|
||||
{i.chat.suggestions.map((q, idx) => (
|
||||
<motion.button
|
||||
key={idx}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 + idx * 0.08 }}
|
||||
onClick={() => sendMessage(q)}
|
||||
className="block w-full text-left px-3 py-2.5 rounded-xl
|
||||
bg-white/[0.05] border border-white/10
|
||||
hover:bg-white/[0.1] transition-colors
|
||||
text-xs text-white/70 hover:text-white"
|
||||
>
|
||||
{q}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Waiting indicator */}
|
||||
<AnimatePresence>
|
||||
{isWaiting && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex gap-2.5"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-accent-electric/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Bot className="w-3.5 h-3.5 text-accent-electric" />
|
||||
</div>
|
||||
<div className="bg-white/[0.06] rounded-2xl px-3.5 py-3 flex items-center gap-1">
|
||||
{[0, 1, 2].map(dotIdx => (
|
||||
<motion.span
|
||||
key={dotIdx}
|
||||
className="block w-1.5 h-1.5 rounded-full bg-accent-electric/70"
|
||||
animate={{ opacity: [0.3, 1, 0.3], y: [0, -3, 0] }}
|
||||
transition={{ duration: 0.7, repeat: Infinity, delay: dotIdx * 0.15 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{messages.map((msg, idx) => (
|
||||
<div key={idx} className={`flex gap-2.5 ${msg.role === 'user' ? 'justify-end' : ''}`}>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="w-7 h-7 rounded-full bg-accent-electric/20 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Bot className="w-3.5 h-3.5 text-accent-electric" />
|
||||
</div>
|
||||
)}
|
||||
<div className={`max-w-[85%] rounded-2xl px-3.5 py-2.5 text-xs leading-relaxed ${
|
||||
msg.role === 'user' ? 'bg-accent-electric/20 text-white' : 'bg-white/[0.06] text-white/80'
|
||||
}`}>
|
||||
<div className="whitespace-pre-wrap">{msg.content}</div>
|
||||
{isStreaming && idx === messages.length - 1 && msg.role === 'assistant' && (
|
||||
<span className="inline-block w-1.5 h-3.5 bg-accent-electric animate-pulse ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
{msg.role === 'user' && (
|
||||
<div className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<User className="w-3.5 h-3.5 text-white/60" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-white/10 px-4 py-3 shrink-0">
|
||||
{isStreaming && (
|
||||
<button
|
||||
onClick={stopGeneration}
|
||||
className="w-full mb-2 px-3 py-1.5 rounded-lg bg-white/[0.06] hover:bg-white/[0.1]
|
||||
text-xs text-white/50 transition-colors"
|
||||
>
|
||||
{i.chat.stop}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
|
||||
placeholder={i.chat.placeholder}
|
||||
disabled={isStreaming}
|
||||
className="flex-1 bg-white/[0.06] border border-white/10 rounded-xl px-3.5 py-2.5
|
||||
text-xs text-white placeholder-white/30 outline-none
|
||||
focus:border-accent-electric/50 focus:ring-1 focus:ring-accent-electric/20
|
||||
disabled:opacity-50 transition-all"
|
||||
/>
|
||||
<button
|
||||
onClick={() => sendMessage()}
|
||||
disabled={isStreaming || !input.trim()}
|
||||
className="px-3.5 py-2.5 bg-accent-electric hover:bg-blue-600 disabled:opacity-30
|
||||
rounded-xl transition-all text-white"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Shield, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { useApp } from '@/lib/context'
|
||||
|
||||
const COOKIE_NAME = 'bp_consent'
|
||||
const SITE_ID = process.env.NEXT_PUBLIC_CONSENT_SITE_ID || 'breakpilot-marketing'
|
||||
|
||||
interface ConsentState {
|
||||
essential: boolean
|
||||
functional: boolean
|
||||
analytics: boolean
|
||||
}
|
||||
|
||||
const defaultConsent: ConsentState = {
|
||||
essential: true,
|
||||
functional: false,
|
||||
analytics: false,
|
||||
}
|
||||
|
||||
const texts = {
|
||||
de: {
|
||||
title: 'Cookie-Einwilligung',
|
||||
description: 'Wir verwenden Cookies, um unsere Website zu verbessern. Essenzielle Cookies sind für die Grundfunktionen erforderlich. Weitere Informationen finden Sie in unserer',
|
||||
privacyLink: 'Datenschutzerklärung',
|
||||
acceptAll: 'Alle akzeptieren',
|
||||
rejectAll: 'Nur notwendige',
|
||||
settings: 'Einstellungen',
|
||||
save: 'Auswahl speichern',
|
||||
categories: {
|
||||
essential: { name: 'Notwendig', description: 'Erforderlich für die Grundfunktionen der Website.', required: true },
|
||||
functional: { name: 'Funktional', description: 'Ermöglicht erweiterte Funktionen wie Spracheinstellungen und Theme-Präferenzen.' },
|
||||
analytics: { name: 'Analyse', description: 'Hilft uns zu verstehen, wie Besucher die Website nutzen.' },
|
||||
},
|
||||
},
|
||||
en: {
|
||||
title: 'Cookie Consent',
|
||||
description: 'We use cookies to improve our website. Essential cookies are required for basic functionality. For more information, please see our',
|
||||
privacyLink: 'Privacy Policy',
|
||||
acceptAll: 'Accept All',
|
||||
rejectAll: 'Essential Only',
|
||||
settings: 'Settings',
|
||||
save: 'Save Preferences',
|
||||
categories: {
|
||||
essential: { name: 'Essential', description: 'Required for basic website functionality.', required: true },
|
||||
functional: { name: 'Functional', description: 'Enables enhanced features like language settings and theme preferences.' },
|
||||
analytics: { name: 'Analytics', description: 'Helps us understand how visitors use the website.' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function getFingerprint(): string {
|
||||
const nav = typeof navigator !== 'undefined' ? navigator : null
|
||||
const raw = [nav?.language, nav?.platform, screen?.width, screen?.height, new Date().getTimezoneOffset()].join('|')
|
||||
let hash = 0
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0
|
||||
}
|
||||
return 'fp_' + Math.abs(hash).toString(36)
|
||||
}
|
||||
|
||||
function getSavedConsent(): ConsentState | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const stored = localStorage.getItem(COOKIE_NAME)
|
||||
if (stored) return JSON.parse(stored)
|
||||
} catch { /* ignore */ }
|
||||
return null
|
||||
}
|
||||
|
||||
async function sendConsent(consent: ConsentState) {
|
||||
try {
|
||||
await fetch('/api/consent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
site_id: SITE_ID,
|
||||
device_fingerprint: getFingerprint(),
|
||||
categories: [
|
||||
'essential',
|
||||
...(consent.functional ? ['functional'] : []),
|
||||
...(consent.analytics ? ['analytics'] : []),
|
||||
],
|
||||
vendors: [],
|
||||
user_agent: navigator.userAgent,
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
// Consent API not reachable — store locally anyway
|
||||
}
|
||||
}
|
||||
|
||||
export default function ConsentBanner() {
|
||||
const { lang } = useApp()
|
||||
const t = texts[lang]
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [consent, setConsent] = useState<ConsentState>(defaultConsent)
|
||||
|
||||
useEffect(() => {
|
||||
const saved = getSavedConsent()
|
||||
if (!saved) {
|
||||
setVisible(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const save = useCallback((state: ConsentState) => {
|
||||
localStorage.setItem(COOKIE_NAME, JSON.stringify(state))
|
||||
sendConsent(state)
|
||||
setVisible(false)
|
||||
window.dispatchEvent(new CustomEvent('consent-change', { detail: state }))
|
||||
}, [])
|
||||
|
||||
const acceptAll = () => save({ essential: true, functional: true, analytics: true })
|
||||
const rejectAll = () => save({ essential: true, functional: false, analytics: false })
|
||||
const saveSelection = () => save(consent)
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const categories = Object.entries(t.categories) as [string, { name: string; description: string; required?: boolean }][]
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="fixed bottom-0 left-0 right-0 z-[9999] p-4 md:p-6"
|
||||
>
|
||||
<div className="max-w-3xl mx-auto rounded-2xl bg-enterprise-dark/95 backdrop-blur-xl border border-white/[0.08] shadow-2xl shadow-black/40 p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-accent-electric/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Shield className="w-4 h-4 text-accent-electric" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-white mb-1">{t.title}</h3>
|
||||
<p className="text-xs text-white/50 leading-relaxed">
|
||||
{t.description}{' '}
|
||||
<a href="/datenschutz" className="text-accent-electric hover:underline">{t.privacyLink}</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category details */}
|
||||
<AnimatePresence>
|
||||
{showDetails && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden mb-4"
|
||||
>
|
||||
<div className="space-y-2 pt-2 border-t border-white/[0.06]">
|
||||
{categories.map(([key, cat]) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center justify-between p-3 rounded-xl bg-white/[0.03] border border-white/[0.06] cursor-pointer hover:bg-white/[0.05] transition-colors"
|
||||
>
|
||||
<div className="flex-1 mr-4">
|
||||
<span className="text-xs font-semibold text-white">{cat.name}</span>
|
||||
<p className="text-xs text-white/40 mt-0.5">{cat.description}</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cat.required || consent[key as keyof ConsentState]}
|
||||
disabled={cat.required}
|
||||
onChange={(e) => setConsent(prev => ({ ...prev, [key]: e.target.checked }))}
|
||||
className="w-4 h-4 rounded accent-accent-electric"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<button
|
||||
onClick={acceptAll}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-accent-electric text-white text-xs font-semibold hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
{t.acceptAll}
|
||||
</button>
|
||||
<button
|
||||
onClick={rejectAll}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-white/[0.06] border border-white/[0.08] text-white/70 text-xs font-semibold hover:bg-white/[0.1] transition-colors"
|
||||
>
|
||||
{t.rejectAll}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => showDetails ? saveSelection() : setShowDetails(true)}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-white/[0.06] border border-white/[0.08] text-white/70 text-xs font-semibold hover:bg-white/[0.1] transition-colors flex items-center justify-center gap-1.5"
|
||||
>
|
||||
{showDetails ? t.save : t.settings}
|
||||
{!showDetails && <ChevronDown className="w-3 h-3" />}
|
||||
{showDetails && <ChevronUp className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Powered by */}
|
||||
<p className="text-center mt-3 text-[10px] text-white/20 font-mono">
|
||||
Consent managed by BreakPilot CMP
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import { t } from '@/lib/content'
|
||||
import { useApp } from '@/lib/context'
|
||||
|
||||
export default function Footer() {
|
||||
const { lang } = useApp()
|
||||
const i = t(lang)
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<footer className="border-t border-white/[0.06] bg-enterprise-darker">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-12">
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-accent-electric to-accent-indigo flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">B</span>
|
||||
</div>
|
||||
<span className="font-bold text-white text-lg">BreakPilot</span>
|
||||
</div>
|
||||
<p className="mono-label mb-2">{i.footer.tagline}</p>
|
||||
<p className="text-white/30 text-sm max-w-sm">
|
||||
{i.footer.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-white/80 text-sm mb-4">Produkt</h4>
|
||||
<ul className="space-y-2">
|
||||
{i.footer.links.product.map(link => (
|
||||
<li key={link}>
|
||||
<a href={`#${link.toLowerCase()}`} className="text-sm text-white/40 hover:text-white/70 transition-colors">
|
||||
{link}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-white/80 text-sm mb-4">Rechtliches</h4>
|
||||
<ul className="space-y-2">
|
||||
{i.footer.links.legal.map(link => (
|
||||
<li key={link}>
|
||||
<a
|
||||
href={link === 'Impressum' ? '/impressum' : link === 'Datenschutz' ? '/datenschutz' : '#'}
|
||||
className="text-sm text-white/40 hover:text-white/70 transition-colors"
|
||||
>
|
||||
{link}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 pt-8 border-t border-white/[0.04] flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<p className="text-xs text-white/20">
|
||||
© {year} {i.footer.copyright}. Alle Rechte vorbehalten.
|
||||
</p>
|
||||
<p className="text-xs text-white/20 font-mono">
|
||||
{i.footer.madeIn}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { navLinks } from '@/lib/sections'
|
||||
import { useApp } from '@/lib/context'
|
||||
|
||||
interface MobileMenuProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function MobileMenu({ open, onClose }: MobileMenuProps) {
|
||||
const { lang } = useApp()
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-50 bg-black/60"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
className="fixed right-0 top-0 bottom-0 z-50 w-72 bg-enterprise-dark border-l border-white/[0.08] p-6"
|
||||
>
|
||||
<button onClick={onClose} className="absolute top-4 right-4 text-white/60 hover:text-white">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<nav className="mt-12 flex flex-col gap-1">
|
||||
<Link
|
||||
href="/"
|
||||
onClick={onClose}
|
||||
className="px-4 py-3 rounded-xl text-sm text-white/60 hover:text-white hover:bg-white/[0.06] transition-colors"
|
||||
>
|
||||
Start
|
||||
</Link>
|
||||
{navLinks.map(link => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={onClose}
|
||||
className="px-4 py-3 rounded-xl text-sm text-white/60 hover:text-white hover:bg-white/[0.06] transition-colors"
|
||||
>
|
||||
{lang === 'de' ? link.labelDe : link.labelEn}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Menu, Sun, Moon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { navLinks } from '@/lib/sections'
|
||||
import { t } from '@/lib/content'
|
||||
import { useApp } from '@/lib/context'
|
||||
import CTAButton from '@/components/ui/CTAButton'
|
||||
import MobileMenu from './MobileMenu'
|
||||
|
||||
export default function Navbar() {
|
||||
const { lang, theme, toggleLang, toggleTheme } = useApp()
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const pathname = usePathname()
|
||||
const i = t(lang)
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setScrolled(window.scrollY > 50)
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.nav
|
||||
initial={{ y: -100 }}
|
||||
animate={{ y: 0 }}
|
||||
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
className={`
|
||||
fixed top-0 left-0 right-0 z-50 transition-all duration-300
|
||||
${scrolled
|
||||
? 'bg-enterprise-dark/80 backdrop-blur-xl border-b border-white/[0.06]'
|
||||
: 'bg-transparent'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-accent-electric to-accent-indigo flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">B</span>
|
||||
</div>
|
||||
<span className="font-bold text-white text-lg">BreakPilot</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{navLinks.map(link => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`
|
||||
px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200
|
||||
${pathname === link.href
|
||||
? 'text-white bg-white/[0.08]'
|
||||
: 'text-white/50 hover:text-white hover:bg-white/[0.04]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{lang === 'de' ? link.labelDe : link.labelEn}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={toggleLang}
|
||||
className="flex items-center gap-0.5 rounded-lg bg-white/[0.06] border border-white/[0.08] overflow-hidden"
|
||||
>
|
||||
<span className={`px-2 py-1 text-xs font-medium transition-colors ${lang === 'de' ? 'bg-accent-electric text-white' : 'text-white/40'}`}>
|
||||
DE
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs font-medium transition-colors ${lang === 'en' ? 'bg-accent-electric text-white' : 'text-white/40'}`}>
|
||||
EN
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-8 h-8 rounded-lg bg-white/[0.06] border border-white/[0.08] flex items-center justify-center
|
||||
hover:bg-white/[0.1] transition-colors"
|
||||
aria-label={theme === 'dark' ? 'Light mode' : 'Dark mode'}
|
||||
>
|
||||
{theme === 'dark'
|
||||
? <Sun className="w-4 h-4 text-white/50" />
|
||||
: <Moon className="w-4 h-4 text-white/50" />
|
||||
}
|
||||
</button>
|
||||
|
||||
<CTAButton href="/preise" className="hidden sm:inline-flex text-xs px-4 py-2">
|
||||
{i.nav.cta}
|
||||
</CTAButton>
|
||||
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="md:hidden p-2 text-white/60 hover:text-white"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.nav>
|
||||
|
||||
<MobileMenu open={mobileOpen} onClose={() => setMobileOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user