diff --git a/docker-compose.yml b/docker-compose.yml index f8c402a..510004f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/pitch-deck/.dockerignore b/pitch-deck/.dockerignore new file mode 100644 index 0000000..f3c1c62 --- /dev/null +++ b/pitch-deck/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.next +.git +*.md +.env* +.DS_Store diff --git a/pitch-deck/Dockerfile b/pitch-deck/Dockerfile new file mode 100644 index 0000000..20267ee --- /dev/null +++ b/pitch-deck/Dockerfile @@ -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"] diff --git a/pitch-deck/app/api/chat/route.ts b/pitch-deck/app/api/chat/route.ts new file mode 100644 index 0000000..4cb502d --- /dev/null +++ b/pitch-deck/app/api/chat/route.ts @@ -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 { + 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 = { + '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 } + ) + } +} diff --git a/pitch-deck/app/api/data/route.ts b/pitch-deck/app/api/data/route.ts new file mode 100644 index 0000000..ef8a500 --- /dev/null +++ b/pitch-deck/app/api/data/route.ts @@ -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 } + ) + } +} diff --git a/pitch-deck/app/api/financial-model/assumptions/route.ts b/pitch-deck/app/api/financial-model/assumptions/route.ts new file mode 100644 index 0000000..3ac8d35 --- /dev/null +++ b/pitch-deck/app/api/financial-model/assumptions/route.ts @@ -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 }) + } +} diff --git a/pitch-deck/app/api/financial-model/compute/route.ts b/pitch-deck/app/api/financial-model/compute/route.ts new file mode 100644 index 0000000..9e5f6b2 --- /dev/null +++ b/pitch-deck/app/api/financial-model/compute/route.ts @@ -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 = {} + 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 }) + } +} diff --git a/pitch-deck/app/api/financial-model/results/[scenarioId]/route.ts b/pitch-deck/app/api/financial-model/results/[scenarioId]/route.ts new file mode 100644 index 0000000..c8f2dd7 --- /dev/null +++ b/pitch-deck/app/api/financial-model/results/[scenarioId]/route.ts @@ -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 }) + } +} diff --git a/pitch-deck/app/api/financial-model/route.ts b/pitch-deck/app/api/financial-model/route.ts new file mode 100644 index 0000000..a74caaf --- /dev/null +++ b/pitch-deck/app/api/financial-model/route.ts @@ -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 }) + } +} diff --git a/pitch-deck/app/globals.css b/pitch-deck/app/globals.css new file mode 100644 index 0000000..95ef764 --- /dev/null +++ b/pitch-deck/app/globals.css @@ -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); + } +} diff --git a/pitch-deck/app/layout.tsx b/pitch-deck/app/layout.tsx new file mode 100644 index 0000000..209399e --- /dev/null +++ b/pitch-deck/app/layout.tsx @@ -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 ( + + + {children} + + + ) +} diff --git a/pitch-deck/app/page.tsx b/pitch-deck/app/page.tsx new file mode 100644 index 0000000..1f62be6 --- /dev/null +++ b/pitch-deck/app/page.tsx @@ -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('de') + + const toggleLanguage = useCallback(() => { + setLang(prev => prev === 'de' ? 'en' : 'de') + }, []) + + return +} diff --git a/pitch-deck/components/ChatFAB.tsx b/pitch-deck/components/ChatFAB.tsx new file mode 100644 index 0000000..fe65677 --- /dev/null +++ b/pitch-deck/components/ChatFAB.tsx @@ -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 + 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([]) + const [input, setInput] = useState('') + const [isStreaming, setIsStreaming] = useState(false) + const [parsedResponses, setParsedResponses] = useState>(new Map()) + const messagesEndRef = useRef(null) + const inputRef = useRef(null) + const abortRef = useRef(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 ( + <> +
{displayText}
+ {isStreaming && idx === messages.length - 1 && msg.role === 'assistant' && ( + + )} + + {/* GOTO Buttons */} + {parsed && parsed.gotos.length > 0 && ( +
+ {parsed.gotos.map((g, gi) => ( + + ))} +
+ )} + + {/* Follow-Up Suggestions */} + {parsed && parsed.followUps.length > 0 && !isStreaming && ( +
+ {parsed.followUps.map((q, qi) => ( + + ))} +
+ )} + + ) + } + + return ( + <> + {/* FAB Button — sits to the left of NavigationFAB */} + + {!isOpen && ( + 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'} + > + + + + + + + + )} + + + {/* Chat Panel */} + + {isOpen && ( + + {/* Header */} +
+
+
+ +
+
+ Investor Agent + + {isStreaming + ? (lang === 'de' ? 'antwortet...' : 'responding...') + : (lang === 'de' ? 'online' : 'online') + } + +
+
+
+ + +
+
+ + {/* Messages */} +
+ {messages.length === 0 && ( +
+
+ + {lang === 'de' ? 'Fragen Sie den Investor Agent:' : 'Ask the Investor Agent:'} +
+ {suggestions.map((q, idx) => ( + 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} + + ))} +
+ )} + + {messages.map((msg, idx) => ( +
+ {msg.role === 'assistant' && ( +
+ +
+ )} +
+ {msg.role === 'assistant' ? renderMessageContent(msg, idx) : ( +
{msg.content}
+ )} +
+ {msg.role === 'user' && ( +
+ +
+ )} +
+ ))} +
+
+ + {/* Input */} +
+ {isStreaming && ( + + )} +
+ 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" + /> + +
+
+ + )} + + + ) +} diff --git a/pitch-deck/components/LanguageToggle.tsx b/pitch-deck/components/LanguageToggle.tsx new file mode 100644 index 0000000..cafc6c5 --- /dev/null +++ b/pitch-deck/components/LanguageToggle.tsx @@ -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 ( + + ) +} diff --git a/pitch-deck/components/NavigationControls.tsx b/pitch-deck/components/NavigationControls.tsx new file mode 100644 index 0000000..b4f320a --- /dev/null +++ b/pitch-deck/components/NavigationControls.tsx @@ -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 */} + + {!isFirst && ( + + + + )} + + + {/* Right Arrow */} + + {!isLast && ( + + + + )} + + + {/* Slide Counter */} +
+ {current + 1} / {total} +
+ + ) +} diff --git a/pitch-deck/components/NavigationFAB.tsx b/pitch-deck/components/NavigationFAB.tsx new file mode 100644 index 0000000..268bdb4 --- /dev/null +++ b/pitch-deck/components/NavigationFAB.tsx @@ -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 + 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 ( +
+ + {!isOpen ? ( + 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" + > + + + ) : ( + + {/* Header */} +
+ {i.nav.slides} + +
+ + {/* Slide List */} +
+ {i.slideNames.map((name, idx) => { + const isActive = idx === currentIndex + const isVisited = visitedSlides.has(idx) + const isAI = idx === totalSlides - 1 + + return ( + + ) + })} +
+ + {/* Footer */} +
+ {/* Language Toggle */} + + + {/* Fullscreen */} + +
+
+ )} + +
+ ) +} diff --git a/pitch-deck/components/ParticleBackground.tsx b/pitch-deck/components/ParticleBackground.tsx new file mode 100644 index 0000000..8a1f49c --- /dev/null +++ b/pitch-deck/components/ParticleBackground.tsx @@ -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(null) + const particlesRef = useRef([]) + const frameRef = useRef(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 ( + + ) +} diff --git a/pitch-deck/components/PitchDeck.tsx b/pitch-deck/components/PitchDeck.tsx new file mode 100644 index 0000000..4605be3 --- /dev/null +++ b/pitch-deck/components/PitchDeck.tsx @@ -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 ( +
+
+
+

{lang === 'de' ? 'Lade Pitch-Daten...' : 'Loading pitch data...'}

+
+
+ ) + } + + if (error || !data) { + return ( +
+
+

{lang === 'de' ? 'Fehler beim Laden' : 'Loading error'}

+

{error || 'No data'}

+
+
+ ) + } + + function renderSlide() { + if (!data) return null + + switch (nav.currentSlide) { + case 'cover': + return + case 'problem': + return + case 'solution': + return + case 'product': + return + case 'how-it-works': + return + case 'market': + return + case 'business-model': + return + case 'traction': + return + case 'competition': + return + case 'team': + return + case 'financials': + return + case 'the-ask': + return + case 'ai-qa': + return + default: + return null + } + } + + return ( +
+ + + + + {renderSlide()} + + + + + + + + + + {nav.showOverview && ( + nav.setShowOverview(false)} + lang={lang} + /> + )} + +
+ ) +} diff --git a/pitch-deck/components/ProgressBar.tsx b/pitch-deck/components/ProgressBar.tsx new file mode 100644 index 0000000..cb90e41 --- /dev/null +++ b/pitch-deck/components/ProgressBar.tsx @@ -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 ( +
+ +
+ ) +} diff --git a/pitch-deck/components/SlideContainer.tsx b/pitch-deck/components/SlideContainer.tsx new file mode 100644 index 0000000..0d84ae1 --- /dev/null +++ b/pitch-deck/components/SlideContainer.tsx @@ -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 ( + + +
+ {children} +
+
+
+ ) +} diff --git a/pitch-deck/components/SlideOverview.tsx b/pitch-deck/components/SlideOverview.tsx new file mode 100644 index 0000000..c812ab1 --- /dev/null +++ b/pitch-deck/components/SlideOverview.tsx @@ -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 ( + + e.stopPropagation()} + > + {i.slideNames.map((name, idx) => ( + 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' + } + `} + > + {idx + 1} + + {name} + + + ))} + + + ) +} diff --git a/pitch-deck/components/slides/AIQASlide.tsx b/pitch-deck/components/slides/AIQASlide.tsx new file mode 100644 index 0000000..12da632 --- /dev/null +++ b/pitch-deck/components/slides/AIQASlide.tsx @@ -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 ( +
+ +
+
+ +
+ +
+

+ {i.aiqa.title} +

+

{i.aiqa.subtitle}

+
+ + +
+ +
+
+
+ ) +} diff --git a/pitch-deck/components/slides/BusinessModelSlide.tsx b/pitch-deck/components/slides/BusinessModelSlide.tsx new file mode 100644 index 0000000..7ba6d68 --- /dev/null +++ b/pitch-deck/components/slides/BusinessModelSlide.tsx @@ -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 ( +
+ +

+ {i.businessModel.title} +

+

{i.businessModel.subtitle}

+
+ + {/* Key Metrics */} +
+ + +

{i.businessModel.recurringRevenue}

+

100%

+

SaaS / Subscription

+
+ + +

{i.businessModel.margin}

+

>70%

+

{lang === 'de' ? 'nach Amortisation' : 'post amortization'}

+
+ + +

{i.businessModel.amortization}

+

24 {i.businessModel.months}

+

{lang === 'de' ? 'Hardware-Amortisation' : 'Hardware Amortization'}

+
+
+ + {/* Unit Economics per Product */} + +

{i.businessModel.unitEconomics}

+
+ {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 ( + +

{p.name}

+
+
+ {lang === 'de' ? 'Monatspreis' : 'Monthly Price'} + {p.monthly_price_eur} EUR +
+ {p.hardware_cost_eur > 0 && ( +
+ {i.businessModel.hardwareCost} + -{amort} EUR/Mo +
+ )} + {p.operating_cost_eur > 0 && ( +
+ {i.businessModel.operatingCost} + -{p.operating_cost_eur.toLocaleString('de-DE')} EUR/Mo +
+ )} +
+ {i.businessModel.margin} + 0 ? 'text-green-400' : 'text-red-400'}`}> + {marginPct > 0 ? '+' : ''}{monthlyMargin} EUR ({marginPct}%) + +
+
+
+ ) + })} +
+
+
+ ) +} diff --git a/pitch-deck/components/slides/CompetitionSlide.tsx b/pitch-deck/components/slides/CompetitionSlide.tsx new file mode 100644 index 0000000..2a943e4 --- /dev/null +++ b/pitch-deck/components/slides/CompetitionSlide.tsx @@ -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 ( +
+ +

+ {i.competition.title} +

+

{i.competition.subtitle}

+
+ + {/* Feature Matrix (Core Compliance) */} + + + + + + + {/* Security & Developer Features — nur bei ComplAI */} + +
+

+ + {lang === 'de' ? <>Integrierte Security & Developer Tools — nur bei : <>Integrated Security & Developer Tools — only} +

+
+ {secFeats.map((feat, idx) => { + const Icon = feat.icon + return ( + +
+
+ + {feat.title} +
+

{feat.desc}

+
+
+ ) + })} +
+
+
+ + {/* Competitor Summary */} +
+ {competitors.map((c, idx) => ( + +
+
+

{c.name}

+ {c.customers_count.toLocaleString()} {lang === 'de' ? 'Kunden' : 'customers'} +
+

{c.pricing_range}

+
+ {(c.weaknesses || []).slice(0, 2).map((w, widx) => ( + + {w} + + ))} +
+
+
+ ))} +
+
+ ) +} diff --git a/pitch-deck/components/slides/CoverSlide.tsx b/pitch-deck/components/slides/CoverSlide.tsx new file mode 100644 index 0000000..b4e5570 --- /dev/null +++ b/pitch-deck/components/slides/CoverSlide.tsx @@ -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 ( +
+ {/* Logo / Brand */} + +
+ + + + + + + +
+
+ + {/* Company Name */} + + BreakPilot{' '} + + + + + + {/* Tagline */} + + {i.cover.tagline} + + + {/* Subtitle */} + + {i.cover.subtitle} + + + {/* CTA */} + + {i.cover.cta} + + +
+ ) +} diff --git a/pitch-deck/components/slides/FinancialsSlide.tsx b/pitch-deck/components/slides/FinancialsSlide.tsx new file mode 100644 index 0000000..c62d3ed --- /dev/null +++ b/pitch-deck/components/slides/FinancialsSlide.tsx @@ -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('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 = {} + 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 ( +
+
+
+ ) + } + + return ( +
+ +

+ {i.financials.title} +

+

{i.financials.subtitle}

+
+ + {/* Hero KPI Cards */} +
+ + + + = 3 ? 'up' : 'down'} + color="#a855f7" + delay={0.25} + /> +
+ + {/* Tab Navigation */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Main content: 3-column layout */} +
+ {/* Left: Charts (8 columns) */} +
+ + {/* TAB: Overview — monatlicher Chart + Waterfall + Unit Economics */} + {activeTab === 'overview' && ( + <> + +
+
+

+ {de ? 'Umsatz vs. Kosten (60 Monate)' : 'Revenue vs. Costs (60 months)'} +

+
+ {de ? 'Umsatz' : 'Revenue'} + {de ? 'Kosten' : 'Costs'} + {de ? 'Kunden' : 'Customers'} +
+
+ +
+
+ +
+ +
+

+ {de ? 'Cash-Flow (Quartal)' : 'Cash Flow (Quarterly)'} +

+ {activeResults && } +
+
+ + +
+
+ +
+ {lastResult && ( + a.key === 'churn_rate_monthly')?.value as number || 3} + lang={lang} + /> + )} +
+
+
+ + )} + + {/* TAB: GuV — Annual P&L Table */} + {activeTab === 'guv' && activeResults && ( + +
+
+

+ {de ? 'Gewinn- und Verlustrechnung (5 Jahre)' : 'Profit & Loss Statement (5 Years)'} +

+

+ {de ? 'Alle Werte in EUR' : 'All values in EUR'} +

+
+ +
+
+ )} + + {/* TAB: Cashflow & Finanzbedarf */} + {activeTab === 'cashflow' && activeResults && ( + +
+

+ {de ? 'Jaehrlicher Cashflow & Finanzbedarf' : 'Annual Cash Flow & Funding Requirements'} +

+ +
+
+ )} +
+ + {/* Right: Controls (4 columns) */} +
+ {/* Scenario Switcher */} + +
+ { + fm.setActiveScenarioId(id) + }} + onToggleCompare={() => { + if (!fm.compareMode) { + fm.computeAll() + } + fm.setCompareMode(!fm.compareMode) + }} + lang={lang} + /> +
+
+ + {/* Assumption Sliders */} + +
+

+ {i.financials.adjustAssumptions} +

+ {fm.activeScenario && ( + { + if (fm.activeScenarioId) { + fm.updateAssumption(fm.activeScenarioId, key, value) + } + }} + lang={lang} + /> + )} + {fm.computing && ( +
+
+ {de ? 'Berechne...' : 'Computing...'} +
+ )} +
+ +
+
+
+ ) +} diff --git a/pitch-deck/components/slides/HowItWorksSlide.tsx b/pitch-deck/components/slides/HowItWorksSlide.tsx new file mode 100644 index 0000000..79173c7 --- /dev/null +++ b/pitch-deck/components/slides/HowItWorksSlide.tsx @@ -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 ( +
+ +

+ {i.howItWorks.title} +

+

{i.howItWorks.subtitle}

+
+ +
+ {/* Connection Line */} +
+ +
+ {i.howItWorks.steps.map((step, idx) => { + const Icon = stepIcons[idx] + return ( + +
+ +
+
+
+ 0{idx + 1} +

{step.title}

+
+

{step.desc}

+
+
+ ) + })} +
+
+
+ ) +} diff --git a/pitch-deck/components/slides/MarketSlide.tsx b/pitch-deck/components/slides/MarketSlide.tsx new file mode 100644 index 0000000..d30922b --- /dev/null +++ b/pitch-deck/components/slides/MarketSlide.tsx @@ -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 ( +
+ +

+ {i.market.title} +

+

{i.market.subtitle}

+
+ +
+ {/* Circles */} +
+ {market.map((m, idx) => ( + + {idx === market.length - 1 && ( +
+ {segments[idx]} +
+ )} +
+ ))} +
+ + {/* Labels */} +
+ {market.map((m, idx) => ( + +
+
+
+ {segments[idx]} + {labels[idx]} +
+
+ +
+
+ {i.market.growth}: {m.growth_rate_pct}% · {i.market.source}: {m.source} +
+
+ + ))} +
+
+
+ ) +} diff --git a/pitch-deck/components/slides/ProblemSlide.tsx b/pitch-deck/components/slides/ProblemSlide.tsx new file mode 100644 index 0000000..1bbcfda --- /dev/null +++ b/pitch-deck/components/slides/ProblemSlide.tsx @@ -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 ( +
+ +

+ {i.problem.title} +

+

{i.problem.subtitle}

+
+ +
+ {i.problem.cards.map((card, idx) => { + const Icon = icons[idx] + return ( + +
+ +
+

{card.title}

+

{card.stat}

+

{card.desc}

+
+ ) + })} +
+ + +
+

+ “{i.problem.quote}” +

+
+
+
+ ) +} diff --git a/pitch-deck/components/slides/ProductSlide.tsx b/pitch-deck/components/slides/ProductSlide.tsx new file mode 100644 index 0000000..282afbb --- /dev/null +++ b/pitch-deck/components/slides/ProductSlide.tsx @@ -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 ( +
+ +

+ {i.product.title} +

+

{i.product.subtitle}

+
+ +
+ {products.map((product, idx) => ( + + ))} +
+
+ ) +} diff --git a/pitch-deck/components/slides/SolutionSlide.tsx b/pitch-deck/components/slides/SolutionSlide.tsx new file mode 100644 index 0000000..7374deb --- /dev/null +++ b/pitch-deck/components/slides/SolutionSlide.tsx @@ -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 ( +
+ +

+ {i.solution.title} +

+

+ — {lang === 'de' ? 'Compliance auf Autopilot' : 'Compliance on Autopilot'} +

+
+ +
+ {i.solution.pillars.map((pillar, idx) => { + const Icon = icons[idx] + return ( + + +
+ +
+

{pillar.title}

+

{pillar.desc}

+
+
+ ) + })} +
+
+ ) +} diff --git a/pitch-deck/components/slides/TeamSlide.tsx b/pitch-deck/components/slides/TeamSlide.tsx new file mode 100644 index 0000000..c5bef4f --- /dev/null +++ b/pitch-deck/components/slides/TeamSlide.tsx @@ -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 ( +
+ +

+ {i.team.title} +

+

{i.team.subtitle}

+
+ +
+ {team.map((member, idx) => ( + +
+ {/* Avatar */} +
+ +
+ +
+

{member.name}

+

+ {lang === 'de' ? member.role_de : member.role_en} +

+

+ {lang === 'de' ? member.bio_de : member.bio_en} +

+ + {/* Equity */} +
+ {i.team.equity}: + {member.equity_pct}% +
+ + {/* Expertise Tags */} +
+ {(member.expertise || []).map((skill, sidx) => ( + + {skill} + + ))} +
+
+
+
+ ))} +
+
+ ) +} diff --git a/pitch-deck/components/slides/TheAskSlide.tsx b/pitch-deck/components/slides/TheAskSlide.tsx new file mode 100644 index 0000000..19ae88c --- /dev/null +++ b/pitch-deck/components/slides/TheAskSlide.tsx @@ -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 ( +
+ +

+ {i.theAsk.title} +

+

{i.theAsk.subtitle}

+
+ + {/* Main Number */} + + +

+ + EUR +

+
+
+ + {/* Details */} +
+ + +

{i.theAsk.instrument}

+

{funding?.instrument || 'SAFE'}

+
+ + +

{i.theAsk.targetDate}

+

Q3 2026

+
+ + +

{lang === 'de' ? 'Runway' : 'Runway'}

+

18 {lang === 'de' ? 'Monate' : 'Months'}

+
+
+ + {/* Use of Funds */} + + +

{i.theAsk.useOfFunds}

+
+ {/* Pie Chart */} +
+ + + + {pieData.map((_, idx) => ( + + ))} + + `${value}%`} + /> + + +
+ + {/* Legend */} +
+ {useOfFunds.map((item, idx) => ( +
+
+ + {lang === 'de' ? item.label_de : item.label_en} + + {item.percentage}% + + {((funding.amount_eur * item.percentage) / 100).toLocaleString('de-DE')} EUR + +
+ ))} +
+
+ + +
+ ) +} diff --git a/pitch-deck/components/slides/TractionSlide.tsx b/pitch-deck/components/slides/TractionSlide.tsx new file mode 100644 index 0000000..29ced32 --- /dev/null +++ b/pitch-deck/components/slides/TractionSlide.tsx @@ -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 ( +
+ +

+ {i.traction.title} +

+

{i.traction.subtitle}

+
+ +
+ {/* KPI Cards */} +
+
+ {metrics.slice(0, 6).map((m, idx) => ( + +
+ + {lang === 'de' ? m.label_de : m.label_en} + + {m.is_live && } +
+

+ {m.value}{m.unit ? ` ${m.unit}` : ''} +

+
+ ))} +
+
+ + {/* Timeline */} + +
+ +
+
+
+
+ ) +} diff --git a/pitch-deck/components/ui/AnimatedCounter.tsx b/pitch-deck/components/ui/AnimatedCounter.tsx new file mode 100644 index 0000000..e54814b --- /dev/null +++ b/pitch-deck/components/ui/AnimatedCounter.tsx @@ -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(null) + const frameRef = useRef(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 ( + + {prefix}{formatted}{suffix} + + ) +} diff --git a/pitch-deck/components/ui/AnnualCashflowChart.tsx b/pitch-deck/components/ui/AnnualCashflowChart.tsx new file mode 100644 index 0000000..3850ada --- /dev/null +++ b/pitch-deck/components/ui/AnnualCashflowChart.tsx @@ -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() + 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 ( +
+ {/* Summary Cards */} +
+
+

+ {de ? 'Startkapital' : 'Initial Funding'} +

+

{formatValue(initialFunding)} EUR

+
+
+

+ {de ? 'Kum. Finanzbedarf' : 'Cum. Funding Need'} +

+

{formatValue(cumulativeNeed)} EUR

+
+
+

+ {de ? 'Finanzierungsluecke' : 'Funding Gap'} +

+

0 ? 'text-red-400' : 'text-emerald-400'}`}> + {totalFundingGap > 0 ? formatValue(totalFundingGap) + ' EUR' : (de ? 'Gedeckt' : 'Covered')} +

+
+
+ + {/* Chart */} +
+ + + + + { + 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] + }} + /> + + + {/* Net Cashflow Bars */} + + {data.map((entry, i) => ( + = 0 ? 'rgba(34, 197, 94, 0.7)' : 'rgba(239, 68, 68, 0.6)'} + /> + ))} + + + {/* Cash Balance Line */} + + + {/* Cumulative Funding Need Line */} + + + +
+ + {/* Legend */} +
+ + + + {de ? 'Netto-Cashflow' : 'Net Cash Flow'} + + + + {de ? 'Cash-Bestand' : 'Cash Balance'} + + + + {de ? 'Kum. Finanzbedarf' : 'Cum. Funding Need'} + +
+
+ ) +} diff --git a/pitch-deck/components/ui/AnnualPLTable.tsx b/pitch-deck/components/ui/AnnualPLTable.tsx new file mode 100644 index 0000000..ae76be0 --- /dev/null +++ b/pitch-deck/components/ui/AnnualPLTable.tsx @@ -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() + 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 ( + + + + + + {rows.map(r => ( + + ))} + + + + {lineItems.map((item) => ( + + + {rows.map(r => { + const val = r[item.key] as number + const isNeg = val < 0 || item.isNegative + return ( + + ) + })} + + ))} + +
+ {de ? 'GuV-Position' : 'P&L Line Item'} + + {r.year} +
+ {item.label} + 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)) + } +
+
+ ) +} diff --git a/pitch-deck/components/ui/BrandName.tsx b/pitch-deck/components/ui/BrandName.tsx new file mode 100644 index 0000000..edc3a91 --- /dev/null +++ b/pitch-deck/components/ui/BrandName.tsx @@ -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 ( + + {prefix && <>BreakPilot } + ComplAI + + ) +} diff --git a/pitch-deck/components/ui/ChatInterface.tsx b/pitch-deck/components/ui/ChatInterface.tsx new file mode 100644 index 0000000..090aee8 --- /dev/null +++ b/pitch-deck/components/ui/ChatInterface.tsx @@ -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([]) + const [input, setInput] = useState('') + const [isStreaming, setIsStreaming] = useState(false) + const messagesEndRef = useRef(null) + const inputRef = useRef(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 ( +
+ {/* Messages */} +
+ {messages.length === 0 && ( +
+
+ + {lang === 'de' ? 'Vorgeschlagene Fragen:' : 'Suggested questions:'} +
+ {i.aiqa.suggestions.map((q, idx) => ( + 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} + + ))} +
+ )} + + + {messages.map((msg, idx) => ( + + {msg.role === 'assistant' && ( +
+ +
+ )} +
+
{msg.content}
+ {isStreaming && idx === messages.length - 1 && msg.role === 'assistant' && ( + + )} +
+ {msg.role === 'user' && ( +
+ +
+ )} +
+ ))} +
+
+
+ + {/* Input */} +
+ 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" + /> + +
+
+ ) +} diff --git a/pitch-deck/components/ui/FadeInView.tsx b/pitch-deck/components/ui/FadeInView.tsx new file mode 100644 index 0000000..bbf37fd --- /dev/null +++ b/pitch-deck/components/ui/FadeInView.tsx @@ -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 ( + + {children} + + ) +} diff --git a/pitch-deck/components/ui/FeatureMatrix.tsx b/pitch-deck/components/ui/FeatureMatrix.tsx new file mode 100644 index 0000000..b64c7d0 --- /dev/null +++ b/pitch-deck/components/ui/FeatureMatrix.tsx @@ -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 ( + + {value ? ( + + + + ) : ( + + )} + + ) +} + +export default function FeatureMatrix({ features, lang }: FeatureMatrixProps) { + return ( +
+ + + + + + + + + + + + {features.map((f, i) => ( + + + + + + + + ))} + +
FeatureProlianceDataGuardheyData
+ {f.is_differentiator && } + + {lang === 'de' ? f.feature_name_de : f.feature_name_en} + +
+
+ ) +} diff --git a/pitch-deck/components/ui/FinancialChart.tsx b/pitch-deck/components/ui/FinancialChart.tsx new file mode 100644 index 0000000..a53456b --- /dev/null +++ b/pitch-deck/components/ui/FinancialChart.tsx @@ -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 + compareMode?: boolean + scenarioColors?: Record + lang: 'de' | 'en' +} + +export default function FinancialChart({ + activeResults, + compareResults, + compareMode = false, + scenarioColors = {}, + lang, +}: FinancialChartProps) { + if (!activeResults) { + return ( +
+ {lang === 'de' ? 'Lade Daten...' : 'Loading data...'} +
+ ) + } + + const results = activeResults.results + const breakEvenMonth = activeResults.summary.break_even_month + + // Build chart data — monthly + const data = results.map((r) => { + const entry: Record = { + 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 ( +
+ + + + + + + + + + + + + + + + + + { + 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 && ( + + )} + + {/* Revenue area */} + + + {/* Cost area */} + + + {/* Customers line */} + + + {/* Compare mode: overlay other scenarios */} + {compareMode && compareResults && Array.from(compareResults.entries()).map(([scenarioId]) => ( + + ))} + + {/* Brush for zooming */} + + + +
+ ) +} diff --git a/pitch-deck/components/ui/FinancialSliders.tsx b/pitch-deck/components/ui/FinancialSliders.tsx new file mode 100644 index 0000000..0c8719b --- /dev/null +++ b/pitch-deck/components/ui/FinancialSliders.tsx @@ -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 ( +
+

{label}

+
+ {steps.map((s: number, i: number) => ( +
+

Y{i + 1}

+

{s}

+
+ ))} +
+
+ ) + } + + return ( +
+
+ {label} + {value}{assumption.unit === 'EUR' ? ' EUR' : assumption.unit === '%' ? '%' : ` ${assumption.unit || ''}`} +
+ 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 + " + /> +
+ ) +} + +interface CategoryGroup { + key: string + label: string + items: FMAssumption[] +} + +export default function FinancialSliders({ assumptions, onAssumptionChange, lang }: FinancialSlidersProps) { + const [openCategories, setOpenCategories] = useState>(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 ( +
+ {categories.filter(c => c.items.length > 0).map((cat) => { + const isOpen = openCategories.has(cat.key) + return ( +
+ + + {isOpen && ( + +
+ {cat.items.map((a) => ( + onAssumptionChange(a.key, val)} + lang={lang} + /> + ))} +
+
+ )} +
+
+ ) + })} +
+ ) +} diff --git a/pitch-deck/components/ui/GlassCard.tsx b/pitch-deck/components/ui/GlassCard.tsx new file mode 100644 index 0000000..7589c8b --- /dev/null +++ b/pitch-deck/components/ui/GlassCard.tsx @@ -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 ( + + {children} + + ) +} diff --git a/pitch-deck/components/ui/GradientText.tsx b/pitch-deck/components/ui/GradientText.tsx new file mode 100644 index 0000000..fc2a986 --- /dev/null +++ b/pitch-deck/components/ui/GradientText.tsx @@ -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 ( + + {children} + + ) +} diff --git a/pitch-deck/components/ui/KPICard.tsx b/pitch-deck/components/ui/KPICard.tsx new file mode 100644 index 0000000..28f7768 --- /dev/null +++ b/pitch-deck/components/ui/KPICard.tsx @@ -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 ( + + {/* Glow effect */} +
+ +

{label}

+
+

+ +

+ {trend !== 'neutral' && ( + + {trend === 'up' ? : } + + )} +
+ {subLabel && ( +

{subLabel}

+ )} + + ) +} diff --git a/pitch-deck/components/ui/LiveIndicator.tsx b/pitch-deck/components/ui/LiveIndicator.tsx new file mode 100644 index 0000000..2e51b4b --- /dev/null +++ b/pitch-deck/components/ui/LiveIndicator.tsx @@ -0,0 +1,13 @@ +'use client' + +export default function LiveIndicator({ className = '' }: { className?: string }) { + return ( + + + + + + LIVE + + ) +} diff --git a/pitch-deck/components/ui/PricingCard.tsx b/pitch-deck/components/ui/PricingCard.tsx new file mode 100644 index 0000000..0090790 --- /dev/null +++ b/pitch-deck/components/ui/PricingCard.tsx @@ -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 ( + + {product.is_popular && ( +
+ {i.product.popular} +
+ )} + +
+ + +

{product.name}

+

{product.hardware}

+ +
+ {product.monthly_price_eur} + EUR +
+

{i.product.monthly}

+ +
+
+ {i.product.llm} + {product.llm_size} +
+ {product.hardware_cost_eur > 0 && ( +
+ {i.product.hardware} + {product.hardware_cost_eur.toLocaleString('de-DE')} EUR +
+ )} +
+ +
    + {(features || []).map((feature, idx) => ( +
  • + + {feature} +
  • + ))} +
+
+
+ ) +} diff --git a/pitch-deck/components/ui/ProductShowcase.tsx b/pitch-deck/components/ui/ProductShowcase.tsx new file mode 100644 index 0000000..4c137bd --- /dev/null +++ b/pitch-deck/components/ui/ProductShowcase.tsx @@ -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 ( + +
+ +
+
+ ) + } + + return ( + +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {type +
+
+ ) +} diff --git a/pitch-deck/components/ui/RunwayGauge.tsx b/pitch-deck/components/ui/RunwayGauge.tsx new file mode 100644 index 0000000..19f186f --- /dev/null +++ b/pitch-deck/components/ui/RunwayGauge.tsx @@ -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 ( + +
+ + {/* Background arc */} + + + {/* Filled arc */} + + + {/* 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 ( + + + + {tick} + + + ) + })} + + {/* Needle */} + + + {/* Center circle */} + + + +
+ +
+

{Math.round(months)}

+

{label}

+
+
+ ) +} diff --git a/pitch-deck/components/ui/ScenarioSwitcher.tsx b/pitch-deck/components/ui/ScenarioSwitcher.tsx new file mode 100644 index 0000000..07d4991 --- /dev/null +++ b/pitch-deck/components/ui/ScenarioSwitcher.tsx @@ -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 ( +
+
+

+ {lang === 'de' ? 'Szenarien' : 'Scenarios'} +

+ +
+ +
+ {scenarios.map((s) => ( + 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' + }`} + > + + {s.name} + + ))} +
+
+ ) +} diff --git a/pitch-deck/components/ui/Timeline.tsx b/pitch-deck/components/ui/Timeline.tsx new file mode 100644 index 0000000..85e4d8f --- /dev/null +++ b/pitch-deck/components/ui/Timeline.tsx @@ -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 ( +
+ {/* Line */} +
+ +
+ {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 ( + +
+ +
+
+
+ {dateStr} + {m.status === 'in_progress' && ( + + In Progress + + )} +
+

+ {lang === 'de' ? m.title_de : m.title_en} +

+

+ {lang === 'de' ? m.description_de : m.description_en} +

+
+
+ ) + })} +
+
+ ) +} diff --git a/pitch-deck/components/ui/UnitEconomicsCards.tsx b/pitch-deck/components/ui/UnitEconomicsCards.tsx new file mode 100644 index 0000000..9fb336a --- /dev/null +++ b/pitch-deck/components/ui/UnitEconomicsCards.tsx @@ -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 ( + + + + + ) +} + +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 ( +
+ {cards.map((card, i) => ( + +
+ +
+

+ +

+

{card.label}

+

{card.sub}

+
+ ))} +
+ ) +} diff --git a/pitch-deck/components/ui/WaterfallChart.tsx b/pitch-deck/components/ui/WaterfallChart.tsx new file mode 100644 index 0000000..3cb35ae --- /dev/null +++ b/pitch-deck/components/ui/WaterfallChart.tsx @@ -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 ( +
+ + + + + [ + formatValue(value) + ' EUR', + name === 'revenue' ? (lang === 'de' ? 'Umsatz' : 'Revenue') + : name === 'costs' ? (lang === 'de' ? 'Kosten' : 'Costs') + : 'Net', + ]} + /> + + + {quarterlyData.map((entry, i) => ( + + ))} + + + {quarterlyData.map((entry, i) => ( + + ))} + + + +
+ ) +} diff --git a/pitch-deck/lib/animations.ts b/pitch-deck/lib/animations.ts new file mode 100644 index 0000000..3479d50 --- /dev/null +++ b/pitch-deck/lib/animations.ts @@ -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 }, +} diff --git a/pitch-deck/lib/db.ts b/pitch-deck/lib/db.ts new file mode 100644 index 0000000..252e8a9 --- /dev/null +++ b/pitch-deck/lib/db.ts @@ -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 diff --git a/pitch-deck/lib/hooks/useFinancialModel.ts b/pitch-deck/lib/hooks/useFinancialModel.ts new file mode 100644 index 0000000..051e00e --- /dev/null +++ b/pitch-deck/lib/hooks/useFinancialModel.ts @@ -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([]) + const [activeScenarioId, setActiveScenarioId] = useState(null) + const [compareMode, setCompareMode] = useState(false) + const [results, setResults] = useState>(new Map()) + const [loading, setLoading] = useState(true) + const [computing, setComputing] = useState(false) + const computeTimer = useRef(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, + } +} diff --git a/pitch-deck/lib/hooks/useKeyboard.ts b/pitch-deck/lib/hooks/useKeyboard.ts new file mode 100644 index 0000000..204c708 --- /dev/null +++ b/pitch-deck/lib/hooks/useKeyboard.ts @@ -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]) +} diff --git a/pitch-deck/lib/hooks/useLanguage.ts b/pitch-deck/lib/hooks/useLanguage.ts new file mode 100644 index 0000000..b9dbea4 --- /dev/null +++ b/pitch-deck/lib/hooks/useLanguage.ts @@ -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({ + lang: 'de', + toggleLanguage: () => {}, + setLanguage: () => {}, +}) + +export function LanguageProvider({ children }: { children: ReactNode }) { + const [lang, setLang] = useState('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) +} diff --git a/pitch-deck/lib/hooks/usePitchData.ts b/pitch-deck/lib/hooks/usePitchData.ts new file mode 100644 index 0000000..132de90 --- /dev/null +++ b/pitch-deck/lib/hooks/usePitchData.ts @@ -0,0 +1,29 @@ +'use client' + +import { useState, useEffect } from 'react' +import { PitchData } from '../types' + +export function usePitchData() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 } +} diff --git a/pitch-deck/lib/hooks/useSlideNavigation.ts b/pitch-deck/lib/hooks/useSlideNavigation.ts new file mode 100644 index 0000000..fb26863 --- /dev/null +++ b/pitch-deck/lib/hooks/useSlideNavigation.ts @@ -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>(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, + } +} diff --git a/pitch-deck/lib/i18n.ts b/pitch-deck/lib/i18n.ts new file mode 100644 index 0000000..64a8dec --- /dev/null +++ b/pitch-deck/lib/i18n.ts @@ -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 diff --git a/pitch-deck/lib/types.ts b/pitch-deck/lib/types.ts new file mode 100644 index 0000000..253e4a0 --- /dev/null +++ b/pitch-deck/lib/types.ts @@ -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' diff --git a/pitch-deck/next.config.js b/pitch-deck/next.config.js new file mode 100644 index 0000000..f0eab76 --- /dev/null +++ b/pitch-deck/next.config.js @@ -0,0 +1,10 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + reactStrictMode: true, + typescript: { + ignoreBuildErrors: true, + }, +} + +module.exports = nextConfig diff --git a/pitch-deck/package.json b/pitch-deck/package.json new file mode 100644 index 0000000..09aec13 --- /dev/null +++ b/pitch-deck/package.json @@ -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" + } +} diff --git a/pitch-deck/postcss.config.mjs b/pitch-deck/postcss.config.mjs new file mode 100644 index 0000000..d0c615b --- /dev/null +++ b/pitch-deck/postcss.config.mjs @@ -0,0 +1,9 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + +export default config diff --git a/pitch-deck/tailwind.config.ts b/pitch-deck/tailwind.config.ts new file mode 100644 index 0000000..58fba02 --- /dev/null +++ b/pitch-deck/tailwind.config.ts @@ -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 diff --git a/pitch-deck/tsconfig.json b/pitch-deck/tsconfig.json new file mode 100644 index 0000000..ba48aa7 --- /dev/null +++ b/pitch-deck/tsconfig.json @@ -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"] +}