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:
54
pitch-deck/components/ui/AnimatedCounter.tsx
Normal file
54
pitch-deck/components/ui/AnimatedCounter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
180
pitch-deck/components/ui/AnnualCashflowChart.tsx
Normal file
180
pitch-deck/components/ui/AnnualCashflowChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
142
pitch-deck/components/ui/AnnualPLTable.tsx
Normal file
142
pitch-deck/components/ui/AnnualPLTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
pitch-deck/components/ui/BrandName.tsx
Normal file
19
pitch-deck/components/ui/BrandName.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
167
pitch-deck/components/ui/ChatInterface.tsx
Normal file
167
pitch-deck/components/ui/ChatInterface.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
pitch-deck/components/ui/FadeInView.tsx
Normal file
39
pitch-deck/components/ui/FadeInView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
pitch-deck/components/ui/FeatureMatrix.tsx
Normal file
69
pitch-deck/components/ui/FeatureMatrix.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
200
pitch-deck/components/ui/FinancialChart.tsx
Normal file
200
pitch-deck/components/ui/FinancialChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
143
pitch-deck/components/ui/FinancialSliders.tsx
Normal file
143
pitch-deck/components/ui/FinancialSliders.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
pitch-deck/components/ui/GlassCard.tsx
Normal file
33
pitch-deck/components/ui/GlassCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
pitch-deck/components/ui/GradientText.tsx
Normal file
27
pitch-deck/components/ui/GradientText.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
pitch-deck/components/ui/KPICard.tsx
Normal file
59
pitch-deck/components/ui/KPICard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
pitch-deck/components/ui/LiveIndicator.tsx
Normal file
13
pitch-deck/components/ui/LiveIndicator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
pitch-deck/components/ui/PricingCard.tsx
Normal file
82
pitch-deck/components/ui/PricingCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
pitch-deck/components/ui/ProductShowcase.tsx
Normal file
50
pitch-deck/components/ui/ProductShowcase.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
pitch-deck/components/ui/RunwayGauge.tsx
Normal file
133
pitch-deck/components/ui/RunwayGauge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
pitch-deck/components/ui/ScenarioSwitcher.tsx
Normal file
63
pitch-deck/components/ui/ScenarioSwitcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
pitch-deck/components/ui/Timeline.tsx
Normal file
66
pitch-deck/components/ui/Timeline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
pitch-deck/components/ui/UnitEconomicsCards.tsx
Normal file
93
pitch-deck/components/ui/UnitEconomicsCards.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
pitch-deck/components/ui/WaterfallChart.tsx
Normal file
85
pitch-deck/components/ui/WaterfallChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user