feat(pitch-deck): add AI Presenter mode with LiteLLM migration and FAQ system
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 27s
CI / test-python-voice (push) Successful in 25s
CI / test-bqas (push) Successful in 25s
CI / Deploy (push) Successful in 4s
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 27s
CI / test-python-voice (push) Successful in 25s
CI / test-bqas (push) Successful in 25s
CI / Deploy (push) Successful in 4s
- Migrate chat API from Ollama to LiteLLM (OpenAI-compatible SSE) - Add 15-min presenter storyline with bilingual scripts for all 20 slides - Add FAQ system (30 entries) with keyword matching for instant answers - Add IntroPresenterSlide with avatar placeholder and start button - Add PresenterOverlay (progress bar, subtitle text, play/pause/stop) - Add AvatarPlaceholder with pulse animation during speaking - Add usePresenterMode hook (state machine: idle→presenting→paused→answering→resuming) - Add 'P' keyboard shortcut to toggle presenter mode - Support [GOTO:slide-id] markers in chat responses - Dynamic slide count (was hardcoded 13, now from SLIDE_ORDER) - TTS stub prepared for future Piper integration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,9 @@ 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'
|
||||
import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
|
||||
import { PresenterState } from '@/lib/presenter/types'
|
||||
import { matchFAQ, getFAQAnswer } from '@/lib/presenter/faq-matcher'
|
||||
|
||||
interface ChatFABProps {
|
||||
lang: Language
|
||||
@@ -12,6 +15,8 @@ interface ChatFABProps {
|
||||
currentIndex: number
|
||||
visitedSlides: Set<number>
|
||||
onGoToSlide: (index: number) => void
|
||||
presenterState?: PresenterState
|
||||
onPresenterInterrupt?: () => void
|
||||
}
|
||||
|
||||
interface ParsedMessage {
|
||||
@@ -62,18 +67,31 @@ function parseAgentResponse(content: string, lang: Language): ParsedMessage {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse GOTO markers from the text
|
||||
const gotoRegex = /\[GOTO:(\d+)\]/g
|
||||
// Parse GOTO markers — support both [GOTO:N] (numeric) and [GOTO:slide-id] (string)
|
||||
const gotoRegex = /\[GOTO:([\w-]+)\]/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}`,
|
||||
})
|
||||
const target = gotoMatch[1]
|
||||
let slideIndex: number
|
||||
|
||||
// Try numeric index first
|
||||
const numericIndex = parseInt(target)
|
||||
if (!isNaN(numericIndex) && numericIndex >= 0 && numericIndex < SLIDE_ORDER.length) {
|
||||
slideIndex = numericIndex
|
||||
} else {
|
||||
// Try slide ID lookup
|
||||
slideIndex = SLIDE_ORDER.indexOf(target as SlideId)
|
||||
}
|
||||
|
||||
if (slideIndex >= 0) {
|
||||
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, '')
|
||||
text = text.replace(/\s*\[GOTO:[\w-]+\]/g, '')
|
||||
|
||||
// Clean up trailing reminder instruction that might leak through
|
||||
text = text.replace(/\n*\(Erinnerung:.*?\)\s*$/s, '').trim()
|
||||
@@ -81,7 +99,15 @@ function parseAgentResponse(content: string, lang: Language): ParsedMessage {
|
||||
return { text: text.trim(), followUps, gotos }
|
||||
}
|
||||
|
||||
export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlides, onGoToSlide }: ChatFABProps) {
|
||||
export default function ChatFAB({
|
||||
lang,
|
||||
currentSlide,
|
||||
currentIndex,
|
||||
visitedSlides,
|
||||
onGoToSlide,
|
||||
presenterState = 'idle',
|
||||
onPresenterInterrupt,
|
||||
}: ChatFABProps) {
|
||||
const i = t(lang)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
@@ -124,28 +150,43 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
|
||||
const message = text || input.trim()
|
||||
if (!message || isStreaming) return
|
||||
|
||||
// Interrupt presenter if it's running
|
||||
if (presenterState === 'presenting' && onPresenterInterrupt) {
|
||||
onPresenterInterrupt()
|
||||
}
|
||||
|
||||
setInput('')
|
||||
setMessages(prev => [...prev, { role: 'user', content: message }])
|
||||
setIsStreaming(true)
|
||||
setIsWaiting(true)
|
||||
|
||||
// Check FAQ first for instant response
|
||||
const faqMatch = matchFAQ(message, lang)
|
||||
|
||||
abortRef.current = new AbortController()
|
||||
|
||||
try {
|
||||
const requestBody: Record<string, unknown> = {
|
||||
message,
|
||||
history: messages.slice(-10),
|
||||
lang,
|
||||
slideContext: {
|
||||
currentSlide,
|
||||
currentIndex,
|
||||
visitedSlides: Array.from(visitedSlides),
|
||||
totalSlides: SLIDE_ORDER.length,
|
||||
},
|
||||
}
|
||||
|
||||
// If FAQ matched, send the cached answer for fast streaming (no LLM call)
|
||||
if (faqMatch) {
|
||||
requestBody.faqAnswer = getFAQAnswer(faqMatch, lang)
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}),
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: abortRef.current.signal,
|
||||
})
|
||||
|
||||
@@ -175,6 +216,20 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// If FAQ matched and has a goto_slide, add a GOTO marker to the response
|
||||
if (faqMatch?.goto_slide) {
|
||||
const gotoIdx = SLIDE_ORDER.indexOf(faqMatch.goto_slide)
|
||||
if (gotoIdx >= 0) {
|
||||
const suffix = `\n\n[GOTO:${faqMatch.goto_slide}]`
|
||||
content += suffix
|
||||
setMessages(prev => {
|
||||
const updated = [...prev]
|
||||
updated[updated.length - 1] = { role: 'assistant', content }
|
||||
return updated
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
console.error('Chat error:', err)
|
||||
@@ -277,6 +332,10 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
|
||||
<circle cx="12" cy="10" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="10" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
{/* Presenter active indicator */}
|
||||
{presenterState !== 'idle' && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 rounded-full bg-green-400 border-2 border-black animate-pulse" />
|
||||
)}
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -307,7 +366,9 @@ export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlide
|
||||
<span className="text-xs text-white/30 ml-2">
|
||||
{isStreaming
|
||||
? (lang === 'de' ? 'antwortet...' : 'responding...')
|
||||
: (lang === 'de' ? 'online' : 'online')
|
||||
: presenterState !== 'idle'
|
||||
? (lang === 'de' ? 'Presenter aktiv' : 'Presenter active')
|
||||
: (lang === 'de' ? 'online' : 'online')
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AnimatePresence } from 'framer-motion'
|
||||
import { useSlideNavigation } from '@/lib/hooks/useSlideNavigation'
|
||||
import { useKeyboard } from '@/lib/hooks/useKeyboard'
|
||||
import { usePitchData } from '@/lib/hooks/usePitchData'
|
||||
import { usePresenterMode } from '@/lib/hooks/usePresenterMode'
|
||||
import { Language, PitchData } from '@/lib/types'
|
||||
|
||||
import ParticleBackground from './ParticleBackground'
|
||||
@@ -14,7 +15,10 @@ import NavigationFAB from './NavigationFAB'
|
||||
import ChatFAB from './ChatFAB'
|
||||
import SlideOverview from './SlideOverview'
|
||||
import SlideContainer from './SlideContainer'
|
||||
import PresenterOverlay from './presenter/PresenterOverlay'
|
||||
import AvatarPlaceholder from './presenter/AvatarPlaceholder'
|
||||
|
||||
import IntroPresenterSlide from './slides/IntroPresenterSlide'
|
||||
import CoverSlide from './slides/CoverSlide'
|
||||
import ProblemSlide from './slides/ProblemSlide'
|
||||
import SolutionSlide from './slides/SolutionSlide'
|
||||
@@ -45,6 +49,13 @@ export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
|
||||
const nav = useSlideNavigation()
|
||||
const [fabOpen, setFabOpen] = useState(false)
|
||||
|
||||
const presenter = usePresenterMode({
|
||||
goToSlide: nav.goToSlide,
|
||||
currentSlide: nav.currentIndex,
|
||||
totalSlides: nav.totalSlides,
|
||||
language: lang,
|
||||
})
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen()
|
||||
@@ -66,6 +77,7 @@ export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
|
||||
onFullscreen: toggleFullscreen,
|
||||
onLanguageToggle: onToggleLanguage,
|
||||
onMenuToggle: toggleMenu,
|
||||
onPresenterToggle: presenter.toggle,
|
||||
onGoToSlide: nav.goToSlide,
|
||||
enabled: !nav.showOverview,
|
||||
})
|
||||
@@ -96,6 +108,14 @@ export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
|
||||
if (!data) return null
|
||||
|
||||
switch (nav.currentSlide) {
|
||||
case 'intro-presenter':
|
||||
return (
|
||||
<IntroPresenterSlide
|
||||
lang={lang}
|
||||
onStartPresenter={presenter.start}
|
||||
isPresenting={presenter.state !== 'idle'}
|
||||
/>
|
||||
)
|
||||
case 'cover':
|
||||
return <CoverSlide lang={lang} onNext={nav.nextSlide} funding={data.funding} />
|
||||
case 'problem':
|
||||
@@ -163,6 +183,8 @@ export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
|
||||
currentIndex={nav.currentIndex}
|
||||
visitedSlides={nav.visitedSlides}
|
||||
onGoToSlide={nav.goToSlide}
|
||||
presenterState={presenter.state}
|
||||
onPresenterInterrupt={presenter.pause}
|
||||
/>
|
||||
|
||||
<NavigationFAB
|
||||
@@ -174,6 +196,21 @@ export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
|
||||
onToggleLanguage={onToggleLanguage}
|
||||
/>
|
||||
|
||||
{/* Presenter UI */}
|
||||
<AvatarPlaceholder state={presenter.state} />
|
||||
<PresenterOverlay
|
||||
state={presenter.state}
|
||||
currentIndex={nav.currentIndex}
|
||||
totalSlides={nav.totalSlides}
|
||||
progress={presenter.progress}
|
||||
displayText={presenter.displayText}
|
||||
lang={lang}
|
||||
onPause={presenter.pause}
|
||||
onResume={presenter.resume}
|
||||
onStop={presenter.stop}
|
||||
onSkip={presenter.skipSlide}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{nav.showOverview && (
|
||||
<SlideOverview
|
||||
|
||||
76
pitch-deck/components/presenter/AvatarPlaceholder.tsx
Normal file
76
pitch-deck/components/presenter/AvatarPlaceholder.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { PresenterState } from '@/lib/presenter/types'
|
||||
|
||||
interface AvatarPlaceholderProps {
|
||||
state: PresenterState
|
||||
}
|
||||
|
||||
export default function AvatarPlaceholder({ state }: AvatarPlaceholderProps) {
|
||||
const isSpeaking = state === 'presenting' || state === 'answering'
|
||||
const isIdle = state === 'idle'
|
||||
|
||||
if (isIdle) return null
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
className="fixed bottom-24 right-6 z-40"
|
||||
>
|
||||
<div className="relative w-16 h-16">
|
||||
{/* Pulse rings when speaking */}
|
||||
{isSpeaking && (
|
||||
<>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full border border-indigo-400/30"
|
||||
animate={{ scale: [1, 1.4, 1], opacity: [0.3, 0, 0.3] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full border border-purple-400/20"
|
||||
animate={{ scale: [1, 1.6, 1], opacity: [0.2, 0, 0.2] }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut', delay: 0.2 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Avatar circle */}
|
||||
<motion.div
|
||||
className={`w-16 h-16 rounded-full flex items-center justify-center shadow-lg
|
||||
${isSpeaking
|
||||
? 'bg-gradient-to-br from-indigo-500/40 to-purple-500/40 border-2 border-indigo-400/50'
|
||||
: 'bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-indigo-400/30'
|
||||
}`}
|
||||
animate={isSpeaking ? { scale: [1, 1.05, 1] } : {}}
|
||||
transition={{ duration: 0.8, repeat: Infinity, ease: 'easeInOut' }}
|
||||
>
|
||||
{/* Bot icon */}
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
className={isSpeaking ? 'text-indigo-300' : 'text-indigo-400/60'}
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="10" rx="2" />
|
||||
<circle cx="12" cy="5" r="2" />
|
||||
<path d="M12 7v4" />
|
||||
<circle cx="8" cy="16" r="1" fill="currentColor" />
|
||||
<circle cx="16" cy="16" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
|
||||
{/* State dot */}
|
||||
<div className={`absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-black/80
|
||||
${state === 'presenting' ? 'bg-green-400' :
|
||||
state === 'paused' ? 'bg-yellow-400' :
|
||||
state === 'answering' ? 'bg-blue-400' :
|
||||
'bg-indigo-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
161
pitch-deck/components/presenter/PresenterOverlay.tsx
Normal file
161
pitch-deck/components/presenter/PresenterOverlay.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Play, Pause, Square, SkipForward } from 'lucide-react'
|
||||
import { Language } from '@/lib/types'
|
||||
import { PresenterState } from '@/lib/presenter/types'
|
||||
import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
|
||||
import { t } from '@/lib/i18n'
|
||||
|
||||
interface PresenterOverlayProps {
|
||||
state: PresenterState
|
||||
currentIndex: number
|
||||
totalSlides: number
|
||||
progress: number
|
||||
displayText: string
|
||||
lang: Language
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
onStop: () => void
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
export default function PresenterOverlay({
|
||||
state,
|
||||
currentIndex,
|
||||
totalSlides,
|
||||
progress,
|
||||
displayText,
|
||||
lang,
|
||||
onPause,
|
||||
onResume,
|
||||
onStop,
|
||||
onSkip,
|
||||
}: PresenterOverlayProps) {
|
||||
const i = t(lang)
|
||||
const slideName = i.slideNames[currentIndex] || SLIDE_ORDER[currentIndex] || ''
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{state !== 'idle' && (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
className="fixed bottom-0 left-0 right-0 z-40 pointer-events-none"
|
||||
>
|
||||
<div className="mx-auto max-w-4xl px-4 pb-4 pointer-events-auto">
|
||||
<div className="bg-black/80 backdrop-blur-xl border border-white/10 rounded-2xl overflow-hidden shadow-2xl">
|
||||
{/* Progress bar */}
|
||||
<div className="h-1 bg-white/5">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-indigo-500 to-purple-500"
|
||||
style={{ width: `${Math.min(progress, 100)}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 py-3">
|
||||
{/* Top row: slide info + controls */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* State indicator */}
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
state === 'presenting' ? 'bg-green-400 animate-pulse' :
|
||||
state === 'paused' ? 'bg-yellow-400' :
|
||||
state === 'answering' ? 'bg-blue-400 animate-pulse' :
|
||||
state === 'resuming' ? 'bg-indigo-400 animate-pulse' :
|
||||
'bg-white/30'
|
||||
}`} />
|
||||
<span className="text-xs text-white/50 font-medium">
|
||||
{lang === 'de' ? 'Folie' : 'Slide'} {currentIndex + 1}/{totalSlides} — {slideName}
|
||||
</span>
|
||||
<span className="text-xs text-white/30">
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center
|
||||
hover:bg-white/20 transition-colors"
|
||||
title={lang === 'de' ? 'Naechste Folie' : 'Next slide'}
|
||||
>
|
||||
<SkipForward className="w-3.5 h-3.5 text-white/60" />
|
||||
</button>
|
||||
|
||||
{state === 'presenting' ? (
|
||||
<button
|
||||
onClick={onPause}
|
||||
className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center
|
||||
hover:bg-white/20 transition-colors"
|
||||
title={lang === 'de' ? 'Pausieren' : 'Pause'}
|
||||
>
|
||||
<Pause className="w-3.5 h-3.5 text-white/60" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onResume}
|
||||
className="w-7 h-7 rounded-full bg-indigo-500/30 flex items-center justify-center
|
||||
hover:bg-indigo-500/50 transition-colors"
|
||||
title={lang === 'de' ? 'Fortsetzen' : 'Resume'}
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 text-indigo-300" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onStop}
|
||||
className="w-7 h-7 rounded-full bg-red-500/20 flex items-center justify-center
|
||||
hover:bg-red-500/30 transition-colors"
|
||||
title={lang === 'de' ? 'Stoppen' : 'Stop'}
|
||||
>
|
||||
<Square className="w-3 h-3 text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtitle text */}
|
||||
<AnimatePresence mode="wait">
|
||||
{displayText && (
|
||||
<motion.p
|
||||
key={displayText.slice(0, 30)}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="text-sm text-white/70 leading-relaxed"
|
||||
>
|
||||
{displayText}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* State message */}
|
||||
{state === 'paused' && (
|
||||
<p className="text-xs text-yellow-400/60 mt-1">
|
||||
{lang === 'de' ? 'Pausiert — stellen Sie eine Frage oder druecken Sie Play' : 'Paused — ask a question or press play'}
|
||||
</p>
|
||||
)}
|
||||
{state === 'answering' && (
|
||||
<p className="text-xs text-blue-400/60 mt-1">
|
||||
{lang === 'de' ? 'Beantworte Ihre Frage...' : 'Answering your question...'}
|
||||
</p>
|
||||
)}
|
||||
{state === 'resuming' && (
|
||||
<p className="text-xs text-indigo-400/60 mt-1">
|
||||
{lang === 'de' ? 'Setze Praesentation fort...' : 'Resuming presentation...'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Language, PitchFeature, PitchCompetitor } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import {
|
||||
ChevronDown, ChevronRight, Globe, Building2, Users, TrendingUp,
|
||||
DollarSign, Cpu, Star, Check, X, Minus,
|
||||
DollarSign, Cpu, Star, Check, X, Minus, Shield, Tag,
|
||||
} from 'lucide-react'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
@@ -201,6 +201,12 @@ const ALL_FEATURES: ComparisonFeature[] = [
|
||||
{ de: 'SBOM-Generator (CycloneDX/SPDX)', en: 'SBOM Generator (CycloneDX/SPDX)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: true },
|
||||
{ de: 'Multi-Framework Consent SDK', en: 'Multi-Framework Consent SDK', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: true },
|
||||
{ de: 'RAG mit 2.274 Rechtstexten', en: 'RAG with 2,274 Legal Texts', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: true },
|
||||
// Pentesting & Code-Security (kein Compliance-Wettbewerber hat dies)
|
||||
{ de: 'SAST (Static Application Security Testing)', en: 'SAST (Static Application Security Testing)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: true },
|
||||
{ de: 'DAST (Dynamic Application Security Testing)', en: 'DAST (Dynamic Application Security Testing)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: true },
|
||||
{ de: 'LLM-Auto-Fix (automatische Code-Korrekturen)', en: 'LLM Auto-Fix (Automatic Code Corrections)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: true },
|
||||
{ de: 'Container-Security Scanning (Trivy)', en: 'Container Security Scanning (Trivy)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: true },
|
||||
{ de: 'Secret Detection (Gitleaks)', en: 'Secret Detection (Gitleaks)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: true },
|
||||
// Compliance Features (shared)
|
||||
{ de: 'DSGVO / GDPR', en: 'GDPR', bp: true, vanta: 'partial', drata: 'partial', sprinto: 'partial', proliance: true, dataguard: true, heydata: true, isDiff: false, isUSP: false },
|
||||
{ de: 'AI Act', en: 'AI Act', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: false },
|
||||
@@ -248,6 +254,179 @@ const DACH_NOTE = {
|
||||
en: 'Other DACH players: Secjur (Hamburg, AI compliance, ~€5.5M seed), Usercentrics (CMP only, $117M rev), Caralegal (privacy/risk, M&A 2025), 2B Advice (legacy, 20+ yrs), OneTrust (US enterprise, $500M+ ARR). None combines GDPR + code security + self-hosted AI.',
|
||||
}
|
||||
|
||||
// ─── Pricing Comparison Data ──────────────────────────────────────────────────
|
||||
|
||||
interface PricingTier {
|
||||
name: { de: string; en: string }
|
||||
price: string
|
||||
annual: string
|
||||
notes: { de: string; en: string }
|
||||
}
|
||||
|
||||
interface CompetitorPricing {
|
||||
name: string
|
||||
flag: string
|
||||
model: string
|
||||
publicPricing: boolean
|
||||
tiers: PricingTier[]
|
||||
setupFee: string
|
||||
isBP?: boolean
|
||||
}
|
||||
|
||||
const PRICING_COMPARISON: CompetitorPricing[] = [
|
||||
{
|
||||
name: 'ComplAI',
|
||||
flag: '🇩🇪',
|
||||
model: 'Self-Hosted',
|
||||
publicPricing: true,
|
||||
tiers: [
|
||||
{ name: { de: 'Starter', en: 'Starter' }, price: '€990/mo', annual: '€11.880/yr', notes: { de: 'Mac Mini, 30B LLM, 57 Module', en: 'Mac Mini, 30B LLM, 57 modules' } },
|
||||
{ name: { de: 'Professional', en: 'Professional' }, price: '€1.490/mo', annual: '€17.880/yr', notes: { de: 'Mac Studio, 70B LLM, Priority', en: 'Mac Studio, 70B LLM, priority' } },
|
||||
{ name: { de: 'Enterprise', en: 'Enterprise' }, price: '€2.990/mo', annual: '€35.880/yr', notes: { de: '2x Mac Studio, 1000B Cloud-LLM', en: '2x Mac Studio, 1000B cloud LLM' } },
|
||||
],
|
||||
setupFee: '€0',
|
||||
isBP: true,
|
||||
},
|
||||
{
|
||||
name: 'Vanta',
|
||||
flag: '🇺🇸',
|
||||
model: 'SaaS',
|
||||
publicPricing: false,
|
||||
tiers: [
|
||||
{ name: { de: 'Startup', en: 'Startup' }, price: '~$500/mo', annual: '~$6K/yr', notes: { de: '1 Framework, <50 MA', en: '1 framework, <50 employees' } },
|
||||
{ name: { de: 'Business', en: 'Business' }, price: '~$2K/mo', annual: '~$25K/yr', notes: { de: 'Multi-Framework, VRM', en: 'Multi-framework, VRM' } },
|
||||
{ name: { de: 'Enterprise', en: 'Enterprise' }, price: '~$5-7K/mo', annual: '~$60-80K/yr', notes: { de: 'Custom, SSO, RBAC', en: 'Custom, SSO, RBAC' } },
|
||||
],
|
||||
setupFee: '~$5-15K',
|
||||
},
|
||||
{
|
||||
name: 'Drata',
|
||||
flag: '🇺🇸',
|
||||
model: 'SaaS',
|
||||
publicPricing: false,
|
||||
tiers: [
|
||||
{ name: { de: 'Foundation', en: 'Foundation' }, price: '~$500/mo', annual: '~$5-8K/yr', notes: { de: '1 Framework, Basis', en: '1 framework, basic' } },
|
||||
{ name: { de: 'Business', en: 'Business' }, price: '~$1.5K/mo', annual: '~$18-20K/yr', notes: { de: 'Multi-Framework, API', en: 'Multi-framework, API' } },
|
||||
{ name: { de: 'Enterprise', en: 'Enterprise' }, price: '~$4-8K/mo', annual: '~$50-100K/yr', notes: { de: 'SafeBase, Custom', en: 'SafeBase, custom' } },
|
||||
],
|
||||
setupFee: '~$5-10K',
|
||||
},
|
||||
{
|
||||
name: 'Sprinto',
|
||||
flag: '🇮🇳',
|
||||
model: 'SaaS',
|
||||
publicPricing: false,
|
||||
tiers: [
|
||||
{ name: { de: 'Growth', en: 'Growth' }, price: '~$350/mo', annual: '~$4K/yr', notes: { de: '1 Framework, KMU', en: '1 framework, SMB' } },
|
||||
{ name: { de: 'Business', en: 'Business' }, price: '~$1K/mo', annual: '~$12K/yr', notes: { de: 'Multi-Framework', en: 'Multi-framework' } },
|
||||
{ name: { de: 'Enterprise', en: 'Enterprise' }, price: '~$2K+/mo', annual: '~$25K+/yr', notes: { de: 'Custom Integrations', en: 'Custom integrations' } },
|
||||
],
|
||||
setupFee: '~$2-5K',
|
||||
},
|
||||
{
|
||||
name: 'Proliance',
|
||||
flag: '🇩🇪',
|
||||
model: 'SaaS',
|
||||
publicPricing: true,
|
||||
tiers: [
|
||||
{ name: { de: 'Basis', en: 'Basic' }, price: '€99/mo', annual: '€1.188/yr', notes: { de: 'DSGVO-Grundlagen', en: 'GDPR basics' } },
|
||||
{ name: { de: 'Professional', en: 'Professional' }, price: '€249/mo', annual: '€2.988/yr', notes: { de: '+ Audit, VVT', en: '+ Audit, records' } },
|
||||
{ name: { de: 'Enterprise', en: 'Enterprise' }, price: '€499/mo', annual: '€5.988/yr', notes: { de: 'Multi-Standort, DSB', en: 'Multi-location, DPO' } },
|
||||
],
|
||||
setupFee: '€0',
|
||||
},
|
||||
{
|
||||
name: 'DataGuard',
|
||||
flag: '🇩🇪',
|
||||
model: 'SaaS + Beratung',
|
||||
publicPricing: false,
|
||||
tiers: [
|
||||
{ name: { de: 'Starter', en: 'Starter' }, price: '~€250/mo', annual: '~€3K/yr', notes: { de: 'Nur Software', en: 'Software only' } },
|
||||
{ name: { de: 'Managed', en: 'Managed' }, price: '~€1K/mo', annual: '~€12K/yr', notes: { de: '+ Ext. DSB', en: '+ Ext. DPO' } },
|
||||
{ name: { de: 'Enterprise', en: 'Enterprise' }, price: '~€2K+/mo', annual: '~€24K+/yr', notes: { de: 'ISO 27001 + TISAX', en: 'ISO 27001 + TISAX' } },
|
||||
],
|
||||
setupFee: '~€2-5K',
|
||||
},
|
||||
{
|
||||
name: 'heyData',
|
||||
flag: '🇩🇪',
|
||||
model: 'SaaS',
|
||||
publicPricing: true,
|
||||
tiers: [
|
||||
{ name: { de: 'Essential', en: 'Essential' }, price: '€83/mo', annual: '€996/yr', notes: { de: '1-19 MA, DSGVO', en: '1-19 empl., GDPR' } },
|
||||
{ name: { de: 'Pro', en: 'Pro' }, price: '€199/mo', annual: '€2.388/yr', notes: { de: '20-99 MA, DSB', en: '20-99 empl., DPO' } },
|
||||
{ name: { de: 'Premium', en: 'Premium' }, price: '€333/mo', annual: '€3.996/yr', notes: { de: '100+ MA, Audit', en: '100+ empl., audit' } },
|
||||
],
|
||||
setupFee: '€0',
|
||||
},
|
||||
]
|
||||
|
||||
// ─── AppSec / Pentesting Competitor Data ─────────────────────────────────────
|
||||
|
||||
interface AppSecCompetitor {
|
||||
name: string
|
||||
flag: string
|
||||
hq: string
|
||||
founded: number
|
||||
employees: number
|
||||
revenue: string
|
||||
revenueNum: number
|
||||
customers: string
|
||||
funding: string
|
||||
pricing: string
|
||||
focus: { de: string; en: string }
|
||||
}
|
||||
|
||||
const APPSEC_COMPETITORS: AppSecCompetitor[] = [
|
||||
{ name: 'Snyk', flag: '🇺🇸', hq: 'Boston', founded: 2015, employees: 1200, revenue: '~$300M ARR', revenueNum: 300_000_000, customers: '3.000+', funding: '$850M (Series G, $7.4B)', pricing: '$25K–100K+/yr', focus: { de: 'SCA + SAST, Developer-First', en: 'SCA + SAST, developer-first' } },
|
||||
{ name: 'Veracode', flag: '🇺🇸', hq: 'Burlington, MA', founded: 2006, employees: 1300, revenue: '~$300M', revenueNum: 300_000_000, customers: '3.500+', funding: 'PE (Thoma Bravo, $2.5B)', pricing: '$50K–500K+/yr', focus: { de: 'SAST + DAST + SCA, Enterprise', en: 'SAST + DAST + SCA, enterprise' } },
|
||||
{ name: 'Checkmarx', flag: '🇮🇱', hq: 'Tel Aviv', founded: 2006, employees: 1000, revenue: '~$250M', revenueNum: 250_000_000, customers: '1.800+', funding: 'PE (Hellman & Friedman)', pricing: '$40K–300K+/yr', focus: { de: 'SAST + DAST + SCA + API', en: 'SAST + DAST + SCA + API' } },
|
||||
{ name: 'SonarSource', flag: '🇨🇭', hq: 'Genf', founded: 2008, employees: 500, revenue: '~$250M', revenueNum: 250_000_000, customers: '400K+ Devs', funding: '$412M (Series D)', pricing: '$15K–150K+/yr', focus: { de: 'Code-Qualitaet + SAST', en: 'Code quality + SAST' } },
|
||||
{ name: 'Semgrep', flag: '🇺🇸', hq: 'San Francisco', founded: 2020, employees: 150, revenue: '~$30M ARR', revenueNum: 30_000_000, customers: '1.500+', funding: '$100M (Series C)', pricing: '$10K–100K+/yr', focus: { de: 'Open-Source SAST, Supply Chain', en: 'Open-source SAST, supply chain' } },
|
||||
{ name: 'Pentera', flag: '🇮🇱', hq: 'Tel Aviv', founded: 2015, employees: 400, revenue: '~$100M', revenueNum: 100_000_000, customers: '900+', funding: '$189M (Series C)', pricing: '$50K–250K+/yr', focus: { de: 'Automatisiertes Pentesting/BAS', en: 'Automated pentesting/BAS' } },
|
||||
{ name: 'Invicti', flag: '🇺🇸', hq: 'Austin, TX', founded: 2018, employees: 500, revenue: '~$100M', revenueNum: 100_000_000, customers: '3.000+', funding: 'PE (Turn/River)', pricing: '$15K–100K+/yr', focus: { de: 'DAST (Acunetix + Netsparker)', en: 'DAST (Acunetix + Netsparker)' } },
|
||||
{ name: 'Intruder', flag: '🇬🇧', hq: 'London', founded: 2015, employees: 100, revenue: '~$10M', revenueNum: 10_000_000, customers: '2.500+', funding: '$15M (Series A)', pricing: '$1.5K–20K+/yr', focus: { de: 'Vulnerability Scanner, SMB', en: 'Vulnerability scanner, SMB' } },
|
||||
]
|
||||
|
||||
interface AppSecFeature {
|
||||
de: string
|
||||
en: string
|
||||
bp: FeatureStatus
|
||||
snyk: FeatureStatus
|
||||
veracode: FeatureStatus
|
||||
checkmarx: FeatureStatus
|
||||
sonar: FeatureStatus
|
||||
semgrep: FeatureStatus
|
||||
pentera: FeatureStatus
|
||||
invicti: FeatureStatus
|
||||
intruder: FeatureStatus
|
||||
isUSP: boolean
|
||||
}
|
||||
|
||||
const APPSEC_FEATURES: AppSecFeature[] = [
|
||||
// ComplAI USPs — kein AppSec-Anbieter hat dies
|
||||
{ de: 'DSGVO / GDPR Compliance', en: 'GDPR Compliance', bp: true, snyk: false, veracode: false, checkmarx: false, sonar: false, semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: true },
|
||||
{ de: 'AI Act Compliance', en: 'AI Act Compliance', bp: true, snyk: false, veracode: false, checkmarx: false, sonar: false, semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: true },
|
||||
{ de: 'CRA & NIS2 Compliance', en: 'CRA & NIS2 Compliance', bp: true, snyk: false, veracode: false, checkmarx: false, sonar: false, semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: true },
|
||||
{ de: '57 Compliance-Module (SDK)', en: '57 Compliance Modules (SDK)', bp: true, snyk: false, veracode: false, checkmarx: false, sonar: false, semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: true },
|
||||
{ de: 'Self-Hosted KI (On-Premise)', en: 'Self-Hosted AI (On-Premise)', bp: true, snyk: false, veracode: false, checkmarx: false, sonar: false, semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: true },
|
||||
{ de: 'PII-Redaction LLM Gateway', en: 'PII Redaction LLM Gateway', bp: true, snyk: false, veracode: false, checkmarx: false, sonar: false, semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: true },
|
||||
{ de: 'Firmware & Embedded-Security', en: 'Firmware & Embedded Security', bp: true, snyk: false, veracode: 'partial', checkmarx: false, sonar: false, semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: true },
|
||||
// Shared AppSec Features
|
||||
{ de: 'SAST (Static Analysis)', en: 'SAST (Static Analysis)', bp: true, snyk: true, veracode: true, checkmarx: true, sonar: true, semgrep: true, pentera: false, invicti: false, intruder: false, isUSP: false },
|
||||
{ de: 'DAST (Dynamic Analysis)', en: 'DAST (Dynamic Analysis)', bp: true, snyk: false, veracode: true, checkmarx: true, sonar: false, semgrep: false, pentera: true, invicti: true, intruder: true, isUSP: false },
|
||||
{ de: 'SCA (Software Composition)', en: 'SCA (Software Composition)', bp: true, snyk: true, veracode: true, checkmarx: true, sonar: 'partial', semgrep: 'partial', pentera: false, invicti: false, intruder: false, isUSP: false },
|
||||
{ de: 'LLM-basierte Auto-Fixes', en: 'LLM-Based Auto-Fixes', bp: true, snyk: 'partial', veracode: 'partial', checkmarx: 'partial', sonar: 'partial', semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: false },
|
||||
{ de: 'SBOM-Generierung', en: 'SBOM Generation', bp: true, snyk: true, veracode: 'partial', checkmarx: 'partial', sonar: false, semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: false },
|
||||
{ de: 'Container-Security', en: 'Container Security', bp: true, snyk: true, veracode: true, checkmarx: true, sonar: false, semgrep: 'partial', pentera: false, invicti: false, intruder: false, isUSP: false },
|
||||
{ de: 'Secret Detection', en: 'Secret Detection', bp: true, snyk: false, veracode: false, checkmarx: false, sonar: 'partial', semgrep: true, pentera: false, invicti: false, intruder: false, isUSP: false },
|
||||
{ de: 'IaC Scanning', en: 'IaC Scanning', bp: true, snyk: true, veracode: false, checkmarx: false, sonar: false, semgrep: true, pentera: false, invicti: false, intruder: false, isUSP: false },
|
||||
{ de: 'CI/CD-Integration', en: 'CI/CD Integration', bp: true, snyk: true, veracode: true, checkmarx: true, sonar: true, semgrep: true, pentera: 'partial', invicti: 'partial', intruder: 'partial', isUSP: false },
|
||||
{ de: 'API-Security Testing', en: 'API Security Testing', bp: true, snyk: false, veracode: 'partial', checkmarx: true, sonar: false, semgrep: false, pentera: 'partial', invicti: true, intruder: 'partial', isUSP: false },
|
||||
{ de: 'Automatisiertes Pentesting', en: 'Automated Pentesting', bp: true, snyk: false, veracode: false, checkmarx: false, sonar: false, semgrep: false, pentera: true, invicti: false, intruder: true, isUSP: false },
|
||||
{ de: 'Self-Hosted / On-Premise', en: 'Self-Hosted / On-Premise', bp: true, snyk: false, veracode: false, checkmarx: 'partial', sonar: true, semgrep: 'partial', pentera: 'partial', invicti: 'partial', intruder: false, isUSP: false },
|
||||
]
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusIcon({ value }: { value: FeatureStatus }) {
|
||||
@@ -304,7 +483,7 @@ function SectionHeader({
|
||||
|
||||
// ─── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type ViewTab = 'overview' | 'features'
|
||||
type ViewTab = 'overview' | 'features' | 'pricing' | 'appsec'
|
||||
|
||||
export default function CompetitionSlide({ lang, features, competitors }: CompetitionSlideProps) {
|
||||
const i = t(lang)
|
||||
@@ -344,10 +523,12 @@ export default function CompetitionSlide({ lang, features, competitors }: Compet
|
||||
</FadeInView>
|
||||
|
||||
{/* Tab Bar */}
|
||||
<FadeInView delay={0.15} className="flex justify-center gap-2 mb-4">
|
||||
<FadeInView delay={0.15} className="flex justify-center gap-2 mb-4 flex-wrap">
|
||||
{([
|
||||
{ key: 'overview' as ViewTab, de: 'Ueberblick & Vergleich', en: 'Overview & Comparison' },
|
||||
{ key: 'features' as ViewTab, de: 'Feature-Matrix (Detail)', en: 'Feature Matrix (Detail)' },
|
||||
{ key: 'pricing' as ViewTab, de: 'Pricing-Vergleich', en: 'Pricing Comparison' },
|
||||
{ key: 'appsec' as ViewTab, de: 'Pentesting & AppSec', en: 'Pentesting & AppSec' },
|
||||
]).map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
@@ -509,6 +690,178 @@ export default function CompetitionSlide({ lang, features, competitors }: Compet
|
||||
</div>
|
||||
</FadeInView>
|
||||
)}
|
||||
|
||||
{/* ─── Tab: Pricing ─── */}
|
||||
{activeTab === 'pricing' && (
|
||||
<FadeInView delay={0.2}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px] border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="text-left py-2 px-2 text-white/40 font-medium min-w-[90px]">{lang === 'de' ? 'Anbieter' : 'Provider'}</th>
|
||||
<th className="py-2 px-1.5 text-white/40 font-medium text-center">{lang === 'de' ? 'Modell' : 'Model'}</th>
|
||||
<th className="py-2 px-1.5 text-white/40 font-medium text-center">{lang === 'de' ? 'Einstieg' : 'Entry'}</th>
|
||||
<th className="py-2 px-1.5 text-white/40 font-medium text-center">Mid</th>
|
||||
<th className="py-2 px-1.5 text-white/40 font-medium text-center">Enterprise</th>
|
||||
<th className="py-2 px-1.5 text-white/40 font-medium text-center">Setup</th>
|
||||
<th className="py-2 px-1.5 text-white/40 font-medium text-center">{lang === 'de' ? 'Oeffentlich' : 'Public'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PRICING_COMPARISON.map((cp) => (
|
||||
<tr key={cp.name} className={`border-b border-white/5 ${cp.isBP ? 'bg-indigo-500/5' : ''}`}>
|
||||
<td className="py-2 px-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{cp.flag}</span>
|
||||
<span className={`font-semibold ${cp.isBP ? 'text-indigo-400' : 'text-white/70'}`}>
|
||||
{cp.isBP ? <BrandName className="text-[11px]" /> : cp.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-1.5 text-center">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${cp.model === 'Self-Hosted' ? 'bg-green-500/15 text-green-400' : 'bg-white/5 text-white/40'}`}>
|
||||
{cp.model}
|
||||
</span>
|
||||
</td>
|
||||
{cp.tiers.map((tier, idx) => (
|
||||
<td key={idx} className="py-2 px-1.5 text-center">
|
||||
<div className={`font-semibold ${cp.isBP ? 'text-indigo-300' : 'text-white/70'}`}>{tier.price}</div>
|
||||
<div className="text-[10px] text-white/30">{tier.annual}</div>
|
||||
<div className="text-[10px] text-white/25 mt-0.5">{lang === 'de' ? tier.notes.de : tier.notes.en}</div>
|
||||
</td>
|
||||
))}
|
||||
<td className="py-2 px-1.5 text-center text-white/40">{cp.setupFee}</td>
|
||||
<td className="py-2 px-1.5 text-center">
|
||||
{cp.publicPricing
|
||||
? <Check className="w-3.5 h-3.5 text-green-400 mx-auto" />
|
||||
: <X className="w-3.5 h-3.5 text-white/15 mx-auto" />
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Key Insights */}
|
||||
<GlassCard className="!p-3 mt-4" hover={false}>
|
||||
<h4 className="text-xs font-semibold text-white/60 mb-2 flex items-center gap-1.5">
|
||||
<Tag className="w-3.5 h-3.5" />
|
||||
{lang === 'de' ? 'Pricing-Einordnung' : 'Pricing Context'}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3 text-[11px]">
|
||||
<div className="bg-white/[0.03] rounded-lg p-2">
|
||||
<div className="text-white/50 mb-1">{lang === 'de' ? 'Compliance-Only (DACH)' : 'Compliance Only (DACH)'}</div>
|
||||
<div className="text-white/80 font-medium">€83 – €499/mo</div>
|
||||
<div className="text-white/30 text-[10px]">{lang === 'de' ? 'Proliance, heyData — nur DSGVO, kein Code-Security' : 'Proliance, heyData — GDPR only, no code security'}</div>
|
||||
</div>
|
||||
<div className="bg-white/[0.03] rounded-lg p-2">
|
||||
<div className="text-white/50 mb-1">{lang === 'de' ? 'US-Enterprise (Global)' : 'US Enterprise (Global)'}</div>
|
||||
<div className="text-white/80 font-medium">$500 – $7K+/mo</div>
|
||||
<div className="text-white/30 text-[10px]">{lang === 'de' ? 'Vanta, Drata — SOC 2 Fokus, Setup-Gebuehr, kein Self-Hosted' : 'Vanta, Drata — SOC 2 focus, setup fee, no self-hosted'}</div>
|
||||
</div>
|
||||
<div className="bg-indigo-500/5 border border-indigo-500/10 rounded-lg p-2">
|
||||
<div className="text-indigo-400 mb-1 font-medium">ComplAI</div>
|
||||
<div className="text-white/80 font-medium">€990 – €2.990/mo</div>
|
||||
<div className="text-white/30 text-[10px]">{lang === 'de' ? 'Compliance + Code-Security + Self-Hosted KI, kein Setup' : 'Compliance + code security + self-hosted AI, no setup fee'}</div>
|
||||
</div>
|
||||
<div className="bg-white/[0.03] rounded-lg p-2">
|
||||
<div className="text-white/50 mb-1">{lang === 'de' ? 'AppSec-Tools (separat)' : 'AppSec Tools (separate)'}</div>
|
||||
<div className="text-white/80 font-medium">$10K – $500K+/yr</div>
|
||||
<div className="text-white/30 text-[10px]">{lang === 'de' ? 'Snyk, Veracode — keine Compliance, Cloud-only' : 'Snyk, Veracode — no compliance, cloud only'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<p className="text-[10px] text-white/25 text-center mt-3 italic">
|
||||
{lang === 'de'
|
||||
? '~ = geschaetzte Preise (nicht oeffentlich). Alle Preise ohne MwSt. Stand: Q1 2026.'
|
||||
: '~ = estimated pricing (not public). All prices excl. VAT. As of Q1 2026.'}
|
||||
</p>
|
||||
</FadeInView>
|
||||
)}
|
||||
|
||||
{/* ─── Tab: AppSec / Pentesting ─── */}
|
||||
{activeTab === 'appsec' && (
|
||||
<FadeInView delay={0.2}>
|
||||
{/* Intro */}
|
||||
<GlassCard className="!p-3 mb-4" hover={false}>
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield className="w-4 h-4 text-red-400 mt-0.5 shrink-0" />
|
||||
<div className="text-[11px]">
|
||||
<span className="text-white/80 font-semibold">
|
||||
{lang === 'de' ? 'Warum ein 2. Wettbewerbsvergleich?' : 'Why a 2nd competitive comparison?'}
|
||||
</span>
|
||||
<p className="text-white/50 mt-1 leading-relaxed">
|
||||
{lang === 'de'
|
||||
? 'Kein Compliance-Anbieter (Vanta, Drata, etc.) bietet DAST, SAST oder LLM-basierte Code-Fixes. Kein AppSec-Anbieter (Snyk, Veracode, etc.) bietet DSGVO/AI-Act-Compliance. ComplAI ist die einzige Plattform, die beides kombiniert.'
|
||||
: 'No compliance vendor (Vanta, Drata, etc.) offers DAST, SAST, or LLM-based code fixes. No AppSec vendor (Snyk, Veracode, etc.) offers GDPR/AI Act compliance. ComplAI is the only platform combining both.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* AppSec Competitor Cards */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Shield className="w-3.5 h-3.5 text-red-400" />
|
||||
<span className="text-xs font-semibold text-red-400">{lang === 'de' ? 'AppSec / Pentesting Anbieter' : 'AppSec / Pentesting Providers'}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{APPSEC_COMPETITORS.map(c => (
|
||||
<AppSecCard key={c.name} competitor={c} lang={lang} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AppSec Feature Matrix */}
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<SectionHeader
|
||||
label={lang === 'de' ? 'USP — nur ComplAI' : 'USP — ComplAI only'}
|
||||
count={APPSEC_FEATURES.filter(f => f.isUSP).length}
|
||||
open={openSections.has('appsec-usp')}
|
||||
onToggle={() => toggleSection('appsec-usp')}
|
||||
accent="text-indigo-400"
|
||||
/>
|
||||
{openSections.has('appsec-usp') && (
|
||||
<AppSecFeatureTable features={APPSEC_FEATURES.filter(f => f.isUSP)} lang={lang} highlight />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<SectionHeader
|
||||
label={lang === 'de' ? 'Alle AppSec Features' : 'All AppSec Features'}
|
||||
count={APPSEC_FEATURES.length}
|
||||
open={openSections.has('appsec-all')}
|
||||
onToggle={() => toggleSection('appsec-all')}
|
||||
/>
|
||||
{openSections.has('appsec-all') && (
|
||||
<AppSecFeatureTable features={APPSEC_FEATURES} lang={lang} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score Summary */}
|
||||
<div className="mt-4 flex items-center justify-center gap-4 flex-wrap">
|
||||
{[
|
||||
{ name: 'ComplAI', score: APPSEC_FEATURES.filter(f => f.bp === true).length, color: 'text-indigo-400' },
|
||||
{ name: 'Snyk', score: APPSEC_FEATURES.filter(f => f.snyk === true).length, color: 'text-white/50' },
|
||||
{ name: 'Veracode', score: APPSEC_FEATURES.filter(f => f.veracode === true).length, color: 'text-white/50' },
|
||||
{ name: 'Checkmarx', score: APPSEC_FEATURES.filter(f => f.checkmarx === true).length, color: 'text-white/50' },
|
||||
{ name: 'SonarSrc', score: APPSEC_FEATURES.filter(f => f.sonar === true).length, color: 'text-white/50' },
|
||||
{ name: 'Semgrep', score: APPSEC_FEATURES.filter(f => f.semgrep === true).length, color: 'text-white/50' },
|
||||
{ name: 'Pentera', score: APPSEC_FEATURES.filter(f => f.pentera === true).length, color: 'text-white/50' },
|
||||
{ name: 'Invicti', score: APPSEC_FEATURES.filter(f => f.invicti === true).length, color: 'text-white/50' },
|
||||
{ name: 'Intruder', score: APPSEC_FEATURES.filter(f => f.intruder === true).length, color: 'text-white/50' },
|
||||
].map(item => (
|
||||
<div key={item.name} className="text-center">
|
||||
<div className={`text-lg font-bold ${item.color}`}>{item.score}/{APPSEC_FEATURES.length}</div>
|
||||
<div className="text-[10px] text-white/40">{item.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FadeInView>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -619,3 +972,71 @@ function FeatureTable({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppSecCard({ competitor: c, lang }: { competitor: AppSecCompetitor; lang: Language }) {
|
||||
return (
|
||||
<div className="bg-white/[0.04] border border-white/5 rounded-xl p-2 text-[11px]">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="text-sm">{c.flag}</span>
|
||||
<span className="font-semibold text-white/80 text-xs">{c.name}</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-white/40 mb-1 truncate">{c.hq} · {c.founded}</div>
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5 text-white/50">
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="w-2.5 h-2.5 text-white/30" />
|
||||
<span className="text-white/70">{c.employees.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<DollarSign className="w-2.5 h-2.5 text-white/30" />
|
||||
<span className="text-white/70 truncate">{c.revenue}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 pt-1 border-t border-white/5 text-[10px]">
|
||||
<div className="text-white/40 truncate">{c.funding}</div>
|
||||
<div className="text-white/50 mt-0.5">{c.pricing}</div>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-white/35 truncate" title={c.focus[lang]}>
|
||||
{c.focus[lang]}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppSecFeatureTable({ features, lang, highlight }: { features: AppSecFeature[]; lang: Language; highlight?: boolean }) {
|
||||
const cols = ['bp', 'snyk', 'veracode', 'checkmarx', 'sonar', 'semgrep', 'pentera', 'invicti', 'intruder'] as const
|
||||
const labels = ['ComplAI', 'Snyk', 'Veracode', 'Checkmarx', 'Sonar', 'Semgrep', 'Pentera', 'Invicti', 'Intruder']
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto mt-1 mb-1">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="text-left py-1.5 px-2 text-white/40 font-medium min-w-[160px]">Feature</th>
|
||||
{labels.map((l, idx) => (
|
||||
<th key={l} className={`py-1.5 px-1 font-medium text-center whitespace-nowrap ${idx === 0 ? 'text-indigo-400' : 'text-white/50'}`}>
|
||||
{idx === 0 ? <BrandName className="text-[11px]" /> : l}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{features.map((f, i) => (
|
||||
<tr key={i} className={`border-b border-white/5 ${highlight && f.isUSP ? 'bg-indigo-500/5' : ''}`}>
|
||||
<td className="py-1.5 px-2 flex items-center gap-1.5">
|
||||
{f.isUSP && highlight && <Star className="w-3 h-3 text-yellow-400 shrink-0" />}
|
||||
<span className={f.isUSP && highlight ? 'text-white font-medium' : 'text-white/60'}>
|
||||
{lang === 'de' ? f.de : f.en}
|
||||
</span>
|
||||
</td>
|
||||
{cols.map(col => (
|
||||
<td key={col} className="py-1.5 px-1 text-center">
|
||||
<StatusIcon value={f[col] as FeatureStatus} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
116
pitch-deck/components/slides/IntroPresenterSlide.tsx
Normal file
116
pitch-deck/components/slides/IntroPresenterSlide.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { Play, MessageCircle, Pause } from 'lucide-react'
|
||||
import { Language } from '@/lib/types'
|
||||
import GradientText from '../ui/GradientText'
|
||||
|
||||
interface IntroPresenterSlideProps {
|
||||
lang: Language
|
||||
onStartPresenter?: () => void
|
||||
isPresenting?: boolean
|
||||
}
|
||||
|
||||
export default function IntroPresenterSlide({ lang, onStartPresenter, isPresenting }: IntroPresenterSlideProps) {
|
||||
const isDE = lang === 'de'
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center px-8 text-center">
|
||||
{/* Avatar Placeholder Circle */}
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||
className="relative mb-8"
|
||||
>
|
||||
<div className="w-32 h-32 rounded-full bg-gradient-to-br from-indigo-500/30 to-purple-500/30 border-2 border-indigo-400/40 flex items-center justify-center">
|
||||
{/* Pulse rings */}
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full border-2 border-indigo-400/20"
|
||||
animate={{ scale: [1, 1.3, 1], opacity: [0.4, 0, 0.4] }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full border-2 border-purple-400/20"
|
||||
animate={{ scale: [1, 1.5, 1], opacity: [0.3, 0, 0.3] }}
|
||||
transition={{ duration: 2.5, repeat: Infinity, ease: 'easeInOut', delay: 0.3 }}
|
||||
/>
|
||||
{/* Bot icon */}
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-indigo-300">
|
||||
<rect x="3" y="11" width="18" height="10" rx="2" />
|
||||
<circle cx="12" cy="5" r="2" />
|
||||
<path d="M12 7v4" />
|
||||
<circle cx="8" cy="16" r="1" fill="currentColor" />
|
||||
<circle cx="16" cy="16" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.5 }}
|
||||
>
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-3">
|
||||
<GradientText>{isDE ? 'KI-Praesentator' : 'AI Presenter'}</GradientText>
|
||||
</h1>
|
||||
<p className="text-lg text-white/60 max-w-lg mx-auto mb-8">
|
||||
{isDE
|
||||
? 'Ihr persoenlicher KI-Guide durch das BreakPilot ComplAI Pitch Deck. 15 Minuten, alle Fakten, jederzeit unterbrechbar.'
|
||||
: 'Your personal AI guide through the BreakPilot ComplAI pitch deck. 15 minutes, all facts, interruptible at any time.'}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Start Button */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5, duration: 0.5 }}
|
||||
>
|
||||
<button
|
||||
onClick={onStartPresenter}
|
||||
className="group relative px-8 py-4 rounded-2xl bg-gradient-to-r from-indigo-600 to-purple-600
|
||||
hover:from-indigo-500 hover:to-purple-500 transition-all duration-300
|
||||
text-white font-semibold text-lg shadow-lg shadow-indigo-600/30
|
||||
hover:shadow-xl hover:shadow-indigo-600/40 hover:scale-105"
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
{isPresenting ? (
|
||||
<>
|
||||
<Pause className="w-5 h-5" />
|
||||
{isDE ? 'Praesentation laeuft...' : 'Presentation running...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-5 h-5" />
|
||||
{isDE ? 'Praesentation starten' : 'Start Presentation'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
{/* Interaction hints */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.8, duration: 0.5 }}
|
||||
className="mt-10 flex flex-col md:flex-row gap-4 text-sm text-white/40"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
<span>{isDE ? 'Jederzeit Fragen im Chat stellen' : 'Ask questions in chat anytime'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-1.5 py-0.5 bg-white/10 rounded text-xs font-mono">P</span>
|
||||
<span>{isDE ? 'Taste P: Presenter An/Aus' : 'Press P: Toggle Presenter'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-1.5 py-0.5 bg-white/10 rounded text-xs font-mono">ESC</span>
|
||||
<span>{isDE ? 'Slide-Uebersicht' : 'Slide Overview'}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Language, PitchMarket } from '@/lib/types'
|
||||
import { t, formatEur } from '@/lib/i18n'
|
||||
import { ExternalLink, X, TrendingUp } from 'lucide-react'
|
||||
import { ExternalLink, X, TrendingUp, Shield } from 'lucide-react'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
import AnimatedCounter from '../ui/AnimatedCounter'
|
||||
@@ -60,6 +60,61 @@ const marketSources: Record<string, MarketSourceInfo[]> = {
|
||||
],
|
||||
}
|
||||
|
||||
// ─── Pentesting / AppSec Market Data ──────────────────────────────────────────
|
||||
|
||||
type MarketView = 'compliance' | 'pentesting'
|
||||
|
||||
interface PentestMarketEntry {
|
||||
segment: string
|
||||
label: { de: string; en: string }
|
||||
value_eur: number
|
||||
growth_rate_pct: number
|
||||
source: string
|
||||
}
|
||||
|
||||
const PENTEST_MARKET: PentestMarketEntry[] = [
|
||||
{ segment: 'TAM', label: { de: 'Total Addressable Market', en: 'Total Addressable Market' }, value_eur: 13_000_000_000, growth_rate_pct: 17, source: 'Gartner + MarketsAndMarkets 2025' },
|
||||
{ segment: 'SAM', label: { de: 'Serviceable Addressable Market', en: 'Serviceable Addressable Market' }, value_eur: 1_600_000_000, growth_rate_pct: 22, source: 'DACH AST + Pentesting (Bottom-Up)' },
|
||||
{ segment: 'SOM', label: { de: 'Serviceable Obtainable Market', en: 'Serviceable Obtainable Market' }, value_eur: 35_000_000, growth_rate_pct: 0, source: 'Year 5 Target (500 Kunden)' },
|
||||
]
|
||||
|
||||
const pentestMarketSources: Record<string, MarketSourceInfo[]> = {
|
||||
TAM: [
|
||||
{
|
||||
name: 'MarketsAndMarkets — Application Security Testing Market 2025',
|
||||
url: 'https://www.marketsandmarkets.com/Market-Reports/application-security-testing-market-150735030.html',
|
||||
date: '2025',
|
||||
excerpt_de: 'Der globale AST-Markt (SAST, DAST, IAST, SCA) wird auf $8,5 Mrd. (2025) geschaetzt und soll bis 2030 auf $19,5 Mrd. wachsen (CAGR 18,2%). Hinzu kommt der Pentesting-Markt ($2,7 Mrd.) und der Compliance-Convergence-Anteil ($1,8 Mrd.). Gesamt-TAM fuer integriertes AppSec + Compliance: ~$13 Mrd.',
|
||||
excerpt_en: 'The global AST market (SAST, DAST, IAST, SCA) is estimated at $8.5B (2025), projected to reach $19.5B by 2030 (CAGR 18.2%). Adding the pentesting market ($2.7B) and compliance convergence share ($1.8B), total TAM for integrated AppSec + compliance: ~$13B.',
|
||||
},
|
||||
{
|
||||
name: 'Gartner — Magic Quadrant for Application Security Testing 2024',
|
||||
url: 'https://www.gartner.com/reviews/market/application-security-testing',
|
||||
date: '2024',
|
||||
excerpt_de: 'Gartner bestaetigt den Trend zur Konvergenz von AppSec und Compliance. Fuehrende Anbieter (Snyk, Veracode, Checkmarx) erreichen zusammen >$850M Umsatz. Der Markt waechst mit 17-20% p.a., getrieben durch regulatorische Anforderungen (CRA, NIS2) und AI-getriebene Entwicklung.',
|
||||
excerpt_en: 'Gartner confirms the AppSec-compliance convergence trend. Leading vendors (Snyk, Veracode, Checkmarx) generate >$850M combined revenue. The market grows at 17-20% p.a., driven by regulatory requirements (CRA, NIS2) and AI-driven development.',
|
||||
},
|
||||
],
|
||||
SAM: [
|
||||
{
|
||||
name: 'Bottom-Up: DACH AppSec + Manufacturing Pentesting',
|
||||
url: 'https://www.bitkom.org/Marktdaten/ITK-Konjunktur/IT-Markt-Deutschland',
|
||||
date: '2025-2026',
|
||||
excerpt_de: 'DACH IT-Security-Markt: €8,2 Mrd. (Bitkom 2025). AppSec-Anteil: ~15% = €1,2 Mrd. Davon Pentesting/DAST/SAST fuer produzierende Industrie: ~€400M. CRA-Pflicht fuer Maschinenbauer erzeugt neue Nachfrage: geschaetzt +€200M bis 2028. SAM fuer integriertes AppSec + Compliance im DACH-Manufacturing: ~€1,6 Mrd.',
|
||||
excerpt_en: 'DACH IT security market: €8.2B (Bitkom 2025). AppSec share: ~15% = €1.2B. Pentesting/DAST/SAST for manufacturing: ~€400M. CRA obligation for manufacturers creates new demand: est. +€200M by 2028. SAM for integrated AppSec + compliance in DACH manufacturing: ~€1.6B.',
|
||||
},
|
||||
],
|
||||
SOM: [
|
||||
{
|
||||
name: 'VDMA + Branchenbenchmarks — Pentesting SOM',
|
||||
url: 'https://www.vdma.org/statistics',
|
||||
date: '2025-2026',
|
||||
excerpt_de: 'Zielmarkt: 5.000 DACH-Maschinenbauer mit Eigenentwicklung. Bei 10% Durchdringung (500 Unternehmen) und €70K/Jahr Blended ARPU (Compliance €18K + AppSec €52K) ergibt sich ein SOM von €35 Mio. in Year 5. Zum Vergleich: Pentera erreicht mit 400 MA $100M ARR bei 900 Kunden. Intruder (100 MA) erreicht $10M bei 2.500 Kunden.',
|
||||
excerpt_en: 'Target market: 5,000 DACH manufacturers with in-house development. At 10% penetration (500 companies) and €70K/yr blended ARPU (compliance €18K + AppSec €52K), SOM is €35M in Year 5. For comparison: Pentera achieves $100M ARR with 400 employees and 900 customers. Intruder (100 employees) achieves $10M with 2,500 customers.',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
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']
|
||||
@@ -69,14 +124,16 @@ function SourceModal({
|
||||
onClose,
|
||||
segment,
|
||||
lang,
|
||||
sources: sourcesMap,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
segment: string
|
||||
lang: Language
|
||||
sources?: Record<string, MarketSourceInfo[]>
|
||||
}) {
|
||||
if (!isOpen) return null
|
||||
const sources = marketSources[segment] || []
|
||||
const sources = (sourcesMap || marketSources)[segment] || []
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -148,117 +205,201 @@ export default function MarketSlide({ lang, market }: MarketSlideProps) {
|
||||
const segments = [i.market.tam, i.market.sam, i.market.som]
|
||||
const segmentKeys = ['TAM', 'SAM', 'SOM']
|
||||
const [activeModal, setActiveModal] = useState<string | null>(null)
|
||||
const [marketView, setMarketView] = useState<MarketView>('compliance')
|
||||
|
||||
const activeSources = marketView === 'compliance' ? marketSources : pentestMarketSources
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FadeInView className="text-center mb-12">
|
||||
<FadeInView className="text-center mb-6">
|
||||
<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>
|
||||
{/* Market View Toggle */}
|
||||
<FadeInView delay={0.1} className="flex justify-center gap-2 mb-8">
|
||||
<button
|
||||
onClick={() => setMarketView('compliance')}
|
||||
className={`px-4 py-1.5 rounded-full text-xs font-medium transition-all ${
|
||||
marketView === 'compliance'
|
||||
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
|
||||
: 'bg-white/[0.04] text-white/40 border border-white/5 hover:bg-white/[0.08]'
|
||||
}`}
|
||||
>
|
||||
{lang === 'de' ? 'Compliance & Code-Security' : 'Compliance & Code Security'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMarketView('pentesting')}
|
||||
className={`px-4 py-1.5 rounded-full text-xs font-medium transition-all flex items-center gap-1.5 ${
|
||||
marketView === 'pentesting'
|
||||
? 'bg-red-500/20 text-red-300 border border-red-500/30'
|
||||
: 'bg-white/[0.04] text-white/40 border border-white/5 hover:bg-white/[0.08]'
|
||||
}`}
|
||||
>
|
||||
<Shield className="w-3 h-3" />
|
||||
{lang === 'de' ? 'Pentesting & AppSec' : 'Pentesting & AppSec'}
|
||||
</button>
|
||||
</FadeInView>
|
||||
|
||||
{/* Labels */}
|
||||
<div className="space-y-6">
|
||||
{market.map((m, idx) => {
|
||||
const segKey = segmentKeys[idx] || m.market_segment
|
||||
const sourceCount = marketSources[segKey]?.length || 0
|
||||
return (
|
||||
{/* Compliance Market */}
|
||||
{marketView === 'compliance' && (
|
||||
<div className="flex flex-col md:flex-row items-center justify-center gap-12">
|
||||
<div className="relative flex items-center justify-center" style={{ width: 300, height: 300 }}>
|
||||
{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="group cursor-pointer"
|
||||
onClick={() => setActiveModal(segKey)}
|
||||
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] }}
|
||||
>
|
||||
<div 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">
|
||||
{m.value_eur >= 1_000_000_000 ? (
|
||||
<AnimatedCounter
|
||||
target={m.value_eur / 1_000_000_000}
|
||||
suffix={lang === 'de' ? ' Mrd. EUR' : 'B EUR'}
|
||||
decimals={1}
|
||||
duration={1500}
|
||||
/>
|
||||
) : m.value_eur >= 1_000_000 ? (
|
||||
<AnimatedCounter
|
||||
target={m.value_eur / 1_000_000}
|
||||
suffix={lang === 'de' ? ' Mio. EUR' : 'M EUR'}
|
||||
decimals={1}
|
||||
duration={1500}
|
||||
/>
|
||||
) : (
|
||||
<AnimatedCounter
|
||||
target={m.value_eur / 1_000}
|
||||
suffix={'k EUR'}
|
||||
decimals={0}
|
||||
duration={1500}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{m.growth_rate_pct > 0 && (
|
||||
<span className="flex items-center gap-1 text-emerald-400">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
{m.growth_rate_pct}% p.a.
|
||||
</span>
|
||||
)}
|
||||
<span className="text-white/40">
|
||||
{i.market.source}: {m.source}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-indigo-400/60 group-hover:text-indigo-400 transition-colors mt-0.5">
|
||||
{sourceCount} {lang === 'de' ? (sourceCount === 1 ? 'Quelle' : 'Quellen') : (sourceCount === 1 ? 'source' : 'sources')}
|
||||
{' · '}
|
||||
{lang === 'de' ? 'Klicken fuer Details' : 'Click for details'}
|
||||
</p>
|
||||
{idx === market.length - 1 && (
|
||||
<div className="text-center">
|
||||
<span className={`text-xs font-mono ${textColors[idx]}`}>{segments[idx]}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{market.map((m, idx) => {
|
||||
const segKey = segmentKeys[idx] || m.market_segment
|
||||
const sourceCount = marketSources[segKey]?.length || 0
|
||||
return (
|
||||
<motion.div
|
||||
key={m.id}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.5 + idx * 0.15 }}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => setActiveModal(segKey)}
|
||||
>
|
||||
<div 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">
|
||||
{m.value_eur >= 1_000_000_000 ? (
|
||||
<AnimatedCounter target={m.value_eur / 1_000_000_000} suffix={lang === 'de' ? ' Mrd. EUR' : 'B EUR'} decimals={1} duration={1500} />
|
||||
) : m.value_eur >= 1_000_000 ? (
|
||||
<AnimatedCounter target={m.value_eur / 1_000_000} suffix={lang === 'de' ? ' Mio. EUR' : 'M EUR'} decimals={1} duration={1500} />
|
||||
) : (
|
||||
<AnimatedCounter target={m.value_eur / 1_000} suffix={'k EUR'} decimals={0} duration={1500} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{m.growth_rate_pct > 0 && (
|
||||
<span className="flex items-center gap-1 text-emerald-400">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
{m.growth_rate_pct}% p.a.
|
||||
</span>
|
||||
)}
|
||||
<span className="text-white/40">{i.market.source}: {m.source}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-indigo-400/60 group-hover:text-indigo-400 transition-colors mt-0.5">
|
||||
{sourceCount} {lang === 'de' ? (sourceCount === 1 ? 'Quelle' : 'Quellen') : (sourceCount === 1 ? 'source' : 'sources')}
|
||||
{' · '}{lang === 'de' ? 'Klicken fuer Details' : 'Click for details'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Modals */}
|
||||
{/* Pentesting Market */}
|
||||
{marketView === 'pentesting' && (
|
||||
<div className="flex flex-col md:flex-row items-center justify-center gap-12">
|
||||
<div className="relative flex items-center justify-center" style={{ width: 300, height: 300 }}>
|
||||
{PENTEST_MARKET.map((pm, idx) => (
|
||||
<motion.div
|
||||
key={pm.segment}
|
||||
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 ${
|
||||
idx === 0 ? 'border-red-500/30 bg-red-500/5' :
|
||||
idx === 1 ? 'border-orange-500/30 bg-orange-500/5' :
|
||||
'border-yellow-500/30 bg-yellow-500/5'
|
||||
} flex items-center justify-center`}
|
||||
style={{ width: sizes[idx], height: sizes[idx] }}
|
||||
>
|
||||
{idx === PENTEST_MARKET.length - 1 && (
|
||||
<div className="text-center">
|
||||
<span className="text-xs font-mono text-yellow-400">{pm.segment}</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{PENTEST_MARKET.map((pm, idx) => {
|
||||
const ptColors = ['text-red-400', 'text-orange-400', 'text-yellow-400']
|
||||
const sourceCount = pentestMarketSources[pm.segment]?.length || 0
|
||||
return (
|
||||
<motion.div
|
||||
key={pm.segment}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.5 + idx * 0.15 }}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => setActiveModal(pm.segment)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-3 h-3 rounded-full ${ptColors[idx]} bg-current`} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-bold ${ptColors[idx]}`}>{pm.segment}</span>
|
||||
<span className="text-xs text-white/30">{pm.label[lang]}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{pm.value_eur >= 1_000_000_000 ? (
|
||||
<AnimatedCounter target={pm.value_eur / 1_000_000_000} suffix={lang === 'de' ? ' Mrd. EUR' : 'B EUR'} decimals={1} duration={1500} />
|
||||
) : pm.value_eur >= 1_000_000 ? (
|
||||
<AnimatedCounter target={pm.value_eur / 1_000_000} suffix={lang === 'de' ? ' Mio. EUR' : 'M EUR'} decimals={1} duration={1500} />
|
||||
) : (
|
||||
<AnimatedCounter target={pm.value_eur / 1_000} suffix={'k EUR'} decimals={0} duration={1500} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{pm.growth_rate_pct > 0 && (
|
||||
<span className="flex items-center gap-1 text-emerald-400">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
{pm.growth_rate_pct}% p.a.
|
||||
</span>
|
||||
)}
|
||||
<span className="text-white/40">{i.market.source}: {pm.source}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-red-400/60 group-hover:text-red-400 transition-colors mt-0.5">
|
||||
{sourceCount} {lang === 'de' ? (sourceCount === 1 ? 'Quelle' : 'Quellen') : (sourceCount === 1 ? 'source' : 'sources')}
|
||||
{' · '}{lang === 'de' ? 'Klicken fuer Details' : 'Click for details'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Modals — both compliance and pentesting */}
|
||||
{segmentKeys.map((seg) => (
|
||||
<SourceModal
|
||||
key={seg}
|
||||
key={`c-${seg}`}
|
||||
isOpen={activeModal === seg}
|
||||
onClose={() => setActiveModal(null)}
|
||||
segment={seg}
|
||||
lang={lang}
|
||||
sources={activeSources}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user