feat: add pitch-deck service to core infrastructure

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

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

View File

@@ -1117,3 +1117,28 @@ services:
condition: service_healthy
networks:
- breakpilot-network
# =========================================================
# PITCH DECK - Investor Presentation
# =========================================================
pitch-deck:
build:
context: ./pitch-deck
dockerfile: Dockerfile
container_name: bp-core-pitch-deck
platform: linux/arm64
ports:
- "3012:3000"
environment:
NODE_ENV: production
DATABASE_URL: postgres://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@postgres:5432/${POSTGRES_DB:-breakpilot_db}
OLLAMA_URL: ${OLLAMA_URL:-http://host.docker.internal:11434}
OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3:30b-a3b}
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
networks:
- breakpilot-network

6
pitch-deck/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.next
.git
*.md
.env*
.DS_Store

45
pitch-deck/Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm install
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
# Set to production
ENV NODE_ENV=production
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy built assets
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Switch to non-root user
USER nextjs
# Expose port
EXPOSE 3000
# Set hostname
ENV HOSTNAME="0.0.0.0"
# Start the application
CMD ["node", "server.js"]

View File

@@ -0,0 +1,246 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen2.5:32b'
const SYSTEM_PROMPT = `# Investor Agent — BreakPilot ComplAI
## Identitaet
Du bist der BreakPilot ComplAI Investor Relations Agent. Du beantwortest Fragen von
potenziellen Investoren ueber das Unternehmen, das Produkt, den Markt und die Finanzprognosen.
Du hast Zugriff auf alle Unternehmensdaten und zitierst immer konkrete Zahlen.
## Kernprinzipien
- **Datengetrieben**: Beziehe dich immer auf die bereitgestellten Unternehmensdaten
- **Praezise**: Nenne immer konkrete Zahlen, Prozentsaetze und Zeitraeume
- **Begeisternd aber ehrlich**: Stelle das Unternehmen positiv dar, ohne zu uebertreiben
- **Zweisprachig**: Antworte in der Sprache, in der die Frage gestellt wird
## Kernbotschaften (IMMER betonen wenn passend)
1. AI-First: "Alles was durch KI loesbar ist, wird durch KI geloest. Kein klassischer Support, kein grosses Sales-Team."
2. Skalierbarkeit: "10x Kunden ≠ 10x Personal. Die KI skaliert mit."
3. Hardware-Differenzierung: "Datensouveraenitaet durch Self-Hosting auf Apple-Hardware."
4. Kostenstruktur: "18 Mitarbeiter in 2030 bei 8.4 Mio EUR Umsatz."
5. Marktchance: "12.4 Mrd EUR TAM, regulatorisch getrieben."
## Kommunikationsstil
- Professionell, knapp und ueberzeugend
- Strukturierte Antworten mit klaren Abschnitten
- Zahlen hervorheben und kontextualisieren
- Maximal 3-4 Absaetze pro Antwort
## IP-Schutz-Layer (KRITISCH)
NIEMALS offenbaren: Exakte Modellnamen, Frameworks, Code-Architektur, Datenbankschema, Sicherheitsdetails, Cloud-Provider.
Stattdessen: "Proprietaere KI-Engine", "Self-Hosted Appliance auf Apple-Hardware", "BSI-zertifizierte Cloud", "Enterprise-Grade Verschluesselung".
## Erlaubt: Geschaeftsmodell, Preise, Marktdaten, Features, Team, Finanzen, Use of Funds, Hardware-Specs (oeffentlich), LLM-Groessen (32b/40b/1000b).
## Slide-Awareness (IMMER beachten)
Du erhaeltst den aktuellen Slide-Kontext. Nutze ihn fuer kontextuelle Antworten.
Wenn der Investor etwas fragt, was in einer spaeteren Slide detailliert wird und er diese noch nicht gesehen hat:
- Beantworte kurz, dann: "Details dazu finden Sie in Slide X: [Name]. Moechten Sie dorthin springen? [GOTO:X]"
## FOLLOW-UP FRAGEN — KRITISCHE PFLICHT
Du MUSST am Ende JEDER einzelnen Antwort exakt 3 Folgefragen anhaengen.
Die Fragen muessen durch "---" getrennt und mit "[Q]" markiert sein.
JEDE Antwort ohne Folgefragen ist UNVOLLSTAENDIG und FEHLERHAFT.
EXAKTES FORMAT (keine Abweichung erlaubt):
[Deine Antwort hier]
---
[Q] Erste Folgefrage passend zum Thema?
[Q] Zweite Folgefrage die tiefer geht?
[Q] Dritte Folgefrage zu einem verwandten Aspekt?
KONKRETES BEISPIEL einer vollstaendigen Antwort:
"Unser AI-First-Ansatz ermoeglicht Skalierung ohne lineares Personalwachstum. Der Umsatz steigt von 36k EUR (2026) auf 8.4 Mio EUR (2030), waehrend das Team nur von 2 auf 18 Personen waechst.
---
[Q] Wie sieht die Kostenstruktur im Detail aus?
[Q] Welche Unit Economics erreicht ihr in 2030?
[Q] Wie vergleicht sich die Personaleffizienz mit Wettbewerbern?"
WICHTIG: Vergiss NIEMALS die Folgefragen! Sie sind PFLICHT.`
async function loadPitchContext(): Promise<string> {
try {
const client = await pool.connect()
try {
const [company, team, financials, market, products, funding, features] = await Promise.all([
client.query('SELECT * FROM pitch_company LIMIT 1'),
client.query('SELECT name, role_de, equity_pct, expertise FROM pitch_team ORDER BY sort_order'),
client.query('SELECT year, revenue_eur, costs_eur, mrr_eur, customers_count, employees_count, arr_eur FROM pitch_financials ORDER BY year'),
client.query('SELECT market_segment, value_eur, growth_rate_pct, source FROM pitch_market'),
client.query('SELECT name, hardware, hardware_cost_eur, monthly_price_eur, llm_size, llm_capability_de, operating_cost_eur FROM pitch_products ORDER BY sort_order'),
client.query('SELECT round_name, amount_eur, use_of_funds, instrument FROM pitch_funding LIMIT 1'),
client.query('SELECT feature_name_de, breakpilot, proliance, dataguard, heydata, is_differentiator FROM pitch_features WHERE is_differentiator = true'),
])
return `
## Unternehmensdaten (fuer praezise Antworten nutzen)
### Firma
${JSON.stringify(company.rows[0], null, 2)}
### Team
${JSON.stringify(team.rows, null, 2)}
### Finanzprognosen (5-Jahres-Plan)
${JSON.stringify(financials.rows, null, 2)}
### Markt (TAM/SAM/SOM)
${JSON.stringify(market.rows, null, 2)}
### Produkte
${JSON.stringify(products.rows, null, 2)}
### Finanzierung
${JSON.stringify(funding.rows[0], null, 2)}
### Differenzierende Features (nur bei ComplAI)
${JSON.stringify(features.rows, null, 2)}
`
} finally {
client.release()
}
} catch (error) {
console.warn('Could not load pitch context from DB:', error)
return ''
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { message, history = [], lang = 'de', slideContext } = body
if (!message || typeof message !== 'string') {
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
}
const pitchContext = await loadPitchContext()
let systemContent = SYSTEM_PROMPT
if (pitchContext) {
systemContent += '\n' + pitchContext
}
// Slide context for contextual awareness
if (slideContext) {
const SLIDE_NAMES: Record<string, { de: string; en: string; index: number }> = {
'cover': { de: 'Cover', en: 'Cover', index: 0 },
'problem': { de: 'Das Problem', en: 'The Problem', index: 1 },
'solution': { de: 'Die Loesung', en: 'The Solution', index: 2 },
'product': { de: 'Produkte', en: 'Products', index: 3 },
'how-it-works': { de: 'So funktionierts', en: 'How It Works', index: 4 },
'market': { de: 'Markt', en: 'Market', index: 5 },
'business-model': { de: 'Geschaeftsmodell', en: 'Business Model', index: 6 },
'traction': { de: 'Traction', en: 'Traction', index: 7 },
'competition': { de: 'Wettbewerb', en: 'Competition', index: 8 },
'team': { de: 'Team', en: 'Team', index: 9 },
'financials': { de: 'Finanzen', en: 'Financials', index: 10 },
'the-ask': { de: 'The Ask', en: 'The Ask', index: 11 },
'ai-qa': { de: 'KI Q&A', en: 'AI Q&A', index: 12 },
}
const slideKeys = Object.keys(SLIDE_NAMES)
const visited: number[] = slideContext.visitedSlides || []
const currentSlideName = SLIDE_NAMES[slideContext.currentSlide]?.[lang] || slideContext.currentSlide
const notYetSeen = Object.entries(SLIDE_NAMES)
.filter(([, v]) => !visited.includes(v.index))
.map(([, v]) => `${v.index + 1}. ${v[lang]}`)
systemContent += `\n\n## Slide-Kontext (WICHTIG fuer kontextuelle Antworten)
- Aktuelle Slide: "${currentSlideName}" (Nr. ${slideContext.currentIndex + 1} von 13)
- Bereits besuchte Slides: ${visited.map((i: number) => SLIDE_NAMES[slideKeys[i]]?.[lang]).filter(Boolean).join(', ')}
- Noch nicht gesehene Slides: ${notYetSeen.join(', ')}
- Ist Erstbesuch: ${visited.length <= 1 ? 'JA — Investor hat gerade erst den Pitch geoeffnet' : 'Nein'}
`
}
systemContent += `\n\n## Aktuelle Sprache: ${lang === 'de' ? 'Deutsch' : 'English'}\nAntworte in ${lang === 'de' ? 'Deutsch' : 'English'}.`
const messages = [
{ role: 'system', content: systemContent },
...history.slice(-10).map((h: { role: string; content: string }) => ({
role: h.role === 'user' ? 'user' : 'assistant',
content: h.content,
})),
{ role: 'user', content: message + '\n\n(Erinnerung: Beende deine Antwort IMMER mit "---" gefolgt von 3 Folgefragen im Format "[Q] Frage?")' },
]
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: OLLAMA_MODEL,
messages,
stream: true,
options: {
temperature: 0.4,
num_predict: 4096,
},
}),
signal: AbortSignal.timeout(120000),
})
if (!ollamaResponse.ok) {
const errorText = await ollamaResponse.text()
console.error('Ollama error:', ollamaResponse.status, errorText)
return NextResponse.json(
{ error: `LLM nicht erreichbar (Status ${ollamaResponse.status}).` },
{ status: 502 }
)
}
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const reader = ollamaResponse.body!.getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n').filter((l) => l.trim())
for (const line of lines) {
try {
const json = JSON.parse(line)
if (json.message?.content) {
controller.enqueue(encoder.encode(json.message.content))
}
} catch {
// Partial JSON line, skip
}
}
}
} catch (error) {
console.error('Stream read error:', error)
} finally {
controller.close()
}
},
})
return new NextResponse(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
} catch (error) {
console.error('Investor agent chat error:', error)
return NextResponse.json(
{ error: 'Verbindung zum LLM fehlgeschlagen.' },
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server'
import pool from '@/lib/db'
export const dynamic = 'force-dynamic'
export async function GET() {
try {
const client = await pool.connect()
try {
const [
companyRes,
teamRes,
financialsRes,
marketRes,
competitorsRes,
featuresRes,
milestonesRes,
metricsRes,
fundingRes,
productsRes,
] = await Promise.all([
client.query('SELECT * FROM pitch_company LIMIT 1'),
client.query('SELECT * FROM pitch_team ORDER BY sort_order'),
client.query('SELECT * FROM pitch_financials ORDER BY year'),
client.query('SELECT * FROM pitch_market ORDER BY id'),
client.query('SELECT * FROM pitch_competitors ORDER BY id'),
client.query('SELECT * FROM pitch_features ORDER BY sort_order'),
client.query('SELECT * FROM pitch_milestones ORDER BY sort_order'),
client.query('SELECT * FROM pitch_metrics ORDER BY id'),
client.query('SELECT * FROM pitch_funding LIMIT 1'),
client.query('SELECT * FROM pitch_products ORDER BY sort_order'),
])
return NextResponse.json({
company: companyRes.rows[0] || null,
team: teamRes.rows,
financials: financialsRes.rows,
market: marketRes.rows,
competitors: competitorsRes.rows,
features: featuresRes.rows,
milestones: milestonesRes.rows,
metrics: metricsRes.rows,
funding: fundingRes.rows[0] || null,
products: productsRes.rows,
})
} finally {
client.release()
}
} catch (error) {
console.error('Database query error:', error)
return NextResponse.json(
{ error: 'Failed to load pitch data' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
// PUT: Update a single assumption and trigger recompute
export async function PUT(request: NextRequest) {
try {
const body = await request.json()
const { scenarioId, key, value } = body
if (!scenarioId || !key || value === undefined) {
return NextResponse.json({ error: 'scenarioId, key, and value are required' }, { status: 400 })
}
const client = await pool.connect()
try {
const jsonValue = JSON.stringify(value)
await client.query(
'UPDATE pitch_fm_assumptions SET value = $1 WHERE scenario_id = $2 AND key = $3',
[jsonValue, scenarioId, key]
)
return NextResponse.json({ success: true })
} finally {
client.release()
}
} catch (error) {
console.error('Update assumption error:', error)
return NextResponse.json({ error: 'Failed to update assumption' }, { status: 500 })
}
}

View File

@@ -0,0 +1,181 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { scenarioId } = body
if (!scenarioId) {
return NextResponse.json({ error: 'scenarioId is required' }, { status: 400 })
}
const client = await pool.connect()
try {
// Load assumptions
const assumptionsRes = await client.query(
'SELECT key, value, value_type FROM pitch_fm_assumptions WHERE scenario_id = $1',
[scenarioId]
)
const a: Record<string, number | number[]> = {}
for (const row of assumptionsRes.rows) {
const val = typeof row.value === 'string' ? JSON.parse(row.value) : row.value
a[row.key] = val
}
// Extract scalar values
const initialFunding = Number(a.initial_funding) || 200000
const growthRate = (Number(a.monthly_growth_rate) || 15) / 100
const churnRate = (Number(a.churn_rate_monthly) || 3) / 100
const arpuMini = Number(a.arpu_mini) || 299
const arpuStudio = Number(a.arpu_studio) || 999
const arpuCloud = Number(a.arpu_cloud) || 1499
const mixMini = (Number(a.product_mix_mini) || 60) / 100
const mixStudio = (Number(a.product_mix_studio) || 25) / 100
const mixCloud = (Number(a.product_mix_cloud) || 15) / 100
const initialCustomers = Number(a.initial_customers) || 2
const cac = Number(a.cac) || 500
const hwCostMini = Number(a.hw_cost_per_mini) || 3200
const hwCostStudio = Number(a.hw_cost_per_studio) || 12000
const cloudOpex = Number(a.cloud_opex_per_customer) || 150
const salaryAvg = Number(a.salary_avg_monthly) || 6000
const hiringPlan: number[] = Array.isArray(a.hiring_plan) ? a.hiring_plan : [2, 4, 8, 12, 18]
const marketingMonthly = Number(a.marketing_monthly) || 2000
const infraBase = Number(a.infra_monthly_base) || 500
// Weighted ARPU
const weightedArpu = arpuMini * mixMini + arpuStudio * mixStudio + arpuCloud * mixCloud
// Weighted hardware cost (only for Mini and Studio — Cloud is OpEx)
const hwCostWeighted = hwCostMini * mixMini + hwCostStudio * mixStudio
const results = []
let totalCustomers = initialCustomers
let cashBalance = initialFunding
let cumulativeRevenue = 0
let breakEvenMonth: number | null = null
let peakBurn = 0
for (let m = 1; m <= 60; m++) {
const yearIndex = Math.floor((m - 1) / 12) // 0-4
const year = 2026 + yearIndex
const monthInYear = ((m - 1) % 12) + 1
// Employees from hiring plan
const employees = hiringPlan[Math.min(yearIndex, hiringPlan.length - 1)] || 2
// Customer dynamics
const newCustomers = m === 1 ? initialCustomers : Math.max(1, Math.round(totalCustomers * growthRate))
const churned = Math.round(totalCustomers * churnRate)
if (m > 1) {
totalCustomers = totalCustomers + newCustomers - churned
}
totalCustomers = Math.max(0, totalCustomers)
// Revenue
const mrr = totalCustomers * weightedArpu
const arr = mrr * 12
const revenue = mrr
// Costs
const personnelCost = employees * salaryAvg
const cogsHardware = newCustomers * hwCostWeighted
const cogsCloud = totalCustomers * mixCloud * cloudOpex
const cogs = cogsHardware + cogsCloud
const marketingCost = marketingMonthly + (newCustomers * cac)
const infraCost = infraBase + (totalCustomers * 5) // infra scales slightly
const totalCosts = personnelCost + cogs + marketingCost + infraCost
// Cash
const netCash = revenue - totalCosts
cashBalance += netCash
cumulativeRevenue += revenue
// KPIs
const grossMargin = revenue > 0 ? ((revenue - cogs) / revenue) * 100 : 0
const burnRate = netCash < 0 ? Math.abs(netCash) : 0
const runway = burnRate > 0 ? cashBalance / burnRate : 999
const avgLifetimeMonths = churnRate > 0 ? 1 / churnRate : 60
const ltv = weightedArpu * avgLifetimeMonths
const ltvCacRatio = cac > 0 ? ltv / cac : 0
if (peakBurn < burnRate) peakBurn = burnRate
// Break-even detection
if (breakEvenMonth === null && netCash >= 0 && m > 1) {
breakEvenMonth = m
}
results.push({
month: m,
year,
month_in_year: monthInYear,
new_customers: newCustomers,
churned_customers: churned,
total_customers: totalCustomers,
mrr_eur: Math.round(mrr * 100) / 100,
arr_eur: Math.round(arr * 100) / 100,
revenue_eur: Math.round(revenue * 100) / 100,
cogs_eur: Math.round(cogs * 100) / 100,
personnel_eur: Math.round(personnelCost * 100) / 100,
infra_eur: Math.round(infraCost * 100) / 100,
marketing_eur: Math.round(marketingCost * 100) / 100,
total_costs_eur: Math.round(totalCosts * 100) / 100,
employees_count: employees,
gross_margin_pct: Math.round(grossMargin * 100) / 100,
burn_rate_eur: Math.round(burnRate * 100) / 100,
runway_months: Math.round(Math.min(runway, 999) * 10) / 10,
cac_eur: cac,
ltv_eur: Math.round(ltv * 100) / 100,
ltv_cac_ratio: Math.round(ltvCacRatio * 100) / 100,
cash_balance_eur: Math.round(cashBalance * 100) / 100,
cumulative_revenue_eur: Math.round(cumulativeRevenue * 100) / 100,
})
}
// Save to DB (upsert)
await client.query('DELETE FROM pitch_fm_results WHERE scenario_id = $1', [scenarioId])
for (const r of results) {
await client.query(`
INSERT INTO pitch_fm_results (scenario_id, month, year, month_in_year,
new_customers, churned_customers, total_customers,
mrr_eur, arr_eur, revenue_eur,
cogs_eur, personnel_eur, infra_eur, marketing_eur, total_costs_eur,
employees_count, gross_margin_pct, burn_rate_eur, runway_months,
cac_eur, ltv_eur, ltv_cac_ratio,
cash_balance_eur, cumulative_revenue_eur)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24)
`, [
scenarioId, r.month, r.year, r.month_in_year,
r.new_customers, r.churned_customers, r.total_customers,
r.mrr_eur, r.arr_eur, r.revenue_eur,
r.cogs_eur, r.personnel_eur, r.infra_eur, r.marketing_eur, r.total_costs_eur,
r.employees_count, r.gross_margin_pct, r.burn_rate_eur, r.runway_months,
r.cac_eur, r.ltv_eur, r.ltv_cac_ratio,
r.cash_balance_eur, r.cumulative_revenue_eur,
])
}
const lastResult = results[results.length - 1]
return NextResponse.json({
scenario_id: scenarioId,
results,
summary: {
final_arr: lastResult.arr_eur,
final_customers: lastResult.total_customers,
break_even_month: breakEvenMonth,
final_runway: lastResult.runway_months,
final_ltv_cac: lastResult.ltv_cac_ratio,
peak_burn: Math.round(peakBurn * 100) / 100,
total_funding_needed: Math.round(Math.abs(Math.min(...results.map(r => r.cash_balance_eur), 0)) * 100) / 100,
},
})
} finally {
client.release()
}
} catch (error) {
console.error('Compute error:', error)
return NextResponse.json({ error: 'Computation failed' }, { status: 500 })
}
}

View File

@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
export const dynamic = 'force-dynamic'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ scenarioId: string }> }
) {
try {
const { scenarioId } = await params
const client = await pool.connect()
try {
const results = await client.query(
'SELECT * FROM pitch_fm_results WHERE scenario_id = $1 ORDER BY month',
[scenarioId]
)
return NextResponse.json(results.rows)
} finally {
client.release()
}
} catch (error) {
console.error('Load results error:', error)
return NextResponse.json({ error: 'Failed to load results' }, { status: 500 })
}
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET: Load all scenarios with their assumptions
export async function GET() {
try {
const client = await pool.connect()
try {
const scenarios = await client.query(
'SELECT * FROM pitch_fm_scenarios ORDER BY is_default DESC, name'
)
const assumptions = await client.query(
'SELECT * FROM pitch_fm_assumptions ORDER BY sort_order'
)
const result = scenarios.rows.map(s => ({
...s,
assumptions: assumptions.rows
.filter(a => a.scenario_id === s.id)
.map(a => ({
...a,
value: typeof a.value === 'string' ? JSON.parse(a.value) : a.value,
})),
}))
return NextResponse.json(result)
} finally {
client.release()
}
} catch (error) {
console.error('Financial model load error:', error)
return NextResponse.json({ error: 'Failed to load scenarios' }, { status: 500 })
}
}
// POST: Create a new scenario
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { name, description, color, copyFrom } = body
if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 })
}
const client = await pool.connect()
try {
const scenario = await client.query(
'INSERT INTO pitch_fm_scenarios (name, description, color) VALUES ($1, $2, $3) RETURNING *',
[name, description || '', color || '#6366f1']
)
// If copyFrom is set, copy assumptions from another scenario
if (copyFrom) {
await client.query(`
INSERT INTO pitch_fm_assumptions (scenario_id, key, label_de, label_en, value, value_type, unit, min_value, max_value, step_size, category, sort_order)
SELECT $1, key, label_de, label_en, value, value_type, unit, min_value, max_value, step_size, category, sort_order
FROM pitch_fm_assumptions WHERE scenario_id = $2
`, [scenario.rows[0].id, copyFrom])
}
return NextResponse.json(scenario.rows[0])
} finally {
client.release()
}
} catch (error) {
console.error('Create scenario error:', error)
return NextResponse.json({ error: 'Failed to create scenario' }, { status: 500 })
}
}

View File

@@ -0,0 +1,77 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
:root {
--bg-primary: #0a0a1a;
--bg-card: rgba(255, 255, 255, 0.08);
--bg-card-hover: rgba(255, 255, 255, 0.12);
--border-subtle: rgba(255, 255, 255, 0.1);
--text-primary: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.6);
--accent-indigo: #6366f1;
--accent-purple: #a78bfa;
--accent-blue: #60a5fa;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
overflow: hidden;
background: var(--bg-primary);
color: var(--text-primary);
font-family: 'Inter', system-ui, sans-serif;
}
::selection {
background: rgba(99, 102, 241, 0.3);
color: white;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
@layer utilities {
.glass {
background: var(--bg-card);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--border-subtle);
}
.glass-hover:hover {
background: var(--bg-card-hover);
}
.gradient-text {
background: linear-gradient(135deg, #6366f1, #a78bfa, #60a5fa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.text-shadow-glow {
text-shadow: 0 0 40px rgba(99, 102, 241, 0.3);
}
}

21
pitch-deck/app/layout.tsx Normal file
View File

@@ -0,0 +1,21 @@
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'BreakPilot ComplAI — Investor Pitch Deck',
description: 'Datensouveraenitaet meets KI-Compliance. Pre-Seed Q4 2026.',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="de" className="dark">
<body className="bg-[#0a0a1a] text-white antialiased overflow-hidden h-screen">
{children}
</body>
</html>
)
}

15
pitch-deck/app/page.tsx Normal file
View File

@@ -0,0 +1,15 @@
'use client'
import { useState, useCallback } from 'react'
import { Language } from '@/lib/types'
import PitchDeck from '@/components/PitchDeck'
export default function Home() {
const [lang, setLang] = useState<Language>('de')
const toggleLanguage = useCallback(() => {
setLang(prev => prev === 'de' ? 'en' : 'de')
}, [])
return <PitchDeck lang={lang} onToggleLanguage={toggleLanguage} />
}

View File

@@ -0,0 +1,420 @@
'use client'
import { useState, useRef, useEffect, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Send, Bot, User, Sparkles, Maximize2, Minimize2, ArrowRight } from 'lucide-react'
import { ChatMessage, Language, SlideId } from '@/lib/types'
import { t } from '@/lib/i18n'
interface ChatFABProps {
lang: Language
currentSlide: SlideId
currentIndex: number
visitedSlides: Set<number>
onGoToSlide: (index: number) => void
}
interface ParsedMessage {
text: string
followUps: string[]
gotos: { index: number; label: string }[]
}
function parseAgentResponse(content: string, lang: Language): ParsedMessage {
const followUps: string[] = []
const gotos: { index: number; label: string }[] = []
// Split on the follow-up separator — flexible: "---", "- - -", "___", or multiple dashes
const parts = content.split(/\n\s*[-_]{3,}\s*\n/)
let text = parts[0]
// Parse follow-up questions from second part
if (parts.length > 1) {
const qSection = parts.slice(1).join('\n')
// Match [Q], **[Q]**, or numbered/bulleted question patterns
const qMatches = qSection.matchAll(/(?:\[Q\]|\*\*\[Q\]\*\*)\s*(.+?)(?:\n|$)/g)
for (const m of qMatches) {
const q = m[1].trim().replace(/^\*\*|\*\*$/g, '')
if (q.length > 5) followUps.push(q)
}
// Fallback: if no [Q] markers found, look for numbered or bulleted questions in the section
if (followUps.length === 0) {
const lineMatches = qSection.matchAll(/(?:^|\n)\s*(?:\d+[\.\)]\s*|[-•]\s*)(.+?\?)\s*$/gm)
for (const m of lineMatches) {
const q = m[1].trim()
if (q.length > 5 && followUps.length < 3) followUps.push(q)
}
}
}
// Also look for [Q] questions anywhere in the text (sometimes model puts them without ---)
if (followUps.length === 0) {
const inlineMatches = content.matchAll(/\[Q\]\s*(.+?)(?:\n|$)/g)
const inlineQs: string[] = []
for (const m of inlineMatches) {
inlineQs.push(m[1].trim())
}
if (inlineQs.length >= 2) {
followUps.push(...inlineQs)
// Remove [Q] lines from main text
text = text.replace(/\n?\s*\[Q\]\s*.+?(?:\n|$)/g, '\n').trim()
}
}
// Parse GOTO markers from the text
const gotoRegex = /\[GOTO:(\d+)\]/g
let gotoMatch
while ((gotoMatch = gotoRegex.exec(text)) !== null) {
const slideIndex = parseInt(gotoMatch[1])
gotos.push({
index: slideIndex,
label: lang === 'de' ? `Zu Slide ${slideIndex + 1} springen` : `Jump to slide ${slideIndex + 1}`,
})
}
// Remove GOTO markers from visible text
text = text.replace(/\s*\[GOTO:\d+\]/g, '')
// Clean up trailing reminder instruction that might leak through
text = text.replace(/\n*\(Erinnerung:.*?\)\s*$/s, '').trim()
return { text: text.trim(), followUps, gotos }
}
export default function ChatFAB({ lang, currentSlide, currentIndex, visitedSlides, onGoToSlide }: ChatFABProps) {
const i = t(lang)
const [isOpen, setIsOpen] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const [parsedResponses, setParsedResponses] = useState<Map<number, ParsedMessage>>(new Map())
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, parsedResponses])
useEffect(() => {
if (isOpen && inputRef.current) {
setTimeout(() => inputRef.current?.focus(), 200)
}
}, [isOpen])
// Parse the latest assistant message when streaming ends
const lastAssistantIndex = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'assistant') return i
}
return -1
}, [messages])
useEffect(() => {
if (!isStreaming && lastAssistantIndex >= 0 && !parsedResponses.has(lastAssistantIndex)) {
const msg = messages[lastAssistantIndex]
const parsed = parseAgentResponse(msg.content, lang)
setParsedResponses(prev => new Map(prev).set(lastAssistantIndex, parsed))
}
}, [isStreaming, lastAssistantIndex, messages, parsedResponses, lang])
async function sendMessage(text?: string) {
const message = text || input.trim()
if (!message || isStreaming) return
setInput('')
setMessages(prev => [...prev, { role: 'user', content: message }])
setIsStreaming(true)
abortRef.current = new AbortController()
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message,
history: messages.slice(-10),
lang,
slideContext: {
currentSlide,
currentIndex,
visitedSlides: Array.from(visitedSlides),
totalSlides: 13,
},
}),
signal: abortRef.current.signal,
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let content = ''
setMessages(prev => [...prev, { role: 'assistant', content: '' }])
while (true) {
const { done, value } = await reader.read()
if (done) break
content += decoder.decode(value, { stream: true })
const currentText = content
setMessages(prev => {
const updated = [...prev]
updated[updated.length - 1] = { role: 'assistant', content: currentText }
return updated
})
}
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return
console.error('Chat error:', err)
setMessages(prev => [
...prev,
{ role: 'assistant', content: lang === 'de'
? 'Verbindung fehlgeschlagen. Bitte versuchen Sie es erneut.'
: 'Connection failed. Please try again.'
},
])
} finally {
setIsStreaming(false)
abortRef.current = null
}
}
function stopGeneration() {
if (abortRef.current) {
abortRef.current.abort()
setIsStreaming(false)
}
}
const suggestions = i.aiqa.suggestions.slice(0, 3)
function renderMessageContent(msg: ChatMessage, idx: number) {
const parsed = parsedResponses.get(idx)
const displayText = parsed ? parsed.text : msg.content
return (
<>
<div className="whitespace-pre-wrap">{displayText}</div>
{isStreaming && idx === messages.length - 1 && msg.role === 'assistant' && (
<span className="inline-block w-1.5 h-3.5 bg-indigo-400 animate-pulse ml-0.5" />
)}
{/* GOTO Buttons */}
{parsed && parsed.gotos.length > 0 && (
<div className="mt-2 space-y-1">
{parsed.gotos.map((g, gi) => (
<button
key={gi}
onClick={() => onGoToSlide(g.index)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg
bg-indigo-500/20 border border-indigo-500/30
hover:bg-indigo-500/30 transition-colors
text-xs text-indigo-300"
>
<ArrowRight className="w-3 h-3" />
{g.label}
</button>
))}
</div>
)}
{/* Follow-Up Suggestions */}
{parsed && parsed.followUps.length > 0 && !isStreaming && (
<div className="mt-3 space-y-1.5 border-t border-white/10 pt-2">
{parsed.followUps.map((q, qi) => (
<button
key={qi}
onClick={() => sendMessage(q)}
className="block w-full text-left px-2.5 py-2 rounded-lg
bg-white/[0.05] border border-white/10
hover:bg-white/[0.1] transition-colors
text-xs text-white/60 hover:text-white/90"
>
{q}
</button>
))}
</div>
)}
</>
)
}
return (
<>
{/* FAB Button — sits to the left of NavigationFAB */}
<AnimatePresence>
{!isOpen && (
<motion.button
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-[5.5rem] z-50 w-14 h-14 rounded-full
bg-indigo-600 hover:bg-indigo-500 text-white
flex items-center justify-center shadow-lg shadow-indigo-600/30
transition-colors"
aria-label={lang === 'de' ? 'Investor Agent oeffnen' : 'Open Investor Agent'}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
<circle cx="9" cy="10" r="1" fill="currentColor" />
<circle cx="12" cy="10" r="1" fill="currentColor" />
<circle cx="15" cy="10" r="1" fill="currentColor" />
</svg>
</motion.button>
)}
</AnimatePresence>
{/* Chat Panel */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ duration: 0.2 }}
className={`fixed bottom-6 right-6 z-50
${isExpanded ? 'w-[700px] h-[80vh]' : 'w-[400px] h-[520px]'}
rounded-2xl overflow-hidden
bg-black/90 backdrop-blur-xl border border-white/10
shadow-2xl shadow-black/50 flex flex-col
transition-all duration-200`}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 shrink-0">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-indigo-500/20 flex items-center justify-center">
<Bot className="w-4 h-4 text-indigo-400" />
</div>
<div>
<span className="text-sm font-semibold text-white">Investor Agent</span>
<span className="text-xs text-white/30 ml-2">
{isStreaming
? (lang === 'de' ? 'antwortet...' : 'responding...')
: (lang === 'de' ? 'online' : 'online')
}
</span>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setIsExpanded(prev => !prev)}
className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center hover:bg-white/20 transition-colors"
>
{isExpanded ? <Minimize2 className="w-3.5 h-3.5 text-white/60" /> : <Maximize2 className="w-3.5 h-3.5 text-white/60" />}
</button>
<button
onClick={() => setIsOpen(false)}
className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center hover:bg-white/20 transition-colors"
>
<X className="w-4 h-4 text-white/60" />
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{messages.length === 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-white/40 text-xs mb-3">
<Sparkles className="w-3.5 h-3.5" />
<span>{lang === 'de' ? 'Fragen Sie den Investor Agent:' : 'Ask the Investor Agent:'}</span>
</div>
{suggestions.map((q, idx) => (
<motion.button
key={idx}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + idx * 0.08 }}
onClick={() => sendMessage(q)}
className="block w-full text-left px-3 py-2.5 rounded-xl
bg-white/[0.05] border border-white/10
hover:bg-white/[0.1] transition-colors
text-xs text-white/70 hover:text-white"
>
{q}
</motion.button>
))}
</div>
)}
{messages.map((msg, idx) => (
<div
key={idx}
className={`flex gap-2.5 ${msg.role === 'user' ? 'justify-end' : ''}`}
>
{msg.role === 'assistant' && (
<div className="w-7 h-7 rounded-full bg-indigo-500/20 flex items-center justify-center shrink-0 mt-0.5">
<Bot className="w-3.5 h-3.5 text-indigo-400" />
</div>
)}
<div
className={`
max-w-[85%] rounded-2xl px-3.5 py-2.5 text-xs leading-relaxed
${msg.role === 'user'
? 'bg-indigo-500/20 text-white'
: 'bg-white/[0.06] text-white/80'
}
`}
>
{msg.role === 'assistant' ? renderMessageContent(msg, idx) : (
<div className="whitespace-pre-wrap">{msg.content}</div>
)}
</div>
{msg.role === 'user' && (
<div className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center shrink-0 mt-0.5">
<User className="w-3.5 h-3.5 text-white/60" />
</div>
)}
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="border-t border-white/10 px-4 py-3 shrink-0">
{isStreaming && (
<button
onClick={stopGeneration}
className="w-full mb-2 px-3 py-1.5 rounded-lg bg-white/[0.06] hover:bg-white/[0.1]
text-xs text-white/50 transition-colors"
>
{lang === 'de' ? 'Antwort stoppen' : 'Stop response'}
</button>
)}
<div className="flex gap-2">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
placeholder={lang === 'de' ? 'Frage stellen...' : 'Ask a question...'}
disabled={isStreaming}
className="flex-1 bg-white/[0.06] border border-white/10 rounded-xl px-3.5 py-2.5
text-xs text-white placeholder-white/30 outline-none
focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/20
disabled:opacity-50 transition-all"
/>
<button
onClick={() => sendMessage()}
disabled={isStreaming || !input.trim()}
className="px-3.5 py-2.5 bg-indigo-500 hover:bg-indigo-600 disabled:opacity-30
rounded-xl transition-all text-white"
>
<Send className="w-3.5 h-3.5" />
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
export const fadeIn = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0.5 },
}
export const fadeInUp = {
initial: { opacity: 0, y: 40 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 },
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] },
}
export const fadeInDown = {
initial: { opacity: 0, y: -40 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] },
}
export const fadeInLeft = {
initial: { opacity: 0, x: -60 },
animate: { opacity: 1, x: 0 },
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] },
}
export const fadeInRight = {
initial: { opacity: 0, x: 60 },
animate: { opacity: 1, x: 0 },
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] },
}
export const scaleIn = {
initial: { opacity: 0, scale: 0.8 },
animate: { opacity: 1, scale: 1 },
transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
}
export const slideVariants = {
enter: (direction: number) => ({
x: direction > 0 ? '100%' : '-100%',
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? '100%' : '-100%',
opacity: 0,
}),
}
export const staggerContainer = {
animate: {
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
}
export const staggerItem = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.5 },
}

10
pitch-deck/lib/db.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Pool } from 'pg'
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_db',
max: 5,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
})
export default pool

View File

@@ -0,0 +1,109 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { FMScenario, FMResult, FMComputeResponse } from '../types'
export function useFinancialModel() {
const [scenarios, setScenarios] = useState<FMScenario[]>([])
const [activeScenarioId, setActiveScenarioId] = useState<string | null>(null)
const [compareMode, setCompareMode] = useState(false)
const [results, setResults] = useState<Map<string, FMComputeResponse>>(new Map())
const [loading, setLoading] = useState(true)
const [computing, setComputing] = useState(false)
const computeTimer = useRef<NodeJS.Timeout | null>(null)
// Load scenarios on mount
useEffect(() => {
async function load() {
try {
const res = await fetch('/api/financial-model')
if (res.ok) {
const data: FMScenario[] = await res.json()
setScenarios(data)
const defaultScenario = data.find(s => s.is_default) || data[0]
if (defaultScenario) {
setActiveScenarioId(defaultScenario.id)
}
}
} catch (err) {
console.error('Failed to load financial model:', err)
} finally {
setLoading(false)
}
}
load()
}, [])
// Compute when active scenario changes
useEffect(() => {
if (activeScenarioId && !results.has(activeScenarioId)) {
compute(activeScenarioId)
}
}, [activeScenarioId]) // eslint-disable-line react-hooks/exhaustive-deps
const compute = useCallback(async (scenarioId: string) => {
setComputing(true)
try {
const res = await fetch('/api/financial-model/compute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scenarioId }),
})
if (res.ok) {
const data: FMComputeResponse = await res.json()
setResults(prev => new Map(prev).set(scenarioId, data))
}
} catch (err) {
console.error('Compute failed:', err)
} finally {
setComputing(false)
}
}, [])
const updateAssumption = useCallback(async (scenarioId: string, key: string, value: number | number[]) => {
// Optimistic update in local state
setScenarios(prev => prev.map(s => {
if (s.id !== scenarioId) return s
return {
...s,
assumptions: s.assumptions.map(a => a.key === key ? { ...a, value } : a),
}
}))
// Save to DB
await fetch('/api/financial-model/assumptions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scenarioId, key, value }),
})
// Debounced recompute
if (computeTimer.current) clearTimeout(computeTimer.current)
computeTimer.current = setTimeout(() => compute(scenarioId), 300)
}, [compute])
const computeAll = useCallback(async () => {
for (const s of scenarios) {
await compute(s.id)
}
}, [scenarios, compute])
const activeScenario = scenarios.find(s => s.id === activeScenarioId) || null
const activeResults = activeScenarioId ? results.get(activeScenarioId) || null : null
return {
scenarios,
activeScenario,
activeScenarioId,
setActiveScenarioId,
activeResults,
results,
loading,
computing,
compareMode,
setCompareMode,
compute,
computeAll,
updateAssumption,
}
}

View File

@@ -0,0 +1,98 @@
'use client'
import { useEffect, useCallback } from 'react'
interface UseKeyboardProps {
onNext: () => void
onPrev: () => void
onFirst: () => void
onLast: () => void
onOverview: () => void
onFullscreen: () => void
onLanguageToggle: () => void
onMenuToggle: () => void
onGoToSlide: (index: number) => void
enabled?: boolean
}
export function useKeyboard({
onNext,
onPrev,
onFirst,
onLast,
onOverview,
onFullscreen,
onLanguageToggle,
onMenuToggle,
onGoToSlide,
enabled = true,
}: UseKeyboardProps) {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!enabled) return
const target = e.target as HTMLElement
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return
}
switch (e.key) {
case 'ArrowRight':
case ' ':
case 'Enter':
e.preventDefault()
onNext()
break
case 'ArrowLeft':
e.preventDefault()
onPrev()
break
case 'Escape':
e.preventDefault()
onOverview()
break
case 'f':
case 'F':
e.preventDefault()
onFullscreen()
break
case 'Home':
e.preventDefault()
onFirst()
break
case 'End':
e.preventDefault()
onLast()
break
case 'l':
case 'L':
e.preventDefault()
onLanguageToggle()
break
case 'm':
case 'M':
e.preventDefault()
onMenuToggle()
break
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
e.preventDefault()
onGoToSlide(parseInt(e.key) - 1)
break
}
},
[enabled, onNext, onPrev, onFirst, onLast, onOverview, onFullscreen, onLanguageToggle, onMenuToggle, onGoToSlide]
)
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])
}

View File

@@ -0,0 +1,39 @@
'use client'
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
import { Language } from '../types'
import React from 'react'
interface LanguageContextType {
lang: Language
toggleLanguage: () => void
setLanguage: (lang: Language) => void
}
const LanguageContext = createContext<LanguageContextType>({
lang: 'de',
toggleLanguage: () => {},
setLanguage: () => {},
})
export function LanguageProvider({ children }: { children: ReactNode }) {
const [lang, setLang] = useState<Language>('de')
const toggleLanguage = useCallback(() => {
setLang(prev => prev === 'de' ? 'en' : 'de')
}, [])
const setLanguage = useCallback((newLang: Language) => {
setLang(newLang)
}, [])
return React.createElement(
LanguageContext.Provider,
{ value: { lang, toggleLanguage, setLanguage } },
children
)
}
export function useLanguage() {
return useContext(LanguageContext)
}

View File

@@ -0,0 +1,29 @@
'use client'
import { useState, useEffect } from 'react'
import { PitchData } from '../types'
export function usePitchData() {
const [data, setData] = useState<PitchData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
async function fetchData() {
try {
const res = await fetch('/api/data')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = await res.json()
setData(json)
} catch (err) {
console.error('Failed to load pitch data:', err)
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
}
fetchData()
}, [])
return { data, loading, error }
}

View File

@@ -0,0 +1,77 @@
'use client'
import { useState, useCallback } from 'react'
import { SlideId } from '../types'
const SLIDE_ORDER: SlideId[] = [
'cover',
'problem',
'solution',
'product',
'how-it-works',
'market',
'business-model',
'traction',
'competition',
'team',
'financials',
'the-ask',
'ai-qa',
]
export const TOTAL_SLIDES = SLIDE_ORDER.length
export function useSlideNavigation() {
const [currentIndex, setCurrentIndex] = useState(0)
const [direction, setDirection] = useState(0)
const [visitedSlides, setVisitedSlides] = useState<Set<number>>(new Set([0]))
const [showOverview, setShowOverview] = useState(false)
const currentSlide = SLIDE_ORDER[currentIndex]
const goToSlide = useCallback((index: number) => {
if (index < 0 || index >= TOTAL_SLIDES) return
setDirection(index > currentIndex ? 1 : -1)
setCurrentIndex(index)
setVisitedSlides(prev => new Set([...prev, index]))
setShowOverview(false)
}, [currentIndex])
const nextSlide = useCallback(() => {
if (currentIndex < TOTAL_SLIDES - 1) {
goToSlide(currentIndex + 1)
}
}, [currentIndex, goToSlide])
const prevSlide = useCallback(() => {
if (currentIndex > 0) {
goToSlide(currentIndex - 1)
}
}, [currentIndex, goToSlide])
const goToFirst = useCallback(() => goToSlide(0), [goToSlide])
const goToLast = useCallback(() => goToSlide(TOTAL_SLIDES - 1), [goToSlide])
const toggleOverview = useCallback(() => {
setShowOverview(prev => !prev)
}, [])
return {
currentIndex,
currentSlide,
direction,
visitedSlides,
showOverview,
totalSlides: TOTAL_SLIDES,
slideOrder: SLIDE_ORDER,
goToSlide,
nextSlide,
prevSlide,
goToFirst,
goToLast,
toggleOverview,
setShowOverview,
isFirst: currentIndex === 0,
isLast: currentIndex === TOTAL_SLIDES - 1,
}
}

410
pitch-deck/lib/i18n.ts Normal file
View File

@@ -0,0 +1,410 @@
import { Language } from './types'
const translations = {
de: {
nav: {
slides: 'Slides',
fullscreen: 'Vollbild',
language: 'Sprache',
},
slideNames: [
'Cover',
'Das Problem',
'Die Loesung',
'Produkte',
'So funktioniert\'s',
'Markt',
'Geschaeftsmodell',
'Traction',
'Wettbewerb',
'Team',
'Finanzen',
'The Ask',
'KI Q&A',
],
cover: {
tagline: 'Datensouveraenitaet meets KI-Compliance',
subtitle: 'Pre-Seed · Q4 2026',
cta: 'Pitch starten',
},
problem: {
title: 'Das Problem',
subtitle: 'Compliance-Komplexitaet ueberfordert den Mittelstand',
cards: [
{
title: 'DSGVO',
stat: '4.1 Mrd EUR',
desc: 'Bussgelder seit 2018 in der EU. 83% der KMUs sind nicht vollstaendig konform.',
},
{
title: 'AI Act',
stat: 'Aug 2025',
desc: 'Neue EU-Verordnung tritt in Kraft. Unternehmen muessen KI-Systeme klassifizieren und dokumentieren.',
},
{
title: 'NIS2',
stat: '30.000+',
desc: 'Unternehmen in Deutschland neu betroffen. Cybersecurity-Anforderungen steigen massiv.',
},
],
quote: 'Unternehmen brauchen keine weiteren Compliance-Tools — sie brauchen eine KI, die Compliance fuer sie erledigt.',
},
solution: {
title: 'Die Loesung',
subtitle: 'ComplAI — Compliance auf Autopilot',
pillars: [
{
title: 'Self-Hosted',
desc: 'Eigene Hardware im Serverraum. Kein Byte verlaesst das Unternehmen. Volle Datensouveraenitaet.',
icon: 'server',
},
{
title: 'Auto-Compliance',
desc: 'KI erledigt DSGVO, AI Act und NIS2 automatisch. Dokumentation, Audits und Updates — alles KI-gesteuert.',
icon: 'shield',
},
{
title: 'KI-Assistent',
desc: 'Vollautonomer Kundensupport. Beantwortet Fragen, aendert Dokumente, bereitet Audits vor — 24/7.',
icon: 'bot',
},
],
},
product: {
title: 'Unsere Produkte',
subtitle: 'Drei Tiers fuer jede Unternehmensgroesse',
monthly: '/Monat',
hardware: 'Hardware',
llm: 'KI-Modell',
popular: 'Beliebt',
features: 'Features',
},
howItWorks: {
title: 'So funktioniert\'s',
subtitle: 'In 4 Schritten zur vollstaendigen Compliance',
steps: [
{
title: 'Hardware aufstellen',
desc: 'Mac Mini oder Mac Studio im Serverraum anschliessen. Plug & Play — keine Cloud noetig.',
},
{
title: 'KI konfigurieren',
desc: 'Branche, Groesse und Regularien angeben. Die KI erstellt automatisch alle Compliance-Dokumente.',
},
{
title: 'Compliance automatisieren',
desc: 'Laufende Ueberwachung, automatische Updates bei Rechtsaenderungen und Audit-Vorbereitung.',
},
{
title: 'Audit bestehen',
desc: 'Vollstaendige Dokumentation auf Knopfdruck. Behoerdenanfragen werden KI-gestuetzt beantwortet.',
},
],
},
market: {
title: 'Marktchance',
subtitle: 'Der Compliance-Markt waechst zweistellig',
tam: 'TAM',
sam: 'SAM',
som: 'SOM',
tamLabel: 'Total Addressable Market',
samLabel: 'Serviceable Addressable Market',
somLabel: 'Serviceable Obtainable Market',
source: 'Quelle',
growth: 'Wachstum p.a.',
},
businessModel: {
title: 'Geschaeftsmodell',
subtitle: 'Recurring Revenue mit Hardware-Moat',
unitEconomics: 'Unit Economics',
amortization: 'Amortisation',
margin: 'Marge',
months: 'Monate',
recurringRevenue: 'Recurring Revenue',
hardwareCost: 'Hardware-EK',
operatingCost: 'Betriebskosten',
},
traction: {
title: 'Traction & Meilensteine',
subtitle: 'Unser bisheriger Fortschritt',
completed: 'Abgeschlossen',
inProgress: 'In Arbeit',
planned: 'Geplant',
},
competition: {
title: 'Wettbewerb',
subtitle: 'Was uns differenziert',
feature: 'Feature',
selfHosted: 'Self-Hosted',
integratedAI: 'Integrierte KI',
autonomousSupport: 'Autonomer Support',
yes: 'Ja',
no: 'Nein',
partial: 'Teilweise',
},
team: {
title: 'Das Team',
subtitle: 'Gruender mit Domain-Expertise',
equity: 'Equity',
expertise: 'Expertise',
},
financials: {
title: 'Finanzprognose',
subtitle: 'AI-First Kostenstruktur — skaliert ohne lineares Personalwachstum',
revenue: 'Umsatz',
costs: 'Kosten',
customers: 'Kunden',
mrr: 'MRR',
arr: 'ARR',
burnRate: 'Burn Rate',
employees: 'Mitarbeiter',
year: 'Jahr',
sliderGrowth: 'Wachstumsrate',
sliderChurn: 'Churn Rate',
sliderArpu: 'ARPU',
adjustAssumptions: 'Annahmen anpassen',
},
theAsk: {
title: 'The Ask',
subtitle: 'Pre-Seed Finanzierung',
amount: 'Funding',
instrument: 'Instrument',
useOfFunds: 'Use of Funds',
engineering: 'Engineering',
sales: 'Vertrieb',
hardware: 'Hardware',
legal: 'Legal',
reserve: 'Reserve',
targetDate: 'Zieldatum',
},
aiqa: {
title: 'Fragen? Die KI antwortet.',
subtitle: 'Stellen Sie Ihre Investorenfragen — unser AI Agent antwortet mit Echtdaten.',
placeholder: 'Stellen Sie eine Frage zum Investment...',
send: 'Senden',
thinking: 'Denke nach...',
suggestions: [
'Wie skaliert das Geschaeftsmodell?',
'Was ist der unfaire Vorteil?',
'Wie sieht die Exit-Strategie aus?',
'Warum Self-Hosting statt Cloud?',
],
},
},
en: {
nav: {
slides: 'Slides',
fullscreen: 'Fullscreen',
language: 'Language',
},
slideNames: [
'Cover',
'The Problem',
'The Solution',
'Products',
'How It Works',
'Market',
'Business Model',
'Traction',
'Competition',
'Team',
'Financials',
'The Ask',
'AI Q&A',
],
cover: {
tagline: 'Data Sovereignty meets AI Compliance',
subtitle: 'Pre-Seed · Q4 2026',
cta: 'Start Pitch',
},
problem: {
title: 'The Problem',
subtitle: 'Compliance complexity overwhelms SMEs',
cards: [
{
title: 'GDPR',
stat: 'EUR 4.1B',
desc: 'in fines since 2018 across the EU. 83% of SMEs are not fully compliant.',
},
{
title: 'AI Act',
stat: 'Aug 2025',
desc: 'New EU regulation takes effect. Companies must classify and document AI systems.',
},
{
title: 'NIS2',
stat: '30,000+',
desc: 'companies newly affected in Germany. Cybersecurity requirements increase massively.',
},
],
quote: 'Companies don\'t need more compliance tools — they need an AI that handles compliance for them.',
},
solution: {
title: 'The Solution',
subtitle: 'ComplAI — Compliance on Autopilot',
pillars: [
{
title: 'Self-Hosted',
desc: 'Own hardware in your server room. No data leaves the company. Full data sovereignty.',
icon: 'server',
},
{
title: 'Auto-Compliance',
desc: 'AI handles GDPR, AI Act and NIS2 automatically. Documentation, audits and updates — all AI-powered.',
icon: 'shield',
},
{
title: 'AI Assistant',
desc: 'Fully autonomous customer support. Answers questions, modifies documents, prepares audits — 24/7.',
icon: 'bot',
},
],
},
product: {
title: 'Our Products',
subtitle: 'Three tiers for every company size',
monthly: '/month',
hardware: 'Hardware',
llm: 'AI Model',
popular: 'Popular',
features: 'Features',
},
howItWorks: {
title: 'How It Works',
subtitle: 'Full compliance in 4 steps',
steps: [
{
title: 'Set Up Hardware',
desc: 'Connect Mac Mini or Mac Studio in your server room. Plug & Play — no cloud needed.',
},
{
title: 'Configure AI',
desc: 'Specify industry, size, and regulations. The AI automatically creates all compliance documents.',
},
{
title: 'Automate Compliance',
desc: 'Continuous monitoring, automatic updates for regulatory changes and audit preparation.',
},
{
title: 'Pass Audits',
desc: 'Complete documentation at the push of a button. Authority inquiries answered AI-powered.',
},
],
},
market: {
title: 'Market Opportunity',
subtitle: 'The compliance market grows double-digit',
tam: 'TAM',
sam: 'SAM',
som: 'SOM',
tamLabel: 'Total Addressable Market',
samLabel: 'Serviceable Addressable Market',
somLabel: 'Serviceable Obtainable Market',
source: 'Source',
growth: 'Growth p.a.',
},
businessModel: {
title: 'Business Model',
subtitle: 'Recurring Revenue with Hardware Moat',
unitEconomics: 'Unit Economics',
amortization: 'Amortization',
margin: 'Margin',
months: 'months',
recurringRevenue: 'Recurring Revenue',
hardwareCost: 'Hardware Cost',
operatingCost: 'Operating Cost',
},
traction: {
title: 'Traction & Milestones',
subtitle: 'Our progress so far',
completed: 'Completed',
inProgress: 'In Progress',
planned: 'Planned',
},
competition: {
title: 'Competition',
subtitle: 'What differentiates us',
feature: 'Feature',
selfHosted: 'Self-Hosted',
integratedAI: 'Integrated AI',
autonomousSupport: 'Autonomous Support',
yes: 'Yes',
no: 'No',
partial: 'Partial',
},
team: {
title: 'The Team',
subtitle: 'Founders with domain expertise',
equity: 'Equity',
expertise: 'Expertise',
},
financials: {
title: 'Financial Projections',
subtitle: 'AI-First cost structure — scales without linear headcount growth',
revenue: 'Revenue',
costs: 'Costs',
customers: 'Customers',
mrr: 'MRR',
arr: 'ARR',
burnRate: 'Burn Rate',
employees: 'Employees',
year: 'Year',
sliderGrowth: 'Growth Rate',
sliderChurn: 'Churn Rate',
sliderArpu: 'ARPU',
adjustAssumptions: 'Adjust Assumptions',
},
theAsk: {
title: 'The Ask',
subtitle: 'Pre-Seed Funding',
amount: 'Funding',
instrument: 'Instrument',
useOfFunds: 'Use of Funds',
engineering: 'Engineering',
sales: 'Sales',
hardware: 'Hardware',
legal: 'Legal',
reserve: 'Reserve',
targetDate: 'Target Date',
},
aiqa: {
title: 'Questions? The AI answers.',
subtitle: 'Ask your investor questions — our AI agent responds with real data.',
placeholder: 'Ask a question about the investment...',
send: 'Send',
thinking: 'Thinking...',
suggestions: [
'How does the business model scale?',
'What is the unfair advantage?',
'What does the exit strategy look like?',
'Why self-hosting instead of cloud?',
],
},
},
}
export function t(lang: Language): typeof translations.de {
return translations[lang]
}
export function formatEur(value: number, lang: Language): string {
if (value >= 1_000_000_000) {
const v = (value / 1_000_000_000).toFixed(1)
return lang === 'de' ? `${v} Mrd. EUR` : `EUR ${v}B`
}
if (value >= 1_000_000) {
const v = (value / 1_000_000).toFixed(1)
return lang === 'de' ? `${v} Mio. EUR` : `EUR ${v}M`
}
if (value >= 1_000) {
const v = (value / 1_000).toFixed(0)
return lang === 'de' ? `${v}k EUR` : `EUR ${v}k`
}
return lang === 'de' ? `${value} EUR` : `EUR ${value}`
}
export function formatNumber(value: number): string {
return new Intl.NumberFormat('de-DE').format(value)
}
export default translations

216
pitch-deck/lib/types.ts Normal file
View File

@@ -0,0 +1,216 @@
export interface PitchCompany {
id: number
name: string
legal_form: string
founding_date: string
tagline_de: string
tagline_en: string
mission_de: string
mission_en: string
website: string
hq_city: string
}
export interface PitchTeamMember {
id: number
name: string
role_de: string
role_en: string
bio_de: string
bio_en: string
equity_pct: number
expertise: string[]
linkedin_url: string
photo_url: string
}
export interface PitchFinancial {
id: number
year: number
revenue_eur: number
costs_eur: number
mrr_eur: number
burn_rate_eur: number
customers_count: number
employees_count: number
arr_eur: number
}
export interface PitchMarket {
id: number
market_segment: string
label: string
value_eur: number
growth_rate_pct: number
source: string
}
export interface PitchCompetitor {
id: number
name: string
customers_count: number
pricing_range: string
strengths: string[]
weaknesses: string[]
website: string
}
export interface PitchFeature {
id: number
feature_name_de: string
feature_name_en: string
category: string
breakpilot: boolean
proliance: boolean
dataguard: boolean
heydata: boolean
is_differentiator: boolean
}
export interface PitchMilestone {
id: number
milestone_date: string
title_de: string
title_en: string
description_de: string
description_en: string
status: 'completed' | 'in_progress' | 'planned'
category: string
}
export interface PitchMetric {
id: number
metric_name: string
label_de: string
label_en: string
value: string
unit: string
is_live: boolean
}
export interface PitchFunding {
id: number
round_name: string
amount_eur: number
use_of_funds: { category: string; percentage: number; label_de: string; label_en: string }[]
instrument: string
target_date: string
status: string
}
export interface PitchProduct {
id: number
name: string
hardware: string
hardware_cost_eur: number
monthly_price_eur: number
llm_model: string
llm_size: string
llm_capability_de: string
llm_capability_en: string
features_de: string[]
features_en: string[]
is_popular: boolean
operating_cost_eur: number
}
export interface PitchData {
company: PitchCompany
team: PitchTeamMember[]
financials: PitchFinancial[]
market: PitchMarket[]
competitors: PitchCompetitor[]
features: PitchFeature[]
milestones: PitchMilestone[]
metrics: PitchMetric[]
funding: PitchFunding
products: PitchProduct[]
}
// Financial Model Types
export interface FMScenario {
id: string
name: string
description: string
is_default: boolean
color: string
assumptions: FMAssumption[]
}
export interface FMAssumption {
id: string
scenario_id: string
key: string
label_de: string
label_en: string
value: number | number[]
value_type: 'scalar' | 'step' | 'timeseries'
unit: string
min_value: number | null
max_value: number | null
step_size: number | null
category: string
sort_order: number
}
export interface FMResult {
month: number
year: number
month_in_year: number
new_customers: number
churned_customers: number
total_customers: number
mrr_eur: number
arr_eur: number
revenue_eur: number
cogs_eur: number
personnel_eur: number
infra_eur: number
marketing_eur: number
total_costs_eur: number
employees_count: number
gross_margin_pct: number
burn_rate_eur: number
runway_months: number
cac_eur: number
ltv_eur: number
ltv_cac_ratio: number
cash_balance_eur: number
cumulative_revenue_eur: number
}
export interface FMComputeResponse {
scenario_id: string
results: FMResult[]
summary: {
final_arr: number
final_customers: number
break_even_month: number | null
final_runway: number
final_ltv_cac: number
peak_burn: number
total_funding_needed: number
}
}
export type Language = 'de' | 'en'
export interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
export type SlideId =
| 'cover'
| 'problem'
| 'solution'
| 'product'
| 'how-it-works'
| 'market'
| 'business-model'
| 'traction'
| 'competition'
| 'team'
| 'financials'
| 'the-ask'
| 'ai-qa'

10
pitch-deck/next.config.js Normal file
View File

@@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
reactStrictMode: true,
typescript: {
ignoreBuildErrors: true,
},
}
module.exports = nextConfig

29
pitch-deck/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "breakpilot-pitch-deck",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3012",
"build": "next build",
"start": "next start -p 3012"
},
"dependencies": {
"framer-motion": "^11.15.0",
"lucide-react": "^0.468.0",
"next": "^15.1.0",
"pg": "^8.13.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/pg": "^8.11.10",
"@types/react": "^18.3.16",
"@types/react-dom": "^18.3.5",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
export default config

View File

@@ -0,0 +1,29 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
colors: {
pitch: {
dark: '#0a0a1a',
card: 'rgba(255, 255, 255, 0.08)',
border: 'rgba(255, 255, 255, 0.1)',
},
},
backdropBlur: {
xs: '2px',
},
},
},
plugins: [],
}
export default config

21
pitch-deck/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] },
"target": "ES2017"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}