feat: add pitch-deck service to core infrastructure

Migrated pitch-deck from breakpilot-pwa to breakpilot-core.
Container: bp-core-pitch-deck on port 3012.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-14 19:44:27 +01:00
parent 3739d2b8b9
commit f2a24d7341
68 changed files with 5911 additions and 0 deletions

View File

@@ -0,0 +1,420 @@
'use client'
import { useState, useRef, useEffect, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Send, Bot, User, Sparkles, Maximize2, Minimize2, ArrowRight } from 'lucide-react'
import { ChatMessage, Language, SlideId } from '@/lib/types'
import { t } from '@/lib/i18n'
interface ChatFABProps {
lang: Language
currentSlide: SlideId
currentIndex: number
visitedSlides: Set<number>
onGoToSlide: (index: number) => void
}
interface ParsedMessage {
text: string
followUps: string[]
gotos: { index: number; label: string }[]
}
function parseAgentResponse(content: string, lang: Language): ParsedMessage {
const followUps: string[] = []
const gotos: { index: number; label: string }[] = []
// Split on the follow-up separator — flexible: "---", "- - -", "___", or multiple dashes
const parts = content.split(/\n\s*[-_]{3,}\s*\n/)
let text = parts[0]
// Parse follow-up questions from second part
if (parts.length > 1) {
const qSection = parts.slice(1).join('\n')
// Match [Q], **[Q]**, or numbered/bulleted question patterns
const qMatches = qSection.matchAll(/(?:\[Q\]|\*\*\[Q\]\*\*)\s*(.+?)(?:\n|$)/g)
for (const m of qMatches) {
const q = m[1].trim().replace(/^\*\*|\*\*$/g, '')
if (q.length > 5) followUps.push(q)
}
// Fallback: if no [Q] markers found, look for numbered or bulleted questions in the section
if (followUps.length === 0) {
const lineMatches = qSection.matchAll(/(?:^|\n)\s*(?:\d+[\.\)]\s*|[-•]\s*)(.+?\?)\s*$/gm)
for (const m of lineMatches) {
const q = m[1].trim()
if (q.length > 5 && followUps.length < 3) followUps.push(q)
}
}
}
// Also look for [Q] questions anywhere in the text (sometimes model puts them without ---)
if (followUps.length === 0) {
const inlineMatches = content.matchAll(/\[Q\]\s*(.+?)(?:\n|$)/g)
const inlineQs: string[] = []
for (const m of inlineMatches) {
inlineQs.push(m[1].trim())
}
if (inlineQs.length >= 2) {
followUps.push(...inlineQs)
// Remove [Q] lines from main text
text = text.replace(/\n?\s*\[Q\]\s*.+?(?:\n|$)/g, '\n').trim()
}
}
// Parse GOTO markers from the text
const gotoRegex = /\[GOTO:(\d+)\]/g
let gotoMatch
while ((gotoMatch = gotoRegex.exec(text)) !== null) {
const slideIndex = parseInt(gotoMatch[1])
gotos.push({
index: slideIndex,
label: lang === 'de' ? `Zu Slide ${slideIndex + 1} springen` : `Jump to slide ${slideIndex + 1}`,
})
}
// Remove GOTO markers from visible text
text = text.replace(/\s*\[GOTO:\d+\]/g, '')
// Clean up trailing reminder instruction that might leak through
text = text.replace(/\n*\(Erinnerung:.*?\)\s*$/s, '').trim()
return { text: text.trim(), followUps, gotos }
}
export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlides, onGoToSlide }: ChatFABProps) {
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 [parsedResponses, setParsedResponses] = useState<Map<number, ParsedMessage>>(new Map())
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, parsedResponses])
useEffect(() => {
if (isOpen && inputRef.current) {
setTimeout(() => inputRef.current?.focus(), 200)
}
}, [isOpen])
// Parse the latest assistant message when streaming ends
const lastAssistantIndex = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'assistant') return i
}
return -1
}, [messages])
useEffect(() => {
if (!isStreaming && lastAssistantIndex >= 0 && !parsedResponses.has(lastAssistantIndex)) {
const msg = messages[lastAssistantIndex]
const parsed = parseAgentResponse(msg.content, lang)
setParsedResponses(prev => new Map(prev).set(lastAssistantIndex, parsed))
}
}, [isStreaming, lastAssistantIndex, messages, parsedResponses, lang])
async function sendMessage(text?: string) {
const message = text || input.trim()
if (!message || isStreaming) return
setInput('')
setMessages(prev => [...prev, { role: 'user', content: message }])
setIsStreaming(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),
lang,
slideContext: {
currentSlide,
currentIndex,
visitedSlides: Array.from(visitedSlides),
totalSlides: 13,
},
}),
signal: abortRef.current.signal,
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let content = ''
setMessages(prev => [...prev, { role: 'assistant', content: '' }])
while (true) {
const { done, value } = await reader.read()
if (done) break
content += decoder.decode(value, { stream: true })
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
console.error('Chat error:', err)
setMessages(prev => [
...prev,
{ role: 'assistant', content: lang === 'de'
? 'Verbindung fehlgeschlagen. Bitte versuchen Sie es erneut.'
: 'Connection failed. Please try again.'
},
])
} finally {
setIsStreaming(false)
abortRef.current = null
}
}
function stopGeneration() {
if (abortRef.current) {
abortRef.current.abort()
setIsStreaming(false)
}
}
const suggestions = i.aiqa.suggestions.slice(0, 3)
function renderMessageContent(msg: ChatMessage, idx: number) {
const parsed = parsedResponses.get(idx)
const displayText = parsed ? parsed.text : msg.content
return (
<>
<div className="whitespace-pre-wrap">{displayText}</div>
{isStreaming && idx === messages.length - 1 && msg.role === 'assistant' && (
<span className="inline-block w-1.5 h-3.5 bg-indigo-400 animate-pulse ml-0.5" />
)}
{/* GOTO Buttons */}
{parsed && parsed.gotos.length > 0 && (
<div className="mt-2 space-y-1">
{parsed.gotos.map((g, gi) => (
<button
key={gi}
onClick={() => onGoToSlide(g.index)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg
bg-indigo-500/20 border border-indigo-500/30
hover:bg-indigo-500/30 transition-colors
text-xs text-indigo-300"
>
<ArrowRight className="w-3 h-3" />
{g.label}
</button>
))}
</div>
)}
{/* Follow-Up Suggestions */}
{parsed && parsed.followUps.length > 0 && !isStreaming && (
<div className="mt-3 space-y-1.5 border-t border-white/10 pt-2">
{parsed.followUps.map((q, qi) => (
<button
key={qi}
onClick={() => sendMessage(q)}
className="block w-full text-left px-2.5 py-2 rounded-lg
bg-white/[0.05] border border-white/10
hover:bg-white/[0.1] transition-colors
text-xs text-white/60 hover:text-white/90"
>
{q}
</button>
))}
</div>
)}
</>
)
}
return (
<>
{/* FAB Button — sits to the left of NavigationFAB */}
<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-indigo-600 hover:bg-indigo-500 text-white
flex items-center justify-center shadow-lg shadow-indigo-600/30
transition-colors"
aria-label={lang === 'de' ? 'Investor Agent oeffnen' : 'Open Investor Agent'}
>
<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-indigo-500/20 flex items-center justify-center">
<Bot className="w-4 h-4 text-indigo-400" />
</div>
<div>
<span className="text-sm font-semibold text-white">Investor Agent</span>
<span className="text-xs text-white/30 ml-2">
{isStreaming
? (lang === 'de' ? 'antwortet...' : 'responding...')
: (lang === 'de' ? 'online' : '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>{lang === 'de' ? 'Fragen Sie den Investor Agent:' : 'Ask the Investor Agent:'}</span>
</div>
{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>
)}
{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-indigo-500/20 flex items-center justify-center shrink-0 mt-0.5">
<Bot className="w-3.5 h-3.5 text-indigo-400" />
</div>
)}
<div
className={`
max-w-[85%] rounded-2xl px-3.5 py-2.5 text-xs leading-relaxed
${msg.role === 'user'
? 'bg-indigo-500/20 text-white'
: 'bg-white/[0.06] text-white/80'
}
`}
>
{msg.role === 'assistant' ? renderMessageContent(msg, idx) : (
<div className="whitespace-pre-wrap">{msg.content}</div>
)}
</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"
>
{lang === 'de' ? 'Antwort stoppen' : 'Stop response'}
</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={lang === 'de' ? 'Frage stellen...' : 'Ask a question...'}
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-indigo-500/50 focus:ring-1 focus:ring-indigo-500/20
disabled:opacity-50 transition-all"
/>
<button
onClick={() => sendMessage()}
disabled={isStreaming || !input.trim()}
className="px-3.5 py-2.5 bg-indigo-500 hover:bg-indigo-600 disabled:opacity-30
rounded-xl transition-all text-white"
>
<Send className="w-3.5 h-3.5" />
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>
)
}

View File

@@ -0,0 +1,24 @@
'use client'
import { Language } from '@/lib/types'
interface LanguageToggleProps {
lang: Language
onToggle: () => void
}
export default function LanguageToggle({ lang, onToggle }: LanguageToggleProps) {
return (
<button
onClick={onToggle}
className="fixed top-4 right-4 z-40 flex items-center gap-1
px-3 py-1.5 rounded-full bg-white/[0.06] backdrop-blur-sm
border border-white/10 text-xs font-medium
hover:bg-white/[0.1] transition-all"
>
<span className={lang === 'de' ? 'text-white' : 'text-white/40'}>DE</span>
<span className="text-white/20 mx-1">|</span>
<span className={lang === 'en' ? 'text-white' : 'text-white/40'}>EN</span>
</button>
)
}

View File

@@ -0,0 +1,70 @@
'use client'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronLeft, ChevronRight } from 'lucide-react'
interface NavigationControlsProps {
onPrev: () => void
onNext: () => void
isFirst: boolean
isLast: boolean
current: number
total: number
}
export default function NavigationControls({
onPrev,
onNext,
isFirst,
isLast,
current,
total,
}: NavigationControlsProps) {
return (
<>
{/* Left Arrow */}
<AnimatePresence>
{!isFirst && (
<motion.button
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
onClick={onPrev}
className="fixed left-4 top-1/2 -translate-y-1/2 z-40
w-10 h-10 rounded-full bg-white/[0.08] backdrop-blur-sm
border border-white/10 flex items-center justify-center
hover:bg-white/[0.15] transition-all group"
>
<ChevronLeft className="w-5 h-5 text-white/60 group-hover:text-white" />
</motion.button>
)}
</AnimatePresence>
{/* Right Arrow */}
<AnimatePresence>
{!isLast && (
<motion.button
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
onClick={onNext}
className="fixed right-4 top-1/2 -translate-y-1/2 z-40
w-10 h-10 rounded-full bg-white/[0.08] backdrop-blur-sm
border border-white/10 flex items-center justify-center
hover:bg-white/[0.15] transition-all group"
>
<ChevronRight className="w-5 h-5 text-white/60 group-hover:text-white" />
</motion.button>
)}
</AnimatePresence>
{/* Slide Counter */}
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-40
px-3 py-1.5 rounded-full bg-white/[0.06] backdrop-blur-sm
border border-white/10 text-xs text-white/40 font-mono"
>
{current + 1} / {total}
</div>
</>
)
}

View File

@@ -0,0 +1,160 @@
'use client'
import { useState, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Menu, X, Maximize, Minimize, Bot } from 'lucide-react'
import { Language } from '@/lib/types'
import { t } from '@/lib/i18n'
interface NavigationFABProps {
currentIndex: number
totalSlides: number
visitedSlides: Set<number>
onGoToSlide: (index: number) => void
lang: Language
onToggleLanguage: () => void
}
export default function NavigationFAB({
currentIndex,
totalSlides,
visitedSlides,
onGoToSlide,
lang,
onToggleLanguage,
}: NavigationFABProps) {
const [isOpen, setIsOpen] = useState(false)
const [isFullscreen, setIsFullscreen] = useState(false)
const i = t(lang)
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen()
setIsFullscreen(true)
} else {
document.exitFullscreen()
setIsFullscreen(false)
}
}, [])
return (
<div className="fixed bottom-6 right-6 z-50">
<AnimatePresence mode="wait">
{!isOpen ? (
<motion.button
key="fab"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={() => setIsOpen(true)}
className="w-14 h-14 rounded-full bg-indigo-600 hover:bg-indigo-500
flex items-center justify-center shadow-lg shadow-indigo-600/30
transition-colors"
>
<Menu className="w-6 h-6 text-white" />
</motion.button>
) : (
<motion.div
key="panel"
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="w-[300px] max-h-[80vh] rounded-2xl overflow-hidden
bg-black/80 backdrop-blur-xl border border-white/10
shadow-2xl shadow-black/50"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
<span className="text-sm font-semibold text-white">{i.nav.slides}</span>
<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>
{/* Slide List */}
<div className="overflow-y-auto max-h-[55vh] py-2">
{i.slideNames.map((name, idx) => {
const isActive = idx === currentIndex
const isVisited = visitedSlides.has(idx)
const isAI = idx === totalSlides - 1
return (
<button
key={idx}
onClick={() => onGoToSlide(idx)}
className={`
w-full flex items-center gap-3 px-4 py-2.5 text-left
transition-all text-sm
${isActive
? 'bg-indigo-500/20 border-l-2 border-indigo-500 text-white'
: 'hover:bg-white/[0.06] text-white/60 hover:text-white border-l-2 border-transparent'
}
`}
>
<span className={`
w-6 h-6 rounded-full flex items-center justify-center text-xs font-mono shrink-0
${isActive
? 'bg-indigo-500 text-white'
: isVisited
? 'bg-white/10 text-white/60'
: 'bg-white/5 text-white/30'
}
`}>
{idx + 1}
</span>
<span className="flex-1 truncate">{name}</span>
{isAI && <Bot className="w-4 h-4 text-indigo-400 shrink-0" />}
{isActive && (
<span className="w-2 h-2 rounded-full bg-indigo-400 shrink-0" />
)}
</button>
)
})}
</div>
{/* Footer */}
<div className="border-t border-white/10 px-4 py-3 space-y-2">
{/* Language Toggle */}
<button
onClick={onToggleLanguage}
className="w-full flex items-center justify-between px-3 py-2 rounded-lg
bg-white/[0.05] hover:bg-white/[0.1] transition-colors text-sm"
>
<span className="text-white/50">{i.nav.language}</span>
<div className="flex items-center gap-1">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${lang === 'de' ? 'bg-indigo-500 text-white' : 'text-white/40'}`}>
DE
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${lang === 'en' ? 'bg-indigo-500 text-white' : 'text-white/40'}`}>
EN
</span>
</div>
</button>
{/* Fullscreen */}
<button
onClick={toggleFullscreen}
className="w-full flex items-center justify-between px-3 py-2 rounded-lg
bg-white/[0.05] hover:bg-white/[0.1] transition-colors text-sm"
>
<span className="text-white/50">{i.nav.fullscreen}</span>
{isFullscreen ? (
<Minimize className="w-4 h-4 text-white/50" />
) : (
<Maximize className="w-4 h-4 text-white/50" />
)}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,83 @@
'use client'
import { useEffect, useRef } from 'react'
interface Particle {
x: number
y: number
size: number
speed: number
opacity: number
}
export default function ParticleBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const particlesRef = useRef<Particle[]>([])
const frameRef = useRef<number>(0)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
function resize() {
canvas!.width = window.innerWidth
canvas!.height = window.innerHeight
}
function initParticles() {
const count = Math.min(150, Math.floor((window.innerWidth * window.innerHeight) / 8000))
particlesRef.current = Array.from({ length: count }, () => ({
x: Math.random() * canvas!.width,
y: Math.random() * canvas!.height,
size: Math.random() * 1.5 + 0.5,
speed: Math.random() * 0.3 + 0.1,
opacity: Math.random() * 0.5 + 0.1,
}))
}
function animate() {
if (!ctx || !canvas) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
for (const p of particlesRef.current) {
p.y -= p.speed
if (p.y < -10) {
p.y = canvas.height + 10
p.x = Math.random() * canvas.width
}
ctx.beginPath()
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2)
ctx.fillStyle = `rgba(255, 255, 255, ${p.opacity})`
ctx.fill()
}
frameRef.current = requestAnimationFrame(animate)
}
resize()
initParticles()
animate()
window.addEventListener('resize', () => {
resize()
initParticles()
})
return () => {
cancelAnimationFrame(frameRef.current)
window.removeEventListener('resize', resize)
}
}, [])
return (
<canvas
ref={canvasRef}
className="fixed inset-0 pointer-events-none z-0"
style={{ opacity: 0.6 }}
/>
)
}

View File

@@ -0,0 +1,171 @@
'use client'
import { useCallback, useState } from 'react'
import { AnimatePresence } from 'framer-motion'
import { useSlideNavigation } from '@/lib/hooks/useSlideNavigation'
import { useKeyboard } from '@/lib/hooks/useKeyboard'
import { usePitchData } from '@/lib/hooks/usePitchData'
import { Language, PitchData } from '@/lib/types'
import ParticleBackground from './ParticleBackground'
import ProgressBar from './ProgressBar'
import NavigationControls from './NavigationControls'
import NavigationFAB from './NavigationFAB'
import ChatFAB from './ChatFAB'
import SlideOverview from './SlideOverview'
import SlideContainer from './SlideContainer'
import CoverSlide from './slides/CoverSlide'
import ProblemSlide from './slides/ProblemSlide'
import SolutionSlide from './slides/SolutionSlide'
import ProductSlide from './slides/ProductSlide'
import HowItWorksSlide from './slides/HowItWorksSlide'
import MarketSlide from './slides/MarketSlide'
import BusinessModelSlide from './slides/BusinessModelSlide'
import TractionSlide from './slides/TractionSlide'
import CompetitionSlide from './slides/CompetitionSlide'
import TeamSlide from './slides/TeamSlide'
import FinancialsSlide from './slides/FinancialsSlide'
import TheAskSlide from './slides/TheAskSlide'
import AIQASlide from './slides/AIQASlide'
interface PitchDeckProps {
lang: Language
onToggleLanguage: () => void
}
export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
const { data, loading, error } = usePitchData()
const nav = useSlideNavigation()
const [fabOpen, setFabOpen] = useState(false)
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen()
} else {
document.exitFullscreen()
}
}, [])
const toggleMenu = useCallback(() => {
setFabOpen(prev => !prev)
}, [])
useKeyboard({
onNext: nav.nextSlide,
onPrev: nav.prevSlide,
onFirst: nav.goToFirst,
onLast: nav.goToLast,
onOverview: nav.toggleOverview,
onFullscreen: toggleFullscreen,
onLanguageToggle: onToggleLanguage,
onMenuToggle: toggleMenu,
onGoToSlide: nav.goToSlide,
enabled: !nav.showOverview,
})
if (loading) {
return (
<div className="h-screen flex items-center justify-center">
<div className="text-center">
<div className="w-12 h-12 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-white/40 text-sm">{lang === 'de' ? 'Lade Pitch-Daten...' : 'Loading pitch data...'}</p>
</div>
</div>
)
}
if (error || !data) {
return (
<div className="h-screen flex items-center justify-center">
<div className="text-center max-w-md">
<p className="text-red-400 mb-2">{lang === 'de' ? 'Fehler beim Laden' : 'Loading error'}</p>
<p className="text-white/40 text-sm">{error || 'No data'}</p>
</div>
</div>
)
}
function renderSlide() {
if (!data) return null
switch (nav.currentSlide) {
case 'cover':
return <CoverSlide lang={lang} onNext={nav.nextSlide} />
case 'problem':
return <ProblemSlide lang={lang} />
case 'solution':
return <SolutionSlide lang={lang} />
case 'product':
return <ProductSlide lang={lang} products={data.products} />
case 'how-it-works':
return <HowItWorksSlide lang={lang} />
case 'market':
return <MarketSlide lang={lang} market={data.market} />
case 'business-model':
return <BusinessModelSlide lang={lang} products={data.products} />
case 'traction':
return <TractionSlide lang={lang} milestones={data.milestones} metrics={data.metrics} />
case 'competition':
return <CompetitionSlide lang={lang} features={data.features} competitors={data.competitors} />
case 'team':
return <TeamSlide lang={lang} team={data.team} />
case 'financials':
return <FinancialsSlide lang={lang} />
case 'the-ask':
return <TheAskSlide lang={lang} funding={data.funding} />
case 'ai-qa':
return <AIQASlide lang={lang} />
default:
return null
}
}
return (
<div className="h-screen relative overflow-hidden bg-gradient-to-br from-slate-950 via-[#0a0a1a] to-slate-950">
<ParticleBackground />
<ProgressBar current={nav.currentIndex} total={nav.totalSlides} />
<SlideContainer slideKey={nav.currentSlide} direction={nav.direction}>
{renderSlide()}
</SlideContainer>
<NavigationControls
onPrev={nav.prevSlide}
onNext={nav.nextSlide}
isFirst={nav.isFirst}
isLast={nav.isLast}
current={nav.currentIndex}
total={nav.totalSlides}
/>
<ChatFAB
lang={lang}
currentSlide={nav.currentSlide}
currentIndex={nav.currentIndex}
visitedSlides={nav.visitedSlides}
onGoToSlide={nav.goToSlide}
/>
<NavigationFAB
currentIndex={nav.currentIndex}
totalSlides={nav.totalSlides}
visitedSlides={nav.visitedSlides}
onGoToSlide={nav.goToSlide}
lang={lang}
onToggleLanguage={onToggleLanguage}
/>
<AnimatePresence>
{nav.showOverview && (
<SlideOverview
currentIndex={nav.currentIndex}
onGoToSlide={nav.goToSlide}
onClose={() => nav.setShowOverview(false)}
lang={lang}
/>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,23 @@
'use client'
import { motion } from 'framer-motion'
interface ProgressBarProps {
current: number
total: number
}
export default function ProgressBar({ current, total }: ProgressBarProps) {
const progress = ((current + 1) / total) * 100
return (
<div className="fixed top-0 left-0 right-0 z-50 h-1 bg-white/5">
<motion.div
className="h-full bg-gradient-to-r from-indigo-500 via-purple-500 to-blue-500"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.4, ease: 'easeOut' }}
/>
</div>
)
}

View File

@@ -0,0 +1,53 @@
'use client'
import { ReactNode } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
interface SlideContainerProps {
children: ReactNode
slideKey: string
direction: number
}
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? '30%' : '-30%',
opacity: 0,
scale: 0.95,
}),
center: {
x: 0,
opacity: 1,
scale: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? '30%' : '-30%',
opacity: 0,
scale: 0.95,
}),
}
export default function SlideContainer({ children, slideKey, direction }: SlideContainerProps) {
return (
<AnimatePresence mode="wait" custom={direction}>
<motion.div
key={slideKey}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: 'spring', stiffness: 300, damping: 30 },
opacity: { duration: 0.3 },
scale: { duration: 0.3 },
}}
className="absolute inset-0 flex items-center justify-center overflow-y-auto"
>
<div className="w-full max-w-6xl mx-auto px-6 py-12 md:py-16">
{children}
</div>
</motion.div>
</AnimatePresence>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
import { motion } from 'framer-motion'
import { Language } from '@/lib/types'
import { t } from '@/lib/i18n'
interface SlideOverviewProps {
currentIndex: number
onGoToSlide: (index: number) => void
onClose: () => void
lang: Language
}
export default function SlideOverview({ currentIndex, onGoToSlide, onClose, lang }: SlideOverviewProps) {
const i = t(lang)
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/90 backdrop-blur-sm flex items-center justify-center p-8"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 max-w-5xl w-full"
onClick={(e) => e.stopPropagation()}
>
{i.slideNames.map((name, idx) => (
<motion.button
key={idx}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.03 }}
onClick={() => onGoToSlide(idx)}
className={`
aspect-video rounded-xl p-4 text-left
border transition-all
${idx === currentIndex
? 'bg-indigo-500/20 border-indigo-500 shadow-lg shadow-indigo-500/20'
: 'bg-white/[0.05] border-white/10 hover:bg-white/[0.1] hover:border-white/20'
}
`}
>
<span className="text-xs font-mono text-white/40 block mb-1">{idx + 1}</span>
<span className={`text-sm font-medium ${idx === currentIndex ? 'text-white' : 'text-white/70'}`}>
{name}
</span>
</motion.button>
))}
</motion.div>
</motion.div>
)
}

View File

@@ -0,0 +1,40 @@
'use client'
import { Language } from '@/lib/types'
import { t } from '@/lib/i18n'
import { Bot } from 'lucide-react'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import ChatInterface from '../ui/ChatInterface'
import LiveIndicator from '../ui/LiveIndicator'
interface AIQASlideProps {
lang: Language
}
export default function AIQASlide({ lang }: AIQASlideProps) {
const i = t(lang)
return (
<div>
<FadeInView className="text-center mb-8">
<div className="flex items-center justify-center gap-3 mb-4">
<div className="w-12 h-12 rounded-xl bg-indigo-500/20 flex items-center justify-center">
<Bot className="w-6 h-6 text-indigo-400" />
</div>
<LiveIndicator />
</div>
<h2 className="text-4xl md:text-5xl font-bold mb-3">
<GradientText>{i.aiqa.title}</GradientText>
</h2>
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.aiqa.subtitle}</p>
</FadeInView>
<FadeInView delay={0.3}>
<div className="max-w-3xl mx-auto bg-white/[0.04] border border-white/10 rounded-2xl p-6">
<ChatInterface lang={lang} />
</div>
</FadeInView>
</div>
)
}

View File

@@ -0,0 +1,99 @@
'use client'
import { motion } from 'framer-motion'
import { Language, PitchProduct } from '@/lib/types'
import { t } from '@/lib/i18n'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import GlassCard from '../ui/GlassCard'
import { DollarSign, Repeat, TrendingUp } from 'lucide-react'
interface BusinessModelSlideProps {
lang: Language
products: PitchProduct[]
}
export default function BusinessModelSlide({ lang, products }: BusinessModelSlideProps) {
const i = t(lang)
return (
<div>
<FadeInView className="text-center mb-12">
<h2 className="text-4xl md:text-5xl font-bold mb-3">
<GradientText>{i.businessModel.title}</GradientText>
</h2>
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.businessModel.subtitle}</p>
</FadeInView>
{/* Key Metrics */}
<div className="grid md:grid-cols-3 gap-4 mb-8">
<GlassCard delay={0.2} className="text-center">
<Repeat className="w-6 h-6 text-indigo-400 mx-auto mb-2" />
<p className="text-sm text-white/50 mb-1">{i.businessModel.recurringRevenue}</p>
<p className="text-2xl font-bold text-white">100%</p>
<p className="text-xs text-white/30">SaaS / Subscription</p>
</GlassCard>
<GlassCard delay={0.3} className="text-center">
<DollarSign className="w-6 h-6 text-green-400 mx-auto mb-2" />
<p className="text-sm text-white/50 mb-1">{i.businessModel.margin}</p>
<p className="text-2xl font-bold text-white">&gt;70%</p>
<p className="text-xs text-white/30">{lang === 'de' ? 'nach Amortisation' : 'post amortization'}</p>
</GlassCard>
<GlassCard delay={0.4} className="text-center">
<TrendingUp className="w-6 h-6 text-purple-400 mx-auto mb-2" />
<p className="text-sm text-white/50 mb-1">{i.businessModel.amortization}</p>
<p className="text-2xl font-bold text-white">24 {i.businessModel.months}</p>
<p className="text-xs text-white/30">{lang === 'de' ? 'Hardware-Amortisation' : 'Hardware Amortization'}</p>
</GlassCard>
</div>
{/* Unit Economics per Product */}
<FadeInView delay={0.5}>
<h3 className="text-lg font-semibold mb-4 text-white/70">{i.businessModel.unitEconomics}</h3>
<div className="grid md:grid-cols-3 gap-4">
{products.map((p, idx) => {
const amort = p.hardware_cost_eur > 0 ? Math.round(p.hardware_cost_eur / 24) : 0
const monthlyMargin = p.monthly_price_eur - amort - (p.operating_cost_eur > 0 ? p.operating_cost_eur : 0)
const marginPct = Math.round((monthlyMargin / p.monthly_price_eur) * 100)
return (
<motion.div
key={p.id}
initial={{ opacity: 0, rotateY: -15 }}
animate={{ opacity: 1, rotateY: 0 }}
transition={{ delay: 0.6 + idx * 0.15 }}
className="bg-white/[0.05] border border-white/10 rounded-2xl p-5"
>
<h4 className="font-bold text-white mb-3">{p.name}</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-white/50">{lang === 'de' ? 'Monatspreis' : 'Monthly Price'}</span>
<span className="text-white font-medium">{p.monthly_price_eur} EUR</span>
</div>
{p.hardware_cost_eur > 0 && (
<div className="flex justify-between">
<span className="text-white/50">{i.businessModel.hardwareCost}</span>
<span className="text-white/70">-{amort} EUR/Mo</span>
</div>
)}
{p.operating_cost_eur > 0 && (
<div className="flex justify-between">
<span className="text-white/50">{i.businessModel.operatingCost}</span>
<span className="text-white/70">-{p.operating_cost_eur.toLocaleString('de-DE')} EUR/Mo</span>
</div>
)}
<div className="border-t border-white/10 pt-2 flex justify-between">
<span className="text-white/50">{i.businessModel.margin}</span>
<span className={`font-bold ${marginPct > 0 ? 'text-green-400' : 'text-red-400'}`}>
{marginPct > 0 ? '+' : ''}{monthlyMargin} EUR ({marginPct}%)
</span>
</div>
</div>
</motion.div>
)
})}
</div>
</FadeInView>
</div>
)
}

View File

@@ -0,0 +1,107 @@
'use client'
import { Language, PitchFeature, PitchCompetitor } from '@/lib/types'
import { t } from '@/lib/i18n'
import { ShieldCheck, Code2, ScanLine, FileSearch, Package, Bug } from 'lucide-react'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import FeatureMatrix from '../ui/FeatureMatrix'
import GlassCard from '../ui/GlassCard'
import BrandName from '../ui/BrandName'
interface CompetitionSlideProps {
lang: Language
features: PitchFeature[]
competitors: PitchCompetitor[]
}
const securityFeatures = {
de: [
{ icon: ShieldCheck, title: 'DevSecOps Security Suite', desc: '6 integrierte Security-Tools fuer kontinuierliche Sicherheitsueberwachung' },
{ icon: ScanLine, title: 'SAST & Secrets Detection', desc: 'Automatische Code-Analyse (Semgrep) + Secrets-Scanning (Gitleaks) in der CI/CD Pipeline' },
{ icon: Bug, title: 'Container & Dependency Scanning', desc: 'Trivy + Grype scannen Container-Images und Abhaengigkeiten auf CVEs' },
{ icon: Package, title: 'SBOM-Generator (NIS2-konform)', desc: 'CycloneDX/SPDX Software Bill of Materials fuer NIS2 und ISO 27001 Compliance' },
{ icon: FileSearch, title: 'Software-Risikoanalyse', desc: 'Automatisierte Risikoklassifizierung fuer Embedded-Entwicklung und AI-Act-konforme Systeme' },
{ icon: Code2, title: 'KI-Code-Assistent (1000b)', desc: 'Das Cloud-LLM unterstuetzt Entwickler bei Code-Reviews, Security-Fixes und Compliance-Dokumentation' },
],
en: [
{ icon: ShieldCheck, title: 'DevSecOps Security Suite', desc: '6 integrated security tools for continuous security monitoring' },
{ icon: ScanLine, title: 'SAST & Secrets Detection', desc: 'Automatic code analysis (Semgrep) + secrets scanning (Gitleaks) in CI/CD pipeline' },
{ icon: Bug, title: 'Container & Dependency Scanning', desc: 'Trivy + Grype scan container images and dependencies for CVEs' },
{ icon: Package, title: 'SBOM Generator (NIS2 compliant)', desc: 'CycloneDX/SPDX Software Bill of Materials for NIS2 and ISO 27001 compliance' },
{ icon: FileSearch, title: 'Software Risk Analysis', desc: 'Automated risk classification for embedded development and AI Act compliant systems' },
{ icon: Code2, title: 'AI Code Assistant (1000b)', desc: 'Cloud LLM assists developers with code reviews, security fixes and compliance documentation' },
],
}
export default function CompetitionSlide({ lang, features, competitors }: CompetitionSlideProps) {
const i = t(lang)
const coreFeatures = features.filter(f => f.category !== 'security')
const secFeats = securityFeatures[lang]
return (
<div>
<FadeInView className="text-center mb-8">
<h2 className="text-4xl md:text-5xl font-bold mb-3">
<GradientText>{i.competition.title}</GradientText>
</h2>
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.competition.subtitle}</p>
</FadeInView>
{/* Feature Matrix (Core Compliance) */}
<FadeInView delay={0.3}>
<GlassCard className="mb-6 p-4 overflow-x-auto" hover={false}>
<FeatureMatrix features={coreFeatures} lang={lang} />
</GlassCard>
</FadeInView>
{/* Security & Developer Features — nur bei ComplAI */}
<FadeInView delay={0.5}>
<div className="mb-6">
<h3 className="text-sm font-semibold text-indigo-400 mb-3 flex items-center gap-2">
<ShieldCheck className="w-4 h-4" />
{lang === 'de' ? <>Integrierte Security &amp; Developer Tools nur bei <BrandName /></> : <>Integrated Security &amp; Developer Tools <BrandName /> only</>}
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{secFeats.map((feat, idx) => {
const Icon = feat.icon
return (
<FadeInView key={idx} delay={0.6 + idx * 0.08}>
<div className="bg-indigo-500/5 border border-indigo-500/10 rounded-xl p-3">
<div className="flex items-center gap-2 mb-1.5">
<Icon className="w-4 h-4 text-indigo-400 shrink-0" />
<span className="text-xs font-semibold text-white">{feat.title}</span>
</div>
<p className="text-[11px] text-white/40 leading-relaxed">{feat.desc}</p>
</div>
</FadeInView>
)
})}
</div>
</div>
</FadeInView>
{/* Competitor Summary */}
<div className="grid md:grid-cols-3 gap-4">
{competitors.map((c, idx) => (
<FadeInView key={c.id} delay={0.9 + idx * 0.1}>
<div className="bg-white/[0.04] border border-white/5 rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-white/70">{c.name}</h4>
<span className="text-xs text-white/30">{c.customers_count.toLocaleString()} {lang === 'de' ? 'Kunden' : 'customers'}</span>
</div>
<p className="text-xs text-white/40 mb-2">{c.pricing_range}</p>
<div className="flex flex-wrap gap-1">
{(c.weaknesses || []).slice(0, 2).map((w, widx) => (
<span key={widx} className="text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-400">
{w}
</span>
))}
</div>
</div>
</FadeInView>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,93 @@
'use client'
import { motion } from 'framer-motion'
import { Language } from '@/lib/types'
import { t } from '@/lib/i18n'
import { ArrowRight } from 'lucide-react'
import GradientText from '../ui/GradientText'
import BrandName from '../ui/BrandName'
interface CoverSlideProps {
lang: Language
onNext: () => void
}
export default function CoverSlide({ lang, onNext }: CoverSlideProps) {
const i = t(lang)
return (
<div className="flex flex-col items-center justify-center text-center min-h-[70vh]">
{/* Logo / Brand */}
<motion.div
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}
className="mb-8"
>
<div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600
flex items-center justify-center shadow-lg shadow-indigo-500/30">
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
<path d="M8 12L20 6L32 12V28L20 34L8 28V12Z" stroke="white" strokeWidth="2" fill="none" />
<path d="M20 6V34" stroke="white" strokeWidth="1.5" opacity="0.5" />
<path d="M8 12L32 28" stroke="white" strokeWidth="1.5" opacity="0.3" />
<path d="M32 12L8 28" stroke="white" strokeWidth="1.5" opacity="0.3" />
<circle cx="20" cy="20" r="4" fill="white" opacity="0.8" />
</svg>
</div>
</motion.div>
{/* Company Name */}
<motion.h1
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3 }}
className="text-5xl md:text-7xl font-bold mb-4 tracking-tight"
>
BreakPilot{' '}
<motion.span
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.5 }}
>
<BrandName className="text-5xl md:text-7xl font-bold" />
</motion.span>
</motion.h1>
{/* Tagline */}
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.8, delay: 0.6 }}
className="text-xl md:text-2xl text-white/60 mb-2 max-w-2xl"
>
{i.cover.tagline}
</motion.p>
{/* Subtitle */}
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.8 }}
className="text-sm text-white/30 font-mono tracking-wider mb-12"
>
{i.cover.subtitle}
</motion.p>
{/* CTA */}
<motion.button
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1.2 }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={onNext}
className="group flex items-center gap-2 px-8 py-3 rounded-full
bg-indigo-500 hover:bg-indigo-600 transition-colors text-white font-medium
shadow-lg shadow-indigo-500/30"
>
{i.cover.cta}
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</motion.button>
</div>
)
}

View File

@@ -0,0 +1,277 @@
'use client'
import { useState } from 'react'
import { Language } from '@/lib/types'
import { t } from '@/lib/i18n'
import { useFinancialModel } from '@/lib/hooks/useFinancialModel'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import FinancialChart from '../ui/FinancialChart'
import FinancialSliders from '../ui/FinancialSliders'
import KPICard from '../ui/KPICard'
import RunwayGauge from '../ui/RunwayGauge'
import WaterfallChart from '../ui/WaterfallChart'
import UnitEconomicsCards from '../ui/UnitEconomicsCards'
import ScenarioSwitcher from '../ui/ScenarioSwitcher'
import AnnualPLTable from '../ui/AnnualPLTable'
import AnnualCashflowChart from '../ui/AnnualCashflowChart'
type FinTab = 'overview' | 'guv' | 'cashflow'
interface FinancialsSlideProps {
lang: Language
}
export default function FinancialsSlide({ lang }: FinancialsSlideProps) {
const i = t(lang)
const fm = useFinancialModel()
const [activeTab, setActiveTab] = useState<FinTab>('overview')
const de = lang === 'de'
const activeResults = fm.activeResults
const summary = activeResults?.summary
const lastResult = activeResults?.results[activeResults.results.length - 1]
// Build scenario color map
const scenarioColors: Record<string, string> = {}
fm.scenarios.forEach(s => { scenarioColors[s.id] = s.color })
// Build compare results (exclude active scenario)
const compareResults = new Map(
Array.from(fm.results.entries()).filter(([id]) => id !== fm.activeScenarioId)
)
// Initial funding from assumptions
const initialFunding = (fm.activeScenario?.assumptions.find(a => a.key === 'initial_funding')?.value as number) || 200000
const tabs: { id: FinTab; label: string }[] = [
{ id: 'overview', label: de ? 'Uebersicht' : 'Overview' },
{ id: 'guv', label: de ? 'GuV (Jahres)' : 'P&L (Annual)' },
{ id: 'cashflow', label: de ? 'Cashflow & Finanzbedarf' : 'Cashflow & Funding' },
]
if (fm.loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
</div>
)
}
return (
<div className="max-w-6xl mx-auto px-4">
<FadeInView className="text-center mb-3">
<h2 className="text-3xl md:text-4xl font-bold mb-1">
<GradientText>{i.financials.title}</GradientText>
</h2>
<p className="text-sm text-white/50 max-w-2xl mx-auto">{i.financials.subtitle}</p>
</FadeInView>
{/* Hero KPI Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-3">
<KPICard
label={`ARR 2030`}
value={summary ? Math.round(summary.final_arr / 1_000_000 * 10) / 10 : 0}
suffix=" Mio."
decimals={1}
trend="up"
color="#6366f1"
delay={0.1}
subLabel="EUR"
/>
<KPICard
label={de ? 'Kunden 2030' : 'Customers 2030'}
value={summary?.final_customers || 0}
trend="up"
color="#22c55e"
delay={0.15}
/>
<KPICard
label="Break-Even"
value={summary?.break_even_month || 0}
suffix={de ? ' Mo.' : ' mo.'}
trend={summary?.break_even_month && summary.break_even_month <= 24 ? 'up' : 'down'}
color="#eab308"
delay={0.2}
subLabel={summary?.break_even_month ? `~${Math.ceil((summary.break_even_month) / 12) + 2025}` : ''}
/>
<KPICard
label="LTV/CAC"
value={summary?.final_ltv_cac || 0}
suffix="x"
decimals={1}
trend={(summary?.final_ltv_cac || 0) >= 3 ? 'up' : 'down'}
color="#a855f7"
delay={0.25}
/>
</div>
{/* Tab Navigation */}
<div className="flex items-center gap-1 mb-3">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-3 py-1.5 rounded-lg text-xs transition-all
${activeTab === tab.id
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
: 'bg-white/[0.04] text-white/40 border border-transparent hover:text-white/60 hover:bg-white/[0.06]'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Main content: 3-column layout */}
<div className="grid md:grid-cols-12 gap-3">
{/* Left: Charts (8 columns) */}
<div className="md:col-span-8 space-y-3">
{/* TAB: Overview — monatlicher Chart + Waterfall + Unit Economics */}
{activeTab === 'overview' && (
<>
<FadeInView delay={0.1}>
<div className="bg-white/[0.05] backdrop-blur-xl border border-white/10 rounded-2xl p-3">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-white/40">
{de ? 'Umsatz vs. Kosten (60 Monate)' : 'Revenue vs. Costs (60 months)'}
</p>
<div className="flex items-center gap-3 text-[9px]">
<span className="flex items-center gap-1"><span className="w-2 h-0.5 bg-indigo-500 inline-block" /> {de ? 'Umsatz' : 'Revenue'}</span>
<span className="flex items-center gap-1"><span className="w-2 h-0.5 bg-red-400 inline-block" style={{ borderBottom: '1px dashed' }} /> {de ? 'Kosten' : 'Costs'}</span>
<span className="flex items-center gap-1"><span className="w-2 h-0.5 bg-emerald-500 inline-block" /> {de ? 'Kunden' : 'Customers'}</span>
</div>
</div>
<FinancialChart
activeResults={activeResults}
compareResults={compareResults}
compareMode={fm.compareMode}
scenarioColors={scenarioColors}
lang={lang}
/>
</div>
</FadeInView>
<div className="grid md:grid-cols-2 gap-3">
<FadeInView delay={0.2}>
<div className="bg-white/[0.05] backdrop-blur-xl border border-white/10 rounded-2xl p-3">
<p className="text-xs text-white/40 mb-2">
{de ? 'Cash-Flow (Quartal)' : 'Cash Flow (Quarterly)'}
</p>
{activeResults && <WaterfallChart results={activeResults.results} lang={lang} />}
</div>
</FadeInView>
<FadeInView delay={0.25}>
<div className="space-y-3">
<div className="bg-white/[0.05] backdrop-blur-xl border border-white/10 rounded-2xl p-3 flex justify-center">
<RunwayGauge
months={lastResult?.runway_months || 0}
size={120}
label={de ? 'Runway (Monate)' : 'Runway (months)'}
/>
</div>
{lastResult && (
<UnitEconomicsCards
cac={lastResult.cac_eur}
ltv={lastResult.ltv_eur}
ltvCacRatio={lastResult.ltv_cac_ratio}
grossMargin={lastResult.gross_margin_pct}
churnRate={fm.activeScenario?.assumptions.find(a => a.key === 'churn_rate_monthly')?.value as number || 3}
lang={lang}
/>
)}
</div>
</FadeInView>
</div>
</>
)}
{/* TAB: GuV — Annual P&L Table */}
{activeTab === 'guv' && activeResults && (
<FadeInView delay={0.1}>
<div className="bg-white/[0.05] backdrop-blur-xl border border-white/10 rounded-2xl p-4">
<div className="flex items-center justify-between mb-3">
<p className="text-xs text-white/40">
{de ? 'Gewinn- und Verlustrechnung (5 Jahre)' : 'Profit & Loss Statement (5 Years)'}
</p>
<p className="text-[9px] text-white/20">
{de ? 'Alle Werte in EUR' : 'All values in EUR'}
</p>
</div>
<AnnualPLTable results={activeResults.results} lang={lang} />
</div>
</FadeInView>
)}
{/* TAB: Cashflow & Finanzbedarf */}
{activeTab === 'cashflow' && activeResults && (
<FadeInView delay={0.1}>
<div className="bg-white/[0.05] backdrop-blur-xl border border-white/10 rounded-2xl p-4">
<p className="text-xs text-white/40 mb-3">
{de ? 'Jaehrlicher Cashflow & Finanzbedarf' : 'Annual Cash Flow & Funding Requirements'}
</p>
<AnnualCashflowChart
results={activeResults.results}
initialFunding={initialFunding}
lang={lang}
/>
</div>
</FadeInView>
)}
</div>
{/* Right: Controls (4 columns) */}
<div className="md:col-span-4 space-y-3">
{/* Scenario Switcher */}
<FadeInView delay={0.15}>
<div className="bg-white/[0.05] backdrop-blur-xl border border-white/10 rounded-2xl p-3">
<ScenarioSwitcher
scenarios={fm.scenarios}
activeId={fm.activeScenarioId}
compareMode={fm.compareMode}
onSelect={(id) => {
fm.setActiveScenarioId(id)
}}
onToggleCompare={() => {
if (!fm.compareMode) {
fm.computeAll()
}
fm.setCompareMode(!fm.compareMode)
}}
lang={lang}
/>
</div>
</FadeInView>
{/* Assumption Sliders */}
<FadeInView delay={0.2}>
<div className="bg-white/[0.05] backdrop-blur-xl border border-white/10 rounded-2xl p-3">
<p className="text-[10px] text-white/40 uppercase tracking-wider mb-2">
{i.financials.adjustAssumptions}
</p>
{fm.activeScenario && (
<FinancialSliders
assumptions={fm.activeScenario.assumptions}
onAssumptionChange={(key, value) => {
if (fm.activeScenarioId) {
fm.updateAssumption(fm.activeScenarioId, key, value)
}
}}
lang={lang}
/>
)}
{fm.computing && (
<div className="flex items-center gap-2 mt-2 text-[10px] text-indigo-400">
<div className="w-3 h-3 border border-indigo-400 border-t-transparent rounded-full animate-spin" />
{de ? 'Berechne...' : 'Computing...'}
</div>
)}
</div>
</FadeInView>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,65 @@
'use client'
import { motion } from 'framer-motion'
import { Language } from '@/lib/types'
import { t } from '@/lib/i18n'
import { Plug, Settings, RefreshCw, CheckCircle2 } from 'lucide-react'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
interface HowItWorksSlideProps {
lang: Language
}
const stepIcons = [Plug, Settings, RefreshCw, CheckCircle2]
const stepColors = ['text-blue-400', 'text-indigo-400', 'text-purple-400', 'text-green-400']
export default function HowItWorksSlide({ lang }: HowItWorksSlideProps) {
const i = t(lang)
return (
<div>
<FadeInView className="text-center mb-12">
<h2 className="text-4xl md:text-5xl font-bold mb-3">
<GradientText>{i.howItWorks.title}</GradientText>
</h2>
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.howItWorks.subtitle}</p>
</FadeInView>
<div className="relative max-w-4xl mx-auto">
{/* Connection Line */}
<div className="absolute left-8 top-12 bottom-12 w-px bg-gradient-to-b from-blue-500 via-purple-500 to-green-500 hidden md:block" />
<div className="space-y-8">
{i.howItWorks.steps.map((step, idx) => {
const Icon = stepIcons[idx]
return (
<motion.div
key={idx}
initial={{ opacity: 0, x: -30 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 + idx * 0.2, duration: 0.5 }}
className="flex items-start gap-6 relative"
>
<div className={`
w-16 h-16 rounded-2xl bg-white/[0.06] border border-white/10
flex items-center justify-center shrink-0 relative z-10
${stepColors[idx]}
`}>
<Icon className="w-7 h-7" />
</div>
<div className="pt-2">
<div className="flex items-center gap-3 mb-1">
<span className="text-xs font-mono text-white/30">0{idx + 1}</span>
<h3 className="text-xl font-bold text-white">{step.title}</h3>
</div>
<p className="text-sm text-white/50 leading-relaxed max-w-lg">{step.desc}</p>
</div>
</motion.div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,91 @@
'use client'
import { motion } from 'framer-motion'
import { Language, PitchMarket } from '@/lib/types'
import { t, formatEur } from '@/lib/i18n'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import AnimatedCounter from '../ui/AnimatedCounter'
interface MarketSlideProps {
lang: Language
market: PitchMarket[]
}
const sizes = [280, 200, 130]
const colors = ['border-indigo-500/30 bg-indigo-500/5', 'border-purple-500/30 bg-purple-500/5', 'border-blue-500/30 bg-blue-500/5']
const textColors = ['text-indigo-400', 'text-purple-400', 'text-blue-400']
export default function MarketSlide({ lang, market }: MarketSlideProps) {
const i = t(lang)
const labels = [i.market.tamLabel, i.market.samLabel, i.market.somLabel]
const segments = [i.market.tam, i.market.sam, i.market.som]
return (
<div>
<FadeInView className="text-center mb-12">
<h2 className="text-4xl md:text-5xl font-bold mb-3">
<GradientText>{i.market.title}</GradientText>
</h2>
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.market.subtitle}</p>
</FadeInView>
<div className="flex flex-col md:flex-row items-center justify-center gap-12">
{/* Circles */}
<div className="relative flex items-center justify-center" style={{ width: 300, height: 300 }}>
{market.map((m, idx) => (
<motion.div
key={m.id}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.3 + idx * 0.2, type: 'spring', stiffness: 200 }}
className={`absolute rounded-full border-2 ${colors[idx]} flex items-center justify-center`}
style={{
width: sizes[idx],
height: sizes[idx],
}}
>
{idx === market.length - 1 && (
<div className="text-center">
<span className={`text-xs font-mono ${textColors[idx]}`}>{segments[idx]}</span>
</div>
)}
</motion.div>
))}
</div>
{/* Labels */}
<div className="space-y-6">
{market.map((m, idx) => (
<motion.div
key={m.id}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.5 + idx * 0.15 }}
className="flex items-center gap-4"
>
<div className={`w-3 h-3 rounded-full ${textColors[idx]} bg-current`} />
<div>
<div className="flex items-center gap-2">
<span className={`text-sm font-bold ${textColors[idx]}`}>{segments[idx]}</span>
<span className="text-xs text-white/30">{labels[idx]}</span>
</div>
<div className="text-2xl font-bold text-white">
<AnimatedCounter
target={m.value_eur / 1_000_000_000}
suffix={lang === 'de' ? ' Mrd. EUR' : 'B EUR'}
decimals={1}
duration={1500}
/>
</div>
<div className="text-xs text-white/40">
{i.market.growth}: {m.growth_rate_pct}% · {i.market.source}: {m.source}
</div>
</div>
</motion.div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,54 @@
'use client'
import { motion } from 'framer-motion'
import { Language } from '@/lib/types'
import { t } from '@/lib/i18n'
import { AlertTriangle, Scale, Shield } from 'lucide-react'
import GlassCard from '../ui/GlassCard'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
interface ProblemSlideProps {
lang: Language
}
const icons = [AlertTriangle, Scale, Shield]
export default function ProblemSlide({ lang }: ProblemSlideProps) {
const i = t(lang)
return (
<div>
<FadeInView className="text-center mb-12">
<h2 className="text-4xl md:text-5xl font-bold mb-3">
<GradientText>{i.problem.title}</GradientText>
</h2>
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.problem.subtitle}</p>
</FadeInView>
<div className="grid md:grid-cols-3 gap-6 mb-12">
{i.problem.cards.map((card, idx) => {
const Icon = icons[idx]
return (
<GlassCard key={idx} delay={0.2 + idx * 0.15} className="text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-xl bg-red-500/10 flex items-center justify-center">
<Icon className="w-6 h-6 text-red-400" />
</div>
<h3 className="text-lg font-bold mb-2 text-white">{card.title}</h3>
<p className="text-3xl font-bold text-red-400 mb-3">{card.stat}</p>
<p className="text-sm text-white/50 leading-relaxed">{card.desc}</p>
</GlassCard>
)
})}
</div>
<FadeInView delay={0.8} className="max-w-3xl mx-auto">
<blockquote className="text-center">
<p className="text-lg md:text-xl text-white/70 italic leading-relaxed">
&ldquo;{i.problem.quote}&rdquo;
</p>
</blockquote>
</FadeInView>
</div>
)
}

View File

@@ -0,0 +1,33 @@
'use client'
import { Language, PitchProduct } from '@/lib/types'
import { t } from '@/lib/i18n'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import PricingCard from '../ui/PricingCard'
interface ProductSlideProps {
lang: Language
products: PitchProduct[]
}
export default function ProductSlide({ lang, products }: ProductSlideProps) {
const i = t(lang)
return (
<div>
<FadeInView className="text-center mb-12">
<h2 className="text-4xl md:text-5xl font-bold mb-3">
<GradientText>{i.product.title}</GradientText>
</h2>
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.product.subtitle}</p>
</FadeInView>
<div className="grid md:grid-cols-3 gap-6">
{products.map((product, idx) => (
<PricingCard key={product.id} product={product} lang={lang} delay={0.2 + idx * 0.15} />
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,57 @@
'use client'
import { motion } from 'framer-motion'
import { Language } from '@/lib/types'
import { t } from '@/lib/i18n'
import { Server, ShieldCheck, Bot } from 'lucide-react'
import GlassCard from '../ui/GlassCard'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import BrandName from '../ui/BrandName'
interface SolutionSlideProps {
lang: Language
}
const icons = [Server, ShieldCheck, Bot]
const colors = ['from-blue-500 to-cyan-500', 'from-indigo-500 to-purple-500', 'from-purple-500 to-pink-500']
export default function SolutionSlide({ lang }: SolutionSlideProps) {
const i = t(lang)
return (
<div>
<FadeInView className="text-center mb-12">
<h2 className="text-4xl md:text-5xl font-bold mb-3">
<GradientText>{i.solution.title}</GradientText>
</h2>
<p className="text-lg text-white/50 max-w-2xl mx-auto">
<BrandName /> {lang === 'de' ? 'Compliance auf Autopilot' : 'Compliance on Autopilot'}
</p>
</FadeInView>
<div className="grid md:grid-cols-3 gap-6">
{i.solution.pillars.map((pillar, idx) => {
const Icon = icons[idx]
return (
<motion.div
key={idx}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3 + idx * 0.2, duration: 0.5 }}
>
<GlassCard className="text-center h-full" delay={0}>
<div className={`w-16 h-16 mx-auto mb-5 rounded-2xl bg-gradient-to-br ${colors[idx]}
flex items-center justify-center shadow-lg`}>
<Icon className="w-8 h-8 text-white" />
</div>
<h3 className="text-xl font-bold mb-3 text-white">{pillar.title}</h3>
<p className="text-sm text-white/50 leading-relaxed">{pillar.desc}</p>
</GlassCard>
</motion.div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,76 @@
'use client'
import { motion } from 'framer-motion'
import { Language, PitchTeamMember } from '@/lib/types'
import { t } from '@/lib/i18n'
import { User } from 'lucide-react'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
interface TeamSlideProps {
lang: Language
team: PitchTeamMember[]
}
export default function TeamSlide({ lang, team }: TeamSlideProps) {
const i = t(lang)
return (
<div>
<FadeInView className="text-center mb-12">
<h2 className="text-4xl md:text-5xl font-bold mb-3">
<GradientText>{i.team.title}</GradientText>
</h2>
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.team.subtitle}</p>
</FadeInView>
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
{team.map((member, idx) => (
<motion.div
key={member.id}
initial={{ opacity: 0, x: idx === 0 ? -40 : 40 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 + idx * 0.2, duration: 0.6 }}
className="bg-white/[0.08] backdrop-blur-xl border border-white/10 rounded-3xl p-8"
>
<div className="flex items-start gap-5">
{/* Avatar */}
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600
flex items-center justify-center shrink-0 shadow-lg">
<User className="w-10 h-10 text-white" />
</div>
<div className="flex-1">
<h3 className="text-xl font-bold text-white mb-1">{member.name}</h3>
<p className="text-indigo-400 text-sm font-medium mb-3">
{lang === 'de' ? member.role_de : member.role_en}
</p>
<p className="text-sm text-white/50 leading-relaxed mb-4">
{lang === 'de' ? member.bio_de : member.bio_en}
</p>
{/* Equity */}
<div className="flex items-center gap-2 mb-3">
<span className="text-xs text-white/40">{i.team.equity}:</span>
<span className="text-sm font-bold text-white">{member.equity_pct}%</span>
</div>
{/* Expertise Tags */}
<div className="flex flex-wrap gap-1.5">
{(member.expertise || []).map((skill, sidx) => (
<span
key={sidx}
className="text-xs px-2.5 py-1 rounded-full bg-indigo-500/10 text-indigo-300 border border-indigo-500/20"
>
{skill}
</span>
))}
</div>
</div>
</div>
</motion.div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,127 @@
'use client'
import { motion } from 'framer-motion'
import { Language, PitchFunding } from '@/lib/types'
import { t } from '@/lib/i18n'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import AnimatedCounter from '../ui/AnimatedCounter'
import GlassCard from '../ui/GlassCard'
import { Target, Calendar, FileText } from 'lucide-react'
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'
interface TheAskSlideProps {
lang: Language
funding: PitchFunding
}
const COLORS = ['#6366f1', '#a78bfa', '#60a5fa', '#34d399', '#fbbf24']
export default function TheAskSlide({ lang, funding }: TheAskSlideProps) {
const i = t(lang)
const useOfFunds = funding?.use_of_funds || []
const pieData = useOfFunds.map((item) => ({
name: lang === 'de' ? item.label_de : item.label_en,
value: item.percentage,
}))
return (
<div>
<FadeInView className="text-center mb-10">
<h2 className="text-4xl md:text-5xl font-bold mb-3">
<GradientText>{i.theAsk.title}</GradientText>
</h2>
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.theAsk.subtitle}</p>
</FadeInView>
{/* Main Number */}
<FadeInView delay={0.2} className="text-center mb-10">
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.4, type: 'spring', stiffness: 200 }}
>
<p className="text-6xl md:text-8xl font-bold text-white mb-2">
<AnimatedCounter target={200} suffix="k" duration={2000} />
<span className="text-3xl md:text-4xl text-white/50 ml-2">EUR</span>
</p>
</motion.div>
</FadeInView>
{/* Details */}
<div className="grid md:grid-cols-3 gap-4 mb-8">
<GlassCard delay={0.5} className="text-center p-5">
<FileText className="w-6 h-6 text-indigo-400 mx-auto mb-2" />
<p className="text-xs text-white/40 mb-1">{i.theAsk.instrument}</p>
<p className="text-lg font-bold text-white">{funding?.instrument || 'SAFE'}</p>
</GlassCard>
<GlassCard delay={0.6} className="text-center p-5">
<Calendar className="w-6 h-6 text-purple-400 mx-auto mb-2" />
<p className="text-xs text-white/40 mb-1">{i.theAsk.targetDate}</p>
<p className="text-lg font-bold text-white">Q3 2026</p>
</GlassCard>
<GlassCard delay={0.7} className="text-center p-5">
<Target className="w-6 h-6 text-blue-400 mx-auto mb-2" />
<p className="text-xs text-white/40 mb-1">{lang === 'de' ? 'Runway' : 'Runway'}</p>
<p className="text-lg font-bold text-white">18 {lang === 'de' ? 'Monate' : 'Months'}</p>
</GlassCard>
</div>
{/* Use of Funds */}
<FadeInView delay={0.8}>
<GlassCard hover={false} className="p-6">
<h3 className="text-lg font-semibold text-white mb-4 text-center">{i.theAsk.useOfFunds}</h3>
<div className="flex flex-col md:flex-row items-center gap-8">
{/* Pie Chart */}
<div className="w-48 h-48">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
dataKey="value"
stroke="none"
>
{pieData.map((_, idx) => (
<Cell key={idx} fill={COLORS[idx % COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
background: 'rgba(10, 10, 26, 0.9)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 8,
color: '#fff',
fontSize: 13,
}}
formatter={(value: number) => `${value}%`}
/>
</PieChart>
</ResponsiveContainer>
</div>
{/* Legend */}
<div className="flex-1 space-y-3">
{useOfFunds.map((item, idx) => (
<div key={idx} className="flex items-center gap-3">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: COLORS[idx] }} />
<span className="flex-1 text-sm text-white/70">
{lang === 'de' ? item.label_de : item.label_en}
</span>
<span className="text-sm font-bold text-white">{item.percentage}%</span>
<span className="text-xs text-white/30">
{((funding.amount_eur * item.percentage) / 100).toLocaleString('de-DE')} EUR
</span>
</div>
))}
</div>
</div>
</GlassCard>
</FadeInView>
</div>
)
}

View File

@@ -0,0 +1,58 @@
'use client'
import { Language, PitchMilestone, PitchMetric } from '@/lib/types'
import { t } from '@/lib/i18n'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import GlassCard from '../ui/GlassCard'
import Timeline from '../ui/Timeline'
import LiveIndicator from '../ui/LiveIndicator'
interface TractionSlideProps {
lang: Language
milestones: PitchMilestone[]
metrics: PitchMetric[]
}
export default function TractionSlide({ lang, milestones, metrics }: TractionSlideProps) {
const i = t(lang)
return (
<div>
<FadeInView className="text-center mb-10">
<h2 className="text-4xl md:text-5xl font-bold mb-3">
<GradientText>{i.traction.title}</GradientText>
</h2>
<p className="text-lg text-white/50 max-w-2xl mx-auto">{i.traction.subtitle}</p>
</FadeInView>
<div className="grid md:grid-cols-2 gap-8">
{/* KPI Cards */}
<div>
<div className="grid grid-cols-2 gap-3 mb-6">
{metrics.slice(0, 6).map((m, idx) => (
<GlassCard key={m.id} delay={0.2 + idx * 0.08} className="p-4">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-white/40">
{lang === 'de' ? m.label_de : m.label_en}
</span>
{m.is_live && <LiveIndicator />}
</div>
<p className="text-2xl font-bold text-white">
{m.value}{m.unit ? ` ${m.unit}` : ''}
</p>
</GlassCard>
))}
</div>
</div>
{/* Timeline */}
<FadeInView delay={0.4}>
<div className="bg-white/[0.03] rounded-2xl p-5 border border-white/5 max-h-[400px] overflow-y-auto">
<Timeline milestones={milestones} lang={lang} />
</div>
</FadeInView>
</div>
</div>
)
}

View File

@@ -0,0 +1,54 @@
'use client'
import { useEffect, useState, useRef } from 'react'
interface AnimatedCounterProps {
target: number
duration?: number
prefix?: string
suffix?: string
className?: string
decimals?: number
}
export default function AnimatedCounter({
target,
duration = 2000,
prefix = '',
suffix = '',
className = '',
decimals = 0,
}: AnimatedCounterProps) {
const [current, setCurrent] = useState(0)
const startTime = useRef<number | null>(null)
const frameRef = useRef<number>(0)
useEffect(() => {
startTime.current = null
function animate(timestamp: number) {
if (!startTime.current) startTime.current = timestamp
const elapsed = timestamp - startTime.current
const progress = Math.min(elapsed / duration, 1)
const eased = 1 - Math.pow(1 - progress, 3)
setCurrent(eased * target)
if (progress < 1) {
frameRef.current = requestAnimationFrame(animate)
}
}
frameRef.current = requestAnimationFrame(animate)
return () => cancelAnimationFrame(frameRef.current)
}, [target, duration])
const formatted = decimals > 0
? current.toFixed(decimals)
: Math.round(current).toLocaleString('de-DE')
return (
<span className={className}>
{prefix}{formatted}{suffix}
</span>
)
}

View File

@@ -0,0 +1,180 @@
'use client'
import { FMResult } from '@/lib/types'
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
ReferenceLine,
Line,
ComposedChart,
Cell,
} from 'recharts'
interface AnnualCashflowChartProps {
results: FMResult[]
initialFunding: number
lang: 'de' | 'en'
}
interface AnnualCFRow {
year: string
revenue: number
costs: number
netCashflow: number
cashBalance: number
cumulativeFundingNeed: number
}
export default function AnnualCashflowChart({ results, initialFunding, lang }: AnnualCashflowChartProps) {
const de = lang === 'de'
// Aggregate into yearly
const yearMap = new Map<number, FMResult[]>()
for (const r of results) {
if (!yearMap.has(r.year)) yearMap.set(r.year, [])
yearMap.get(r.year)!.push(r)
}
let cumulativeNeed = 0
const data: AnnualCFRow[] = Array.from(yearMap.entries()).map(([year, months]) => {
const revenue = months.reduce((s, m) => s + m.revenue_eur, 0)
const costs = months.reduce((s, m) => s + m.total_costs_eur, 0)
const netCF = revenue - costs
const lastMonth = months[months.length - 1]
// Cumulative funding need: how much total external capital is needed
if (netCF < 0) cumulativeNeed += Math.abs(netCF)
return {
year: year.toString(),
revenue: Math.round(revenue),
costs: Math.round(costs),
netCashflow: Math.round(netCF),
cashBalance: Math.round(lastMonth.cash_balance_eur),
cumulativeFundingNeed: Math.round(cumulativeNeed),
}
})
const formatValue = (value: number) => {
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(0)}k`
return value.toString()
}
// Calculate total funding needed beyond initial funding
const totalFundingGap = Math.max(0, cumulativeNeed - initialFunding)
return (
<div>
{/* Summary Cards */}
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="text-center">
<p className="text-[9px] text-white/30 uppercase tracking-wider">
{de ? 'Startkapital' : 'Initial Funding'}
</p>
<p className="text-sm font-bold text-white">{formatValue(initialFunding)} EUR</p>
</div>
<div className="text-center">
<p className="text-[9px] text-white/30 uppercase tracking-wider">
{de ? 'Kum. Finanzbedarf' : 'Cum. Funding Need'}
</p>
<p className="text-sm font-bold text-amber-400">{formatValue(cumulativeNeed)} EUR</p>
</div>
<div className="text-center">
<p className="text-[9px] text-white/30 uppercase tracking-wider">
{de ? 'Finanzierungsluecke' : 'Funding Gap'}
</p>
<p className={`text-sm font-bold ${totalFundingGap > 0 ? 'text-red-400' : 'text-emerald-400'}`}>
{totalFundingGap > 0 ? formatValue(totalFundingGap) + ' EUR' : (de ? 'Gedeckt' : 'Covered')}
</p>
</div>
</div>
{/* Chart */}
<div className="w-full h-[220px]">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<XAxis
dataKey="year"
stroke="rgba(255,255,255,0.3)"
tick={{ fill: 'rgba(255,255,255,0.5)', fontSize: 11 }}
/>
<YAxis
stroke="rgba(255,255,255,0.1)"
tick={{ fill: 'rgba(255,255,255,0.4)', fontSize: 10 }}
tickFormatter={formatValue}
/>
<Tooltip
contentStyle={{
background: 'rgba(10, 10, 26, 0.95)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 12,
color: '#fff',
fontSize: 11,
}}
formatter={(value: number, name: string) => {
const label =
name === 'netCashflow' ? (de ? 'Netto-Cashflow' : 'Net Cash Flow')
: name === 'cashBalance' ? (de ? 'Cash-Bestand' : 'Cash Balance')
: name === 'cumulativeFundingNeed' ? (de ? 'Kum. Finanzbedarf' : 'Cum. Funding Need')
: name
return [formatValue(value) + ' EUR', label]
}}
/>
<ReferenceLine y={0} stroke="rgba(255,255,255,0.2)" />
{/* Net Cashflow Bars */}
<Bar dataKey="netCashflow" radius={[4, 4, 4, 4]} barSize={28}>
{data.map((entry, i) => (
<Cell
key={i}
fill={entry.netCashflow >= 0 ? 'rgba(34, 197, 94, 0.7)' : 'rgba(239, 68, 68, 0.6)'}
/>
))}
</Bar>
{/* Cash Balance Line */}
<Line
type="monotone"
dataKey="cashBalance"
stroke="#6366f1"
strokeWidth={2.5}
dot={{ r: 4, fill: '#6366f1', stroke: '#1e1b4b', strokeWidth: 2 }}
/>
{/* Cumulative Funding Need Line */}
<Line
type="monotone"
dataKey="cumulativeFundingNeed"
stroke="#f59e0b"
strokeWidth={2}
strokeDasharray="5 5"
dot={{ r: 3, fill: '#f59e0b' }}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Legend */}
<div className="flex items-center justify-center gap-4 mt-2 text-[9px] text-white/40">
<span className="flex items-center gap-1">
<span className="w-3 h-2.5 rounded-sm bg-emerald-500/70 inline-block" />
<span className="w-3 h-2.5 rounded-sm bg-red-500/60 inline-block" />
{de ? 'Netto-Cashflow' : 'Net Cash Flow'}
</span>
<span className="flex items-center gap-1">
<span className="w-4 h-0.5 bg-indigo-500 inline-block" />
{de ? 'Cash-Bestand' : 'Cash Balance'}
</span>
<span className="flex items-center gap-1">
<span className="w-4 h-0.5 inline-block" style={{ borderBottom: '2px dashed #f59e0b' }} />
{de ? 'Kum. Finanzbedarf' : 'Cum. Funding Need'}
</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,142 @@
'use client'
import { motion } from 'framer-motion'
import { FMResult } from '@/lib/types'
interface AnnualPLTableProps {
results: FMResult[]
lang: 'de' | 'en'
}
interface AnnualRow {
year: number
revenue: number
cogs: number
grossProfit: number
grossMarginPct: number
personnel: number
marketing: number
infra: number
totalOpex: number
ebitda: number
ebitdaMarginPct: number
customers: number
employees: number
}
function fmt(v: number): string {
if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
if (Math.abs(v) >= 1_000) return `${(v / 1_000).toFixed(0)}k`
return Math.round(v).toLocaleString('de-DE')
}
export default function AnnualPLTable({ results, lang }: AnnualPLTableProps) {
// Aggregate monthly results into annual
const annualMap = new Map<number, FMResult[]>()
for (const r of results) {
if (!annualMap.has(r.year)) annualMap.set(r.year, [])
annualMap.get(r.year)!.push(r)
}
const rows: AnnualRow[] = Array.from(annualMap.entries()).map(([year, months]) => {
const revenue = months.reduce((s, m) => s + m.revenue_eur, 0)
const cogs = months.reduce((s, m) => s + m.cogs_eur, 0)
const grossProfit = revenue - cogs
const personnel = months.reduce((s, m) => s + m.personnel_eur, 0)
const marketing = months.reduce((s, m) => s + m.marketing_eur, 0)
const infra = months.reduce((s, m) => s + m.infra_eur, 0)
const totalOpex = personnel + marketing + infra
const ebitda = grossProfit - totalOpex
const lastMonth = months[months.length - 1]
return {
year,
revenue,
cogs,
grossProfit,
grossMarginPct: revenue > 0 ? (grossProfit / revenue) * 100 : 0,
personnel,
marketing,
infra,
totalOpex,
ebitda,
ebitdaMarginPct: revenue > 0 ? (ebitda / revenue) * 100 : 0,
customers: lastMonth.total_customers,
employees: lastMonth.employees_count,
}
})
const de = lang === 'de'
const lineItems: { label: string; key: keyof AnnualRow; isBold?: boolean; isPercent?: boolean; isSeparator?: boolean; isNegative?: boolean }[] = [
{ label: de ? 'Umsatzerloese' : 'Revenue', key: 'revenue', isBold: true },
{ label: de ? '- Herstellungskosten (COGS)' : '- Cost of Goods Sold', key: 'cogs', isNegative: true },
{ label: de ? '= Rohertrag (Gross Profit)' : '= Gross Profit', key: 'grossProfit', isBold: true, isSeparator: true },
{ label: de ? ' Rohertragsmarge' : ' Gross Margin', key: 'grossMarginPct', isPercent: true },
{ label: de ? '- Personalkosten' : '- Personnel', key: 'personnel', isNegative: true },
{ label: de ? '- Marketing & Vertrieb' : '- Marketing & Sales', key: 'marketing', isNegative: true },
{ label: de ? '- Infrastruktur' : '- Infrastructure', key: 'infra', isNegative: true },
{ label: de ? '= OpEx gesamt' : '= Total OpEx', key: 'totalOpex', isBold: true, isSeparator: true, isNegative: true },
{ label: 'EBITDA', key: 'ebitda', isBold: true, isSeparator: true },
{ label: de ? ' EBITDA-Marge' : ' EBITDA Margin', key: 'ebitdaMarginPct', isPercent: true },
{ label: de ? 'Kunden (Jahresende)' : 'Customers (Year End)', key: 'customers' },
{ label: de ? 'Mitarbeiter' : 'Employees', key: 'employees' },
]
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="overflow-x-auto"
>
<table className="w-full text-[11px]">
<thead>
<tr className="border-b border-white/10">
<th className="text-left py-2 pr-4 text-white/40 font-medium min-w-[180px]">
{de ? 'GuV-Position' : 'P&L Line Item'}
</th>
{rows.map(r => (
<th key={r.year} className="text-right py-2 px-2 text-white/50 font-semibold min-w-[80px]">
{r.year}
</th>
))}
</tr>
</thead>
<tbody>
{lineItems.map((item) => (
<tr
key={item.key}
className={`${item.isSeparator ? 'border-t border-white/10' : ''} ${item.isBold ? '' : ''}`}
>
<td className={`py-1.5 pr-4 ${item.isBold ? 'text-white font-semibold' : 'text-white/50'} ${item.isPercent ? 'italic text-white/30' : ''}`}>
{item.label}
</td>
{rows.map(r => {
const val = r[item.key] as number
const isNeg = val < 0 || item.isNegative
return (
<td
key={r.year}
className={`text-right py-1.5 px-2 font-mono
${item.isBold ? 'font-semibold' : ''}
${item.isPercent ? 'text-white/30 italic' : ''}
${!item.isPercent && val < 0 ? 'text-red-400/80' : ''}
${!item.isPercent && val > 0 && item.key === 'ebitda' ? 'text-emerald-400' : ''}
${!item.isPercent && !item.isBold ? 'text-white/60' : 'text-white'}
`}
>
{item.isPercent
? `${val.toFixed(1)}%`
: (item.isNegative && val > 0 ? '-' : '') + fmt(Math.abs(val))
}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</motion.div>
)
}

View File

@@ -0,0 +1,19 @@
'use client'
interface BrandNameProps {
className?: string
prefix?: boolean
}
/**
* Renders "ComplAI" (or "BreakPilot ComplAI") with the "AI" portion
* styled as a gradient to visually distinguish lowercase-L from uppercase-I.
*/
export default function BrandName({ className = '', prefix = false }: BrandNameProps) {
return (
<span className={className}>
{prefix && <>BreakPilot </>}
Compl<span className="bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">AI</span>
</span>
)
}

View File

@@ -0,0 +1,167 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Send, Bot, User, Sparkles } from 'lucide-react'
import { ChatMessage, Language } from '@/lib/types'
import { t } from '@/lib/i18n'
interface ChatInterfaceProps {
lang: Language
}
export default function ChatInterface({ lang }: ChatInterfaceProps) {
const i = t(lang)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
async function sendMessage(text?: string) {
const message = text || input.trim()
if (!message || isStreaming) return
setInput('')
setMessages(prev => [...prev, { role: 'user', content: message }])
setIsStreaming(true)
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
history: messages.slice(-10),
lang,
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let content = ''
setMessages(prev => [...prev, { role: 'assistant', content: '' }])
while (true) {
const { done, value } = await reader.read()
if (done) break
content += decoder.decode(value, { stream: true })
setMessages(prev => {
const updated = [...prev]
updated[updated.length - 1] = { role: 'assistant', content }
return updated
})
}
} catch (err) {
console.error('Chat error:', err)
setMessages(prev => [
...prev,
{ role: 'assistant', content: lang === 'de'
? 'Verbindung fehlgeschlagen. Bitte versuchen Sie es erneut.'
: 'Connection failed. Please try again.'
},
])
} finally {
setIsStreaming(false)
}
}
return (
<div className="flex flex-col h-full max-h-[500px]">
{/* Messages */}
<div className="flex-1 overflow-y-auto space-y-4 mb-4 pr-2">
{messages.length === 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-white/40 text-sm mb-4">
<Sparkles className="w-4 h-4" />
<span>{lang === 'de' ? 'Vorgeschlagene Fragen:' : 'Suggested questions:'}</span>
</div>
{i.aiqa.suggestions.map((q, idx) => (
<motion.button
key={idx}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.1 }}
onClick={() => sendMessage(q)}
className="block w-full text-left px-4 py-3 rounded-xl bg-white/[0.05] border border-white/10
hover:bg-white/[0.1] transition-colors text-sm text-white/70 hover:text-white"
>
{q}
</motion.button>
))}
</div>
)}
<AnimatePresence mode="popLayout">
{messages.map((msg, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`flex gap-3 ${msg.role === 'user' ? 'justify-end' : ''}`}
>
{msg.role === 'assistant' && (
<div className="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center shrink-0">
<Bot className="w-4 h-4 text-indigo-400" />
</div>
)}
<div
className={`
max-w-[80%] rounded-2xl px-4 py-3 text-sm leading-relaxed
${msg.role === 'user'
? 'bg-indigo-500/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-2 h-4 bg-indigo-400 animate-pulse ml-1" />
)}
</div>
{msg.role === 'user' && (
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center shrink-0">
<User className="w-4 h-4 text-white/60" />
</div>
)}
</motion.div>
))}
</AnimatePresence>
<div ref={messagesEndRef} />
</div>
{/* Input */}
<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.aiqa.placeholder}
disabled={isStreaming}
className="flex-1 bg-white/[0.06] border border-white/10 rounded-xl px-4 py-3
text-sm text-white placeholder-white/30 outline-none
focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/20
disabled:opacity-50 transition-all"
/>
<button
onClick={() => sendMessage()}
disabled={isStreaming || !input.trim()}
className="px-4 py-3 bg-indigo-500 hover:bg-indigo-600 disabled:opacity-30
rounded-xl transition-all text-white"
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,39 @@
'use client'
import { motion } from 'framer-motion'
import { ReactNode } from 'react'
interface FadeInViewProps {
children: ReactNode
className?: string
delay?: number
direction?: 'up' | 'down' | 'left' | 'right' | 'none'
duration?: number
}
export default function FadeInView({
children,
className = '',
delay = 0,
direction = 'up',
duration = 0.6,
}: FadeInViewProps) {
const directionMap = {
up: { y: 30 },
down: { y: -30 },
left: { x: 30 },
right: { x: -30 },
none: {},
}
return (
<motion.div
initial={{ opacity: 0, ...directionMap[direction] }}
animate={{ opacity: 1, x: 0, y: 0 }}
transition={{ duration, delay, ease: [0.22, 1, 0.36, 1] }}
className={className}
>
{children}
</motion.div>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
import { motion } from 'framer-motion'
import { PitchFeature, Language } from '@/lib/types'
import { Check, X, Star } from 'lucide-react'
import BrandName from './BrandName'
interface FeatureMatrixProps {
features: PitchFeature[]
lang: Language
}
function Cell({ value, isDiff }: { value: boolean; isDiff: boolean }) {
return (
<td className="px-4 py-3 text-center">
{value ? (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 500, delay: 0.1 }}
>
<Check className={`w-5 h-5 mx-auto ${isDiff ? 'text-green-400' : 'text-white/50'}`} />
</motion.span>
) : (
<X className="w-5 h-5 mx-auto text-white/20" />
)}
</td>
)
}
export default function FeatureMatrix({ features, lang }: FeatureMatrixProps) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/10">
<th className="text-left px-4 py-3 font-medium text-white/60">Feature</th>
<th className="px-4 py-3 font-bold text-indigo-400"><BrandName /></th>
<th className="px-4 py-3 font-medium text-white/60">Proliance</th>
<th className="px-4 py-3 font-medium text-white/60">DataGuard</th>
<th className="px-4 py-3 font-medium text-white/60">heyData</th>
</tr>
</thead>
<tbody>
{features.map((f, i) => (
<motion.tr
key={f.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.05 }}
className={`border-b border-white/5 ${f.is_differentiator ? 'bg-indigo-500/5' : ''}`}
>
<td className="px-4 py-3 flex items-center gap-2">
{f.is_differentiator && <Star className="w-3.5 h-3.5 text-yellow-400" />}
<span className={f.is_differentiator ? 'text-white font-medium' : 'text-white/70'}>
{lang === 'de' ? f.feature_name_de : f.feature_name_en}
</span>
</td>
<Cell value={f.breakpilot} isDiff={f.is_differentiator} />
<Cell value={f.proliance} isDiff={false} />
<Cell value={f.dataguard} isDiff={false} />
<Cell value={f.heydata} isDiff={false} />
</motion.tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,200 @@
'use client'
import { FMResult, FMComputeResponse } from '@/lib/types'
import {
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Line,
ComposedChart,
Area,
ReferenceLine,
Brush,
} from 'recharts'
interface FinancialChartProps {
activeResults: FMComputeResponse | null
compareResults?: Map<string, FMComputeResponse>
compareMode?: boolean
scenarioColors?: Record<string, string>
lang: 'de' | 'en'
}
export default function FinancialChart({
activeResults,
compareResults,
compareMode = false,
scenarioColors = {},
lang,
}: FinancialChartProps) {
if (!activeResults) {
return (
<div className="w-full h-[300px] flex items-center justify-center text-white/30 text-sm">
{lang === 'de' ? 'Lade Daten...' : 'Loading data...'}
</div>
)
}
const results = activeResults.results
const breakEvenMonth = activeResults.summary.break_even_month
// Build chart data — monthly
const data = results.map((r) => {
const entry: Record<string, number | string> = {
label: `${r.year.toString().slice(2)}/${String(r.month_in_year).padStart(2, '0')}`,
month: r.month,
revenue: Math.round(r.revenue_eur),
costs: Math.round(r.total_costs_eur),
customers: r.total_customers,
cashBalance: Math.round(r.cash_balance_eur),
}
// Add compare scenario data
if (compareMode && compareResults) {
compareResults.forEach((cr, scenarioId) => {
const crMonth = cr.results.find(m => m.month === r.month)
if (crMonth) {
entry[`revenue_${scenarioId}`] = Math.round(crMonth.revenue_eur)
entry[`customers_${scenarioId}`] = crMonth.total_customers
}
})
}
return entry
})
const formatValue = (value: number) => {
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(0)}k`
return value.toString()
}
return (
<div className="w-full h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 10, right: 50, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="fmRevenueGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#6366f1" stopOpacity={0.6} />
<stop offset="100%" stopColor="#6366f1" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="fmCostGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#f43f5e" stopOpacity={0.4} />
<stop offset="100%" stopColor="#f43f5e" stopOpacity={0.05} />
</linearGradient>
</defs>
<XAxis
dataKey="label"
stroke="rgba(255,255,255,0.2)"
tick={{ fill: 'rgba(255,255,255,0.4)', fontSize: 9 }}
interval={5}
/>
<YAxis
yAxisId="left"
stroke="rgba(255,255,255,0.1)"
tick={{ fill: 'rgba(255,255,255,0.4)', fontSize: 10 }}
tickFormatter={formatValue}
/>
<YAxis
yAxisId="right"
orientation="right"
stroke="rgba(255,255,255,0.1)"
tick={{ fill: 'rgba(34,197,94,0.5)', fontSize: 10 }}
/>
<Tooltip
contentStyle={{
background: 'rgba(10, 10, 26, 0.95)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 12,
color: '#fff',
fontSize: 11,
backdropFilter: 'blur(12px)',
}}
formatter={(value: number, name: string) => {
const label = name === 'revenue' ? (lang === 'de' ? 'Umsatz' : 'Revenue')
: name === 'costs' ? (lang === 'de' ? 'Kosten' : 'Costs')
: name === 'customers' ? (lang === 'de' ? 'Kunden' : 'Customers')
: name === 'cashBalance' ? 'Cash'
: name
return [name === 'customers' ? value : formatValue(value) + ' EUR', label]
}}
/>
{/* Break-even reference line */}
{breakEvenMonth && (
<ReferenceLine
x={data[breakEvenMonth - 1]?.label}
yAxisId="left"
stroke="#22c55e"
strokeDasharray="5 5"
label={{
value: 'Break-Even',
fill: '#22c55e',
fontSize: 10,
position: 'insideTopRight',
}}
/>
)}
{/* Revenue area */}
<Area
yAxisId="left"
type="monotone"
dataKey="revenue"
fill="url(#fmRevenueGradient)"
stroke="#6366f1"
strokeWidth={2}
/>
{/* Cost area */}
<Area
yAxisId="left"
type="monotone"
dataKey="costs"
fill="url(#fmCostGradient)"
stroke="#f43f5e"
strokeWidth={1.5}
strokeDasharray="4 4"
/>
{/* Customers line */}
<Line
yAxisId="right"
type="monotone"
dataKey="customers"
stroke="#22c55e"
strokeWidth={2}
dot={false}
/>
{/* Compare mode: overlay other scenarios */}
{compareMode && compareResults && Array.from(compareResults.entries()).map(([scenarioId]) => (
<Line
key={`rev_${scenarioId}`}
yAxisId="left"
type="monotone"
dataKey={`revenue_${scenarioId}`}
stroke={scenarioColors[scenarioId] || '#888'}
strokeWidth={1.5}
strokeOpacity={0.5}
dot={false}
strokeDasharray="3 3"
/>
))}
{/* Brush for zooming */}
<Brush
dataKey="label"
height={20}
stroke="rgba(99,102,241,0.4)"
fill="rgba(0,0,0,0.3)"
travellerWidth={8}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -0,0 +1,143 @@
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { FMAssumption, Language } from '@/lib/types'
interface FinancialSlidersProps {
assumptions: FMAssumption[]
onAssumptionChange: (key: string, value: number) => void
lang: Language
}
function Slider({
assumption,
onChange,
lang,
}: {
assumption: FMAssumption
onChange: (value: number) => void
lang: Language
}) {
const value = typeof assumption.value === 'number' ? assumption.value : Number(assumption.value)
const label = lang === 'de' ? assumption.label_de : assumption.label_en
if (assumption.value_type === 'step') {
// Display step values as read-only list
const steps = Array.isArray(assumption.value) ? assumption.value : []
return (
<div className="space-y-1">
<p className="text-[11px] text-white/50">{label}</p>
<div className="flex gap-1.5">
{steps.map((s: number, i: number) => (
<div key={i} className="flex-1 text-center">
<p className="text-[9px] text-white/30">Y{i + 1}</p>
<p className="text-xs text-white font-mono">{s}</p>
</div>
))}
</div>
</div>
)
}
return (
<div className="space-y-1.5">
<div className="flex justify-between text-[11px]">
<span className="text-white/50">{label}</span>
<span className="font-mono text-white">{value}{assumption.unit === 'EUR' ? ' EUR' : assumption.unit === '%' ? '%' : ` ${assumption.unit || ''}`}</span>
</div>
<input
type="range"
min={assumption.min_value ?? 0}
max={assumption.max_value ?? 100}
step={assumption.step_size ?? 1}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="w-full h-1.5 bg-white/10 rounded-full appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-3.5
[&::-webkit-slider-thumb]:h-3.5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-indigo-500
[&::-webkit-slider-thumb]:shadow-lg
[&::-webkit-slider-thumb]:shadow-indigo-500/30
[&::-webkit-slider-thumb]:cursor-pointer
"
/>
</div>
)
}
interface CategoryGroup {
key: string
label: string
items: FMAssumption[]
}
export default function FinancialSliders({ assumptions, onAssumptionChange, lang }: FinancialSlidersProps) {
const [openCategories, setOpenCategories] = useState<Set<string>>(new Set(['revenue']))
// Group assumptions by category
const categories: CategoryGroup[] = [
{ key: 'revenue', label: lang === 'de' ? 'Revenue' : 'Revenue', items: [] },
{ key: 'costs', label: lang === 'de' ? 'Kosten' : 'Costs', items: [] },
{ key: 'team', label: 'Team', items: [] },
{ key: 'funding', label: 'Funding', items: [] },
]
for (const a of assumptions) {
const cat = categories.find(c => c.key === a.category) || categories[0]
cat.items.push(a)
}
const toggleCategory = (key: string) => {
setOpenCategories(prev => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
return (
<div className="space-y-1">
{categories.filter(c => c.items.length > 0).map((cat) => {
const isOpen = openCategories.has(cat.key)
return (
<div key={cat.key} className="border border-white/[0.06] rounded-xl overflow-hidden">
<button
onClick={() => toggleCategory(cat.key)}
className="w-full flex items-center justify-between px-3 py-2 text-xs text-white/60 hover:text-white/80 transition-colors"
>
<span className="font-medium">{cat.label}</span>
{isOpen ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-3 pb-3 space-y-3">
{cat.items.map((a) => (
<Slider
key={a.key}
assumption={a}
onChange={(val) => onAssumptionChange(a.key, val)}
lang={lang}
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,33 @@
'use client'
import { motion } from 'framer-motion'
import { ReactNode } from 'react'
interface GlassCardProps {
children: ReactNode
className?: string
onClick?: () => void
delay?: number
hover?: boolean
}
export default function GlassCard({ children, className = '', onClick, delay = 0, hover = true }: GlassCardProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay }}
whileHover={hover ? { scale: 1.02, backgroundColor: 'rgba(255, 255, 255, 0.12)' } : undefined}
onClick={onClick}
className={`
bg-white/[0.08] backdrop-blur-xl
border border-white/10 rounded-3xl
p-6 transition-colors duration-200
${onClick ? 'cursor-pointer' : ''}
${className}
`}
>
{children}
</motion.div>
)
}

View File

@@ -0,0 +1,27 @@
'use client'
import { motion } from 'framer-motion'
import { ReactNode } from 'react'
interface GradientTextProps {
children: ReactNode
className?: string
delay?: number
}
export default function GradientText({ children, className = '', delay = 0 }: GradientTextProps) {
return (
<motion.span
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay }}
className={`
bg-gradient-to-r from-indigo-400 via-purple-400 to-blue-400
bg-clip-text text-transparent
${className}
`}
>
{children}
</motion.span>
)
}

View File

@@ -0,0 +1,59 @@
'use client'
import { motion } from 'framer-motion'
import { TrendingUp, TrendingDown } from 'lucide-react'
import AnimatedCounter from './AnimatedCounter'
interface KPICardProps {
label: string
value: number
prefix?: string
suffix?: string
decimals?: number
trend?: 'up' | 'down' | 'neutral'
color?: string
delay?: number
subLabel?: string
}
export default function KPICard({
label,
value,
prefix = '',
suffix = '',
decimals = 0,
trend = 'neutral',
color = '#6366f1',
delay = 0,
subLabel,
}: KPICardProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay }}
className="relative overflow-hidden bg-white/[0.06] backdrop-blur-xl border border-white/10 rounded-2xl p-4"
>
{/* Glow effect */}
<div
className="absolute -top-8 -right-8 w-24 h-24 rounded-full blur-3xl opacity-20"
style={{ backgroundColor: color }}
/>
<p className="text-[10px] uppercase tracking-wider text-white/40 mb-1">{label}</p>
<div className="flex items-end gap-2">
<p className="text-2xl font-bold text-white leading-none">
<AnimatedCounter target={value} prefix={prefix} suffix={suffix} duration={1200} decimals={decimals} />
</p>
{trend !== 'neutral' && (
<span className={`flex items-center gap-0.5 text-xs pb-0.5 ${trend === 'up' ? 'text-emerald-400' : 'text-red-400'}`}>
{trend === 'up' ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
</span>
)}
</div>
{subLabel && (
<p className="text-[10px] text-white/30 mt-1">{subLabel}</p>
)}
</motion.div>
)
}

View File

@@ -0,0 +1,13 @@
'use client'
export default function LiveIndicator({ className = '' }: { className?: string }) {
return (
<span className={`inline-flex items-center gap-1.5 ${className}`}>
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span>
<span className="text-xs text-green-400 font-medium">LIVE</span>
</span>
)
}

View File

@@ -0,0 +1,82 @@
'use client'
import { motion } from 'framer-motion'
import { PitchProduct, Language } from '@/lib/types'
import { t } from '@/lib/i18n'
import { Check } from 'lucide-react'
import ProductShowcase from './ProductShowcase'
interface PricingCardProps {
product: PitchProduct
lang: Language
delay?: number
}
export default function PricingCard({ product, lang, delay = 0 }: PricingCardProps) {
const i = t(lang)
const productType = product.name.includes('Mini')
? 'mini'
: product.name.includes('Studio')
? 'studio'
: 'cloud'
const features = lang === 'de' ? product.features_de : product.features_en
return (
<motion.div
initial={{ opacity: 0, y: 40, rotateY: -10 }}
animate={{ opacity: 1, y: 0, rotateY: 0 }}
transition={{ duration: 0.6, delay }}
className={`
relative bg-white/[0.08] backdrop-blur-xl
border rounded-3xl p-6
transition-all duration-300
${product.is_popular
? 'border-indigo-500/50 shadow-lg shadow-indigo-500/10'
: 'border-white/10 hover:border-white/20'
}
`}
>
{product.is_popular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-4 py-1 bg-indigo-500 rounded-full text-xs font-semibold">
{i.product.popular}
</div>
)}
<div className="flex flex-col items-center text-center">
<ProductShowcase type={productType} className="mb-4" />
<h3 className="text-xl font-bold mb-1">{product.name}</h3>
<p className="text-white/50 text-sm mb-4">{product.hardware}</p>
<div className="mb-1">
<span className="text-4xl font-bold">{product.monthly_price_eur}</span>
<span className="text-white/50 text-lg ml-1">EUR</span>
</div>
<p className="text-white/40 text-sm mb-6">{i.product.monthly}</p>
<div className="w-full border-t border-white/10 pt-4 mb-4">
<div className="flex justify-between text-sm mb-2">
<span className="text-white/50">{i.product.llm}</span>
<span className="font-medium">{product.llm_size}</span>
</div>
{product.hardware_cost_eur > 0 && (
<div className="flex justify-between text-sm">
<span className="text-white/50">{i.product.hardware}</span>
<span className="font-medium">{product.hardware_cost_eur.toLocaleString('de-DE')} EUR</span>
</div>
)}
</div>
<ul className="w-full space-y-2">
{(features || []).map((feature, idx) => (
<li key={idx} className="flex items-start gap-2 text-sm text-left">
<Check className="w-4 h-4 text-green-400 shrink-0 mt-0.5" />
<span className="text-white/70">{feature}</span>
</li>
))}
</ul>
</div>
</motion.div>
)
}

View File

@@ -0,0 +1,50 @@
'use client'
import { motion } from 'framer-motion'
import { Cloud } from 'lucide-react'
interface ProductShowcaseProps {
type: 'mini' | 'studio' | 'cloud'
className?: string
}
const PRODUCT_IMAGES = {
mini: 'https://www.apple.com/newsroom/images/2024/10/apples-new-mac-mini-apples-new-mac-mini-is-more-mighty-more-mini-and-built-for-apple-intelligence/article/Apple-Mac-mini-hero_big.jpg.large.jpg',
studio: 'https://www.apple.com/newsroom/images/2025/03/apple-unveils-new-mac-studio-the-most-powerful-mac-ever/article/Apple-Mac-Studio-front-250305_big.jpg.large.jpg',
}
export default function ProductShowcase({ type, className = '' }: ProductShowcaseProps) {
if (type === 'cloud') {
return (
<motion.div
className={`relative ${className}`}
whileHover={{ rotateY: 5, rotateX: -5, scale: 1.05 }}
transition={{ type: 'spring', stiffness: 300 }}
style={{ perspective: 1000, transformStyle: 'preserve-3d' }}
>
<div className="w-28 h-28 rounded-2xl bg-gradient-to-br from-purple-500 to-pink-500
flex items-center justify-center shadow-lg shadow-purple-500/20">
<Cloud className="w-14 h-14 text-white" />
</div>
</motion.div>
)
}
return (
<motion.div
className={`relative ${className}`}
whileHover={{ rotateY: 5, rotateX: -5, scale: 1.05 }}
transition={{ type: 'spring', stiffness: 300 }}
style={{ perspective: 1000, transformStyle: 'preserve-3d' }}
>
<div className="w-28 h-28 rounded-2xl overflow-hidden shadow-lg shadow-indigo-500/20 bg-white/5">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={PRODUCT_IMAGES[type]}
alt={type === 'mini' ? 'Mac Mini M4 Pro' : 'Mac Studio M3 Ultra'}
className="w-full h-full object-cover"
/>
</div>
</motion.div>
)
}

View File

@@ -0,0 +1,133 @@
'use client'
import { motion } from 'framer-motion'
import { useEffect, useState } from 'react'
interface RunwayGaugeProps {
months: number
maxMonths?: number
size?: number
label?: string
}
export default function RunwayGauge({ months, maxMonths = 36, size = 140, label = 'Runway' }: RunwayGaugeProps) {
const [animatedAngle, setAnimatedAngle] = useState(0)
const clampedMonths = Math.min(months, maxMonths)
const targetAngle = (clampedMonths / maxMonths) * 270 - 135 // -135 to +135 degrees
useEffect(() => {
const timer = setTimeout(() => setAnimatedAngle(targetAngle), 100)
return () => clearTimeout(timer)
}, [targetAngle])
// Color based on runway
const getColor = () => {
if (months >= 18) return '#22c55e' // green
if (months >= 12) return '#eab308' // yellow
if (months >= 6) return '#f97316' // orange
return '#ef4444' // red
}
const color = getColor()
const cx = size / 2
const cy = size / 2
const radius = (size / 2) - 16
const needleLength = radius - 10
// Arc path for gauge background
const startAngle = -135
const endAngle = 135
const polarToCartesian = (cx: number, cy: number, r: number, deg: number) => {
const rad = (deg - 90) * Math.PI / 180
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
}
const arcStart = polarToCartesian(cx, cy, radius, startAngle)
const arcEnd = polarToCartesian(cx, cy, radius, endAngle)
const arcPath = `M ${arcStart.x} ${arcStart.y} A ${radius} ${radius} 0 1 1 ${arcEnd.x} ${arcEnd.y}`
// Filled arc
const filledEnd = polarToCartesian(cx, cy, radius, Math.min(animatedAngle, endAngle))
const largeArc = (animatedAngle - startAngle) > 180 ? 1 : 0
const filledPath = `M ${arcStart.x} ${arcStart.y} A ${radius} ${radius} 0 ${largeArc} 1 ${filledEnd.x} ${filledEnd.y}`
// Needle endpoint
const needleRad = (animatedAngle - 90) * Math.PI / 180
const needleX = cx + needleLength * Math.cos(needleRad)
const needleY = cy + needleLength * Math.sin(needleRad)
const shouldPulse = months < 6
return (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, delay: 0.3 }}
className="flex flex-col items-center"
>
<div className={`relative ${shouldPulse ? 'animate-pulse' : ''}`} style={{ width: size, height: size * 0.8 }}>
<svg width={size} height={size * 0.8} viewBox={`0 0 ${size} ${size * 0.8}`}>
{/* Background arc */}
<path d={arcPath} fill="none" stroke="rgba(255,255,255,0.1)" strokeWidth="8" strokeLinecap="round" />
{/* Filled arc */}
<motion.path
d={filledPath}
fill="none"
stroke={color}
strokeWidth="8"
strokeLinecap="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 1.5, ease: 'easeOut' }}
/>
{/* Tick marks */}
{[0, 6, 12, 18, 24, 30, 36].map((tick) => {
const tickAngle = (tick / maxMonths) * 270 - 135
const inner = polarToCartesian(cx, cy, radius - 12, tickAngle)
const outer = polarToCartesian(cx, cy, radius - 6, tickAngle)
return (
<g key={tick}>
<line x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} stroke="rgba(255,255,255,0.3)" strokeWidth="1.5" />
<text
x={polarToCartesian(cx, cy, radius - 22, tickAngle).x}
y={polarToCartesian(cx, cy, radius - 22, tickAngle).y}
fill="rgba(255,255,255,0.3)"
fontSize="8"
textAnchor="middle"
dominantBaseline="central"
>
{tick}
</text>
</g>
)
})}
{/* Needle */}
<motion.line
x1={cx}
y1={cy}
x2={needleX}
y2={needleY}
stroke="white"
strokeWidth="2"
strokeLinecap="round"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
/>
{/* Center circle */}
<circle cx={cx} cy={cy} r="4" fill={color} />
<circle cx={cx} cy={cy} r="2" fill="white" />
</svg>
</div>
<div className="text-center -mt-2">
<p className="text-lg font-bold" style={{ color }}>{Math.round(months)}</p>
<p className="text-[10px] text-white/40 uppercase tracking-wider">{label}</p>
</div>
</motion.div>
)
}

View File

@@ -0,0 +1,63 @@
'use client'
import { FMScenario } from '@/lib/types'
import { motion } from 'framer-motion'
interface ScenarioSwitcherProps {
scenarios: FMScenario[]
activeId: string | null
compareMode: boolean
onSelect: (id: string) => void
onToggleCompare: () => void
lang: 'de' | 'en'
}
export default function ScenarioSwitcher({
scenarios,
activeId,
compareMode,
onSelect,
onToggleCompare,
lang,
}: ScenarioSwitcherProps) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-[10px] text-white/40 uppercase tracking-wider">
{lang === 'de' ? 'Szenarien' : 'Scenarios'}
</p>
<button
onClick={onToggleCompare}
className={`text-[10px] px-2 py-1 rounded-lg transition-colors
${compareMode
? 'bg-indigo-500/30 text-indigo-300 border border-indigo-500/40'
: 'bg-white/[0.06] text-white/40 border border-white/10 hover:text-white/60'
}`}
>
{lang === 'de' ? 'Vergleichen' : 'Compare'}
</button>
</div>
<div className="flex gap-2">
{scenarios.map((s) => (
<motion.button
key={s.id}
whileTap={{ scale: 0.95 }}
onClick={() => onSelect(s.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs transition-all
${activeId === s.id
? 'bg-white/[0.12] border border-white/20 text-white'
: 'bg-white/[0.04] border border-white/10 text-white/50 hover:text-white/70'
}`}
>
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: s.color }}
/>
{s.name}
</motion.button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
'use client'
import { motion } from 'framer-motion'
import { PitchMilestone, Language } from '@/lib/types'
import { CheckCircle2, Circle, Clock } from 'lucide-react'
interface TimelineProps {
milestones: PitchMilestone[]
lang: Language
}
export default function Timeline({ milestones, lang }: TimelineProps) {
return (
<div className="relative">
{/* Line */}
<div className="absolute left-6 top-0 bottom-0 w-px bg-gradient-to-b from-indigo-500 via-purple-500 to-white/10" />
<div className="space-y-6">
{milestones.map((m, i) => {
const Icon = m.status === 'completed' ? CheckCircle2 : m.status === 'in_progress' ? Clock : Circle
const iconColor = m.status === 'completed'
? 'text-green-400'
: m.status === 'in_progress'
? 'text-yellow-400'
: 'text-white/30'
const date = new Date(m.milestone_date)
const dateStr = date.toLocaleDateString(lang === 'de' ? 'de-DE' : 'en-US', {
month: 'short',
year: 'numeric',
})
return (
<motion.div
key={m.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.1 }}
className="relative flex items-start gap-4 pl-2"
>
<div className={`relative z-10 p-1 rounded-full bg-[#0a0a1a] ${iconColor}`}>
<Icon className="w-6 h-6" />
</div>
<div className="flex-1 pb-2">
<div className="flex items-center gap-3 mb-1">
<span className="text-xs font-mono text-white/40">{dateStr}</span>
{m.status === 'in_progress' && (
<span className="text-xs px-2 py-0.5 rounded-full bg-yellow-500/20 text-yellow-400">
In Progress
</span>
)}
</div>
<h4 className="font-semibold text-white">
{lang === 'de' ? m.title_de : m.title_en}
</h4>
<p className="text-sm text-white/50 mt-0.5">
{lang === 'de' ? m.description_de : m.description_en}
</p>
</div>
</motion.div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,93 @@
'use client'
import { motion } from 'framer-motion'
import AnimatedCounter from './AnimatedCounter'
interface UnitEconomicsCardsProps {
cac: number
ltv: number
ltvCacRatio: number
grossMargin: number
churnRate: number
lang: 'de' | 'en'
}
function MiniRing({ progress, color, size = 32 }: { progress: number; color: string; size?: number }) {
const radius = (size / 2) - 3
const circumference = 2 * Math.PI * radius
const offset = circumference - (Math.min(progress, 100) / 100) * circumference
return (
<svg width={size} height={size} className="shrink-0">
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke="rgba(255,255,255,0.1)" strokeWidth="3" />
<motion.circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth="3"
strokeLinecap="round"
strokeDasharray={circumference}
initial={{ strokeDashoffset: circumference }}
animate={{ strokeDashoffset: offset }}
transition={{ duration: 1.5, ease: 'easeOut' }}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
)
}
export default function UnitEconomicsCards({ cac, ltv, ltvCacRatio, grossMargin, churnRate, lang }: UnitEconomicsCardsProps) {
const cacPayback = cac > 0 ? Math.ceil(cac / ((ltv / (1 / (churnRate / 100))) || 1)) : 0
const cards = [
{
label: 'CAC Payback',
value: cacPayback,
suffix: lang === 'de' ? ' Mo.' : ' mo.',
ring: Math.min((cacPayback / 12) * 100, 100),
color: cacPayback <= 6 ? '#22c55e' : cacPayback <= 12 ? '#eab308' : '#ef4444',
sub: `CAC: ${cac.toLocaleString('de-DE')} EUR`,
},
{
label: 'LTV',
value: Math.round(ltv),
suffix: ' EUR',
ring: Math.min(ltvCacRatio * 10, 100),
color: ltvCacRatio >= 3 ? '#22c55e' : ltvCacRatio >= 1.5 ? '#eab308' : '#ef4444',
sub: `LTV/CAC: ${ltvCacRatio.toFixed(1)}x`,
},
{
label: 'Gross Margin',
value: grossMargin,
suffix: '%',
ring: grossMargin,
color: grossMargin >= 70 ? '#22c55e' : grossMargin >= 50 ? '#eab308' : '#ef4444',
sub: `Churn: ${churnRate}%`,
},
]
return (
<div className="grid grid-cols-3 gap-3">
{cards.map((card, i) => (
<motion.div
key={card.label}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 + i * 0.1 }}
className="bg-white/[0.06] backdrop-blur-xl border border-white/10 rounded-xl p-3 text-center"
>
<div className="flex justify-center mb-2">
<MiniRing progress={card.ring} color={card.color} />
</div>
<p className="text-sm font-bold text-white">
<AnimatedCounter target={card.value} suffix={card.suffix} duration={1000} />
</p>
<p className="text-[10px] text-white/40 mt-0.5">{card.label}</p>
<p className="text-[9px] text-white/25 mt-0.5">{card.sub}</p>
</motion.div>
))}
</div>
)
}

View File

@@ -0,0 +1,85 @@
'use client'
import { FMResult } from '@/lib/types'
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
ReferenceLine,
Cell,
} from 'recharts'
interface WaterfallChartProps {
results: FMResult[]
lang: 'de' | 'en'
}
export default function WaterfallChart({ results, lang }: WaterfallChartProps) {
// Sample quarterly data for cleaner display
const quarterlyData = results.filter((_, i) => i % 3 === 0).map((r) => {
const netCash = r.revenue_eur - r.total_costs_eur
return {
label: `${r.year.toString().slice(2)}/Q${Math.ceil(r.month_in_year / 3)}`,
month: r.month,
revenue: Math.round(r.revenue_eur),
costs: Math.round(-r.total_costs_eur),
net: Math.round(netCash),
cashBalance: Math.round(r.cash_balance_eur),
}
})
const formatValue = (value: number) => {
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(0)}k`
return value.toString()
}
return (
<div className="w-full h-[220px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={quarterlyData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<XAxis
dataKey="label"
stroke="rgba(255,255,255,0.3)"
tick={{ fill: 'rgba(255,255,255,0.4)', fontSize: 9 }}
interval={1}
/>
<YAxis
stroke="rgba(255,255,255,0.1)"
tick={{ fill: 'rgba(255,255,255,0.4)', fontSize: 10 }}
tickFormatter={formatValue}
/>
<Tooltip
contentStyle={{
background: 'rgba(10, 10, 26, 0.95)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 12,
color: '#fff',
fontSize: 11,
}}
formatter={(value: number, name: string) => [
formatValue(value) + ' EUR',
name === 'revenue' ? (lang === 'de' ? 'Umsatz' : 'Revenue')
: name === 'costs' ? (lang === 'de' ? 'Kosten' : 'Costs')
: 'Net',
]}
/>
<ReferenceLine y={0} stroke="rgba(255,255,255,0.2)" />
<Bar dataKey="revenue" radius={[3, 3, 0, 0]} barSize={14}>
{quarterlyData.map((entry, i) => (
<Cell key={i} fill="rgba(34, 197, 94, 0.7)" />
))}
</Bar>
<Bar dataKey="costs" radius={[0, 0, 3, 3]} barSize={14}>
{quarterlyData.map((entry, i) => (
<Cell key={i} fill="rgba(239, 68, 68, 0.5)" />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)
}