feat(pitch-deck): add per-version PDF export (standard + financial)
Build pitch-deck / build-push-deploy (push) Successful in 1m49s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 40s
CI / test-python-voice (push) Successful in 29s
CI / test-bqas (push) Successful in 29s

Adds /pitch-print/[versionId] — a server-rendered, print-CSS-optimized
page that generates investor-ready PDFs via the browser's native print
dialog (Save as PDF). Two variants per version:

- Standard PDF (9 pages): Cover, Problem, Solution, Products, Market,
  Team, Milestones, The Ask
- Financial PDF (+4 pages): adds Financials P&L table (aggregated from
  pitch_fm_results), Assumptions, Cap Table, Legal Disclaimer

White background with indigo accents, A4 landscape via @page CSS, all
color-rendered in print via print-color-adjust: exact. Auto-triggers
window.print() 900ms after load. Admin toolbar visible on screen only.

Export buttons added to /pitch-admin/versions/[id] detail page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-05-12 13:00:19 +02:00
parent 79810f4eb8
commit 2e8cbfff3f
6 changed files with 999 additions and 1 deletions
@@ -3,7 +3,7 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { ArrowLeft, Lock, Save, GitFork, Eye, Code } from 'lucide-react' import { ArrowLeft, Lock, Save, GitFork, Eye, Code, FileText, BarChart3 } from 'lucide-react'
import TabEditor from './_components/TabEditors' import TabEditor from './_components/TabEditors'
const TABLE_LABELS: Record<string, string> = { const TABLE_LABELS: Record<string, string> = {
@@ -134,6 +134,20 @@ export default function VersionEditorPage() {
> >
<Eye className="w-4 h-4" /> Preview <Eye className="w-4 h-4" /> Preview
</Link> </Link>
<Link
href={`/pitch-print/${id}`}
target="_blank"
className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg flex items-center gap-2"
>
<FileText className="w-4 h-4" /> Export PDF
</Link>
<Link
href={`/pitch-print/${id}?financial=true`}
target="_blank"
className="bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2"
>
<BarChart3 className="w-4 h-4" /> Export PDF + Financial
</Link>
{isDraft && ( {isDraft && (
<button onClick={commitVersion} className="bg-green-500/15 hover:bg-green-500/25 text-green-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2"> <button onClick={commitVersion} className="bg-green-500/15 hover:bg-green-500/25 text-green-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2">
<Lock className="w-4 h-4" /> Commit <Lock className="w-4 h-4" /> Commit
@@ -0,0 +1,289 @@
import { PrintPage, SectionTitle, PrintTable, Badge, COLORS } from './PrintLayout'
import { Language, PitchCompany, PitchFunding, PitchProduct, PitchMarket, PitchTeamMember, PitchMilestone } from '@/lib/types'
const DE_PROBLEM_CARDS = [
{ title: 'KI-Dilemma', stat: 'Abgehängt', desc: 'Produzierende Unternehmen brauchen KI, um wettbewerbsfähig zu bleiben. Aber US-KI an den eigenen Quellcode zu lassen, kommt für die meisten nicht in Frage. Wer auf US-KI verzichtet, verliert den Anschluss. Wer sie nutzt, riskiert seine Datensouveränität.' },
{ title: 'Patriot Act + FISA 702', stat: 'Kein Schutz', desc: 'Selbst wer EU-Server bei AWS, Google oder Microsoft bucht, ist nicht geschützt. US-Gesetze wie FISA 702 und der Cloud Act gelten extraterritorial — US-Behörden können auf Daten zugreifen, egal wo der Server steht.' },
{ title: 'Regulierungs-Tsunami', stat: 'Nicht tragbar', desc: 'Seit 2024 greifen AI Act, NIS2 und Cyber Resilience Act — zusätzlich zu DSGVO, Data Act, Maschinenverordnung und Lieferkettengesetz. KMU können die Compliance-Kosten nicht mehr allein stemmen.' },
]
const EN_PROBLEM_CARDS = [
{ title: 'AI Dilemma', stat: 'Left Behind', desc: 'Manufacturing companies need AI to stay competitive. But letting US AI access their source code is out of the question for most. Those avoiding US AI fall behind. Those using it risk their data sovereignty.' },
{ title: 'Patriot Act + FISA 702', stat: 'No Protection', desc: 'Even booking EU servers at AWS, Google or Microsoft offers no protection. US laws like FISA 702 and the Cloud Act apply extraterritorially — US authorities can access data regardless of server location.' },
{ title: 'Regulation Tsunami', stat: 'Unsustainable', desc: 'Since 2024, the AI Act, NIS2 and CRA apply — on top of GDPR, Data Act, Machinery Regulation. European companies bear compliance costs that US and Asian competitors do not face.' },
]
const DE_PILLARS = [
{ title: 'Kontinuierliche Code-Security', desc: 'SAST, DAST, SBOM und Pentesting bei jeder Code-Änderung — nicht einmal im Jahr. Findings direkt als Tickets im Issue-Tracker. 15.000+ EUR/Jahr Pentest-Kosten gespart.' },
{ title: 'Compliance auf Autopilot', desc: 'VVT, TOMs, DSFA, Löschfristen, CE-Risikobeurteilung automatisch. Nach dem Audit: Abweichungen End-to-End — Rollen, Stichtage, Tickets, Nachweise, Eskalation bis zur GF.' },
{ title: 'Deutsche Cloud, volle Integration', desc: 'BSI-zertifizierte Cloud in Deutschland. Live-Support via Jitsi und Matrix. Keine US-SaaS im Source Code. Optional Mac Mini/Studio für maximale Privacy.' },
]
const EN_PILLARS = [
{ title: 'Continuous Code Security', desc: 'SAST, DAST, SBOM and pentesting on every code change — not once a year. Findings as tickets in your issue tracker. EUR 15,000+ per year in pentest costs saved.' },
{ title: 'Compliance on Autopilot', desc: 'RoPA, TOMs, DPIA, retention policies, CE risk assessment generated automatically. Post-audit deviations end-to-end: roles, deadlines, tickets, evidence, escalation to management.' },
{ title: 'German Cloud, Full Integration', desc: 'BSI-certified cloud in Germany. Live support via Jitsi and Matrix. No US SaaS in source code. Optional Mac Mini/Studio for maximum privacy.' },
]
function fmtEur(n: number) { return n.toLocaleString('de-DE', { maximumFractionDigits: 0 }) + ' EUR' }
interface SlideBase { lang: Language; pageNum: number; totalPages: number; versionName: string }
export function PrintCoverPage({ company, funding, versionName, lang }: { company: PitchCompany; funding: PitchFunding; lang: Language; versionName: string }) {
const de = lang === 'de'
const instrument = funding?.instrument || 'Pre-Seed'
return (
<div style={{ width: '297mm', height: '210mm', backgroundColor: '#ffffff', display: 'flex', flexDirection: 'column', fontFamily: 'system-ui, -apple-system, sans-serif', boxSizing: 'border-box', margin: '0 auto 32px', boxShadow: '0 4px 24px rgba(0,0,0,0.12)', pageBreakAfter: 'always', breakAfter: 'page', overflow: 'hidden' }}>
{/* Big indigo header */}
<div style={{ height: '72px', background: 'linear-gradient(135deg, #4f46e5, #7c3aed)', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact', display: 'flex', alignItems: 'center', padding: '0 28px' }}>
<span style={{ color: '#fff', fontWeight: 800, fontSize: '28px', letterSpacing: '-0.01em' }}>BreakPilot</span>
<span style={{ color: 'rgba(255,255,255,0.55)', fontWeight: 400, fontSize: '14px', marginLeft: '12px' }}>
{de ? 'Compliance & Code-Security' : 'Compliance & Code Security'}
</span>
</div>
{/* Main content */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '24px 28px 16px' }}>
<p style={{ fontSize: '11px', color: COLORS.indigo, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '8px' }}>
{instrument} · {versionName}
</p>
<h1 style={{ fontSize: '32px', fontWeight: 800, color: COLORS.dark, lineHeight: 1.15, margin: '0 0 10px' }}>
{company?.name || 'BreakPilot'}
</h1>
<p style={{ fontSize: '14px', color: COLORS.med, maxWidth: '320px', lineHeight: 1.5, margin: 0 }}>
{de ? (company?.tagline_de || 'Kontinuierliche Compliance für europäische Unternehmen.') : (company?.tagline_en || 'Continuous compliance for European companies.')}
</p>
{/* Info row */}
<div style={{ display: 'flex', gap: '32px', marginTop: '24px' }}>
{[
[de ? 'Gegründet' : 'Founded', company?.founding_date ? new Date(company.founding_date).getFullYear().toString() : 'Aug 2026'],
[de ? 'Standort' : 'HQ', company?.hq_city || 'Bodman-Ludwigshafen'],
[de ? 'Instrument' : 'Instrument', instrument],
[de ? 'Zielrunde' : 'Round', funding?.round_name || 'Pre-Seed'],
].map(([label, val]) => (
<div key={label}>
<p style={{ fontSize: '8px', color: COLORS.light, textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: '2px' }}>{label}</p>
<p style={{ fontSize: '12px', fontWeight: 700, color: COLORS.dark }}>{val}</p>
</div>
))}
</div>
</div>
{/* Footer */}
<div style={{ height: '36px', backgroundColor: '#f5f3ff', borderTop: `1px solid ${COLORS.border}`, display: 'flex', alignItems: 'center', justifyContent: 'center', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
<span style={{ fontSize: '9px', color: COLORS.indigo, fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
{de ? 'Vertraulich — Nur für Investoren' : 'Confidential — For Investor Use Only'}
</span>
</div>
</div>
)
}
export function PrintProblemPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
const de = lang === 'de'
const cards = de ? DE_PROBLEM_CARDS : EN_PROBLEM_CARDS
return (
<PrintPage title={de ? 'Das Problem' : 'The Problem'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
<SectionTitle subtitle={de ? 'Europäische Unternehmen im Dilemma' : 'European companies in a dilemma'}>
{de ? 'Das Problem' : 'The Problem'}
</SectionTitle>
<div style={{ display: 'flex', gap: '10px', marginTop: '10px' }}>
{cards.map((c) => (
<div key={c.title} style={{ flex: 1, border: `1px solid ${COLORS.border}`, borderRadius: '8px', padding: '12px', borderTop: `3px solid ${COLORS.indigo}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '8px' }}>
<span style={{ fontSize: '8px', fontWeight: 700, color: '#fff', background: COLORS.indigo, padding: '2px 7px', borderRadius: '99px', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>{c.stat}</span>
<span style={{ fontSize: '11px', fontWeight: 700, color: COLORS.dark }}>{c.title}</span>
</div>
<p style={{ fontSize: '9px', color: COLORS.med, lineHeight: 1.55, margin: 0 }}>{c.desc}</p>
</div>
))}
</div>
<div style={{ marginTop: '14px', padding: '10px 14px', background: '#f5f3ff', borderRadius: '8px', borderLeft: `3px solid ${COLORS.indigo}`, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
<p style={{ fontSize: '9px', fontStyle: 'italic', color: COLORS.med, margin: 0, lineHeight: 1.5 }}>
{de ? '„Produzierende Unternehmen brauchen eine KI-Lösung, die in Europa läuft, ihren Code schützt und Compliance automatisiert — ohne ihre Daten an US-Konzerne zu geben."' : '"Manufacturing companies need an AI solution that runs in Europe, protects their code and automates compliance — without giving their data to US corporations."'}
</p>
</div>
</PrintPage>
)
}
export function PrintSolutionPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
const de = lang === 'de'
const pillars = de ? DE_PILLARS : EN_PILLARS
const icons = ['⬡', '◎', '▦']
return (
<PrintPage title={de ? 'Die Lösung' : 'The Solution'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
<SectionTitle subtitle={de ? 'Kontinuierliche Software-Compliance statt jährlicher Stichproben' : 'Continuous software compliance instead of annual spot checks'}>
{de ? 'Die Lösung' : 'The Solution'}
</SectionTitle>
<div style={{ display: 'flex', gap: '10px', marginTop: '10px' }}>
{pillars.map((p, i) => (
<div key={p.title} style={{ flex: 1, border: `1px solid ${COLORS.border}`, borderRadius: '8px', padding: '12px' }}>
<div style={{ width: '28px', height: '28px', borderRadius: '6px', background: COLORS.indigoLight, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: '8px', fontSize: '14px', color: COLORS.indigo, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
{icons[i]}
</div>
<h3 style={{ fontSize: '11px', fontWeight: 700, color: COLORS.dark, marginBottom: '6px' }}>{p.title}</h3>
<p style={{ fontSize: '9px', color: COLORS.med, lineHeight: 1.55, margin: 0 }}>{p.desc}</p>
</div>
))}
</div>
<div style={{ marginTop: '12px', display: 'flex', gap: '8px' }}>
{(de
? ['BSI-Cloud DE', 'DSGVO-konform', 'Kein US-SaaS', 'Kontinuierlich, nicht einmal/Jahr']
: ['BSI Cloud DE', 'GDPR Compliant', 'No US SaaS', 'Continuous, not once/year']
).map(tag => (
<Badge key={tag}>{tag}</Badge>
))}
</div>
</PrintPage>
)
}
export function PrintProductPage({ products, lang, pageNum, totalPages, versionName }: SlideBase & { products: PitchProduct[] }) {
const de = lang === 'de'
const headers = [de ? 'Produkt' : 'Product', de ? 'Preis/Monat' : 'Price/Month', 'Hardware', de ? 'Key Features' : 'Key Features']
const rows = products.map(p => [
<span key="n" style={{ fontWeight: 600 }}>{p.name}{p.is_popular ? <span style={{ marginLeft: 6, fontSize: '7px', color: COLORS.indigo, fontWeight: 700 }}></span> : null}</span>,
fmtEur(p.monthly_price_eur),
p.hardware || '—',
(de ? p.features_de : p.features_en)?.slice(0, 3).join(', ') || '—',
])
return (
<PrintPage title={de ? 'Modularer Baukasten' : 'Modular Toolkit'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
<SectionTitle subtitle={de ? 'Kunden wählen die Module, die sie brauchen' : 'Customers choose the modules they need'}>
{de ? 'Produkte & Pricing' : 'Products & Pricing'}
</SectionTitle>
<div style={{ marginTop: '10px' }}>
<PrintTable headers={headers} rows={rows} colWidths={['22%', '16%', '20%', '42%']} />
</div>
<div style={{ marginTop: '12px', padding: '8px 12px', background: '#f0fdf4', borderRadius: '6px', border: '1px solid #bbf7d0' }}>
<p style={{ fontSize: '9px', color: '#166534', margin: 0 }}>
{de ? 'Kunden zahlen ~50.000 EUR/Jahr und sparen >50.000 EUR (Pentests + CE-Beurteilungen + Auditmanager). ROI ab Tag 1.' : 'Customers pay ~EUR 50,000/year and save >EUR 50,000 (pentests + CE assessments + audit managers). ROI from day 1.'}
</p>
</div>
</PrintPage>
)
}
export function PrintMarketPage({ market, lang, pageNum, totalPages, versionName }: SlideBase & { market: PitchMarket[] }) {
const de = lang === 'de'
const headers = [de ? 'Segment' : 'Segment', de ? 'Markt' : 'Label', de ? 'Volumen' : 'Volume', de ? 'Wachstum p.a.' : 'Growth p.a.', de ? 'Quelle' : 'Source']
const rows = market.map(m => [m.market_segment.toUpperCase(), m.label, fmtEur(m.value_eur), `${m.growth_rate_pct}%`, m.source])
return (
<PrintPage title={de ? 'Marktchance' : 'Market Opportunity'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
<SectionTitle subtitle={de ? 'Compliance & Code-Security für produzierende Unternehmen' : 'Compliance & Code Security for manufacturing companies'}>
{de ? 'Marktchance' : 'Market Opportunity'}
</SectionTitle>
<div style={{ marginTop: '10px' }}>
<PrintTable headers={headers} rows={rows} colWidths={['10%', '30%', '18%', '16%', '26%']} />
</div>
</PrintPage>
)
}
export function PrintTeamPage({ team, lang, pageNum, totalPages, versionName }: SlideBase & { team: PitchTeamMember[] }) {
const de = lang === 'de'
return (
<PrintPage title={de ? 'Das Team' : 'The Team'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
<SectionTitle subtitle={de ? 'Gründer mit Domain-Expertise' : 'Founders with domain expertise'}>
{de ? 'Das Team' : 'The Team'}
</SectionTitle>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '10px', marginTop: '10px' }}>
{team.slice(0, 4).map(m => (
<div key={m.id} style={{ border: `1px solid ${COLORS.border}`, borderRadius: '8px', padding: '12px', borderLeft: `3px solid ${COLORS.indigo}` }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '6px' }}>
<div>
<p style={{ fontSize: '12px', fontWeight: 700, color: COLORS.dark, margin: 0 }}>{m.name}</p>
<p style={{ fontSize: '10px', color: COLORS.indigo, margin: '2px 0 0', fontWeight: 600 }}>{de ? m.role_de : m.role_en}</p>
</div>
{m.equity_pct > 0 && <Badge>{m.equity_pct}%</Badge>}
</div>
<p style={{ fontSize: '9px', color: COLORS.med, lineHeight: 1.5, margin: 0, maxHeight: '52px', overflow: 'hidden' }}>
{de ? m.bio_de : m.bio_en}
</p>
{m.expertise?.length > 0 && (
<div style={{ marginTop: '6px', display: 'flex', flexWrap: 'wrap', gap: '3px' }}>
{m.expertise.slice(0, 4).map(e => <Badge key={e} color="#6b7280">{e}</Badge>)}
</div>
)}
</div>
))}
</div>
</PrintPage>
)
}
export function PrintMilestonesPage({ milestones, lang, pageNum, totalPages, versionName }: SlideBase & { milestones: PitchMilestone[] }) {
const de = lang === 'de'
const ordered = [...milestones].sort((a, b) => {
const order = { completed: 0, in_progress: 1, planned: 2 }
return (order[a.status] - order[b.status]) || new Date(a.milestone_date).getTime() - new Date(b.milestone_date).getTime()
}).slice(0, 12)
const statusColor = (s: string) => ({ completed: '#16a34a', in_progress: '#d97706', planned: '#94a3b8' })[s] || '#94a3b8'
const statusLabel = (s: string) => de ? ({ completed: 'Abgeschlossen', in_progress: 'In Arbeit', planned: 'Geplant' }[s] || s) : ({ completed: 'Completed', in_progress: 'In Progress', planned: 'Planned' }[s] || s)
const fmtDate = (d: string) => new Date(d).toLocaleDateString(de ? 'de-DE' : 'en-GB', { month: 'short', year: 'numeric' })
const rows = ordered.map(m => [
fmtDate(m.milestone_date),
de ? m.title_de : m.title_en,
m.category,
<span key="s" style={{ color: statusColor(m.status), fontWeight: 600 }}>{statusLabel(m.status)}</span>,
])
return (
<PrintPage title={de ? 'Meilensteine' : 'Milestones'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
<SectionTitle subtitle={de ? 'Was wir bereits erreicht haben — und was als Nächstes kommt' : 'What we have achieved — and what comes next'}>
{de ? 'Meilensteine' : 'Milestones'}
</SectionTitle>
<div style={{ marginTop: '10px' }}>
<PrintTable headers={[de ? 'Datum' : 'Date', de ? 'Meilenstein' : 'Milestone', de ? 'Kategorie' : 'Category', de ? 'Status' : 'Status']} rows={rows} colWidths={['14%', '46%', '22%', '18%']} />
</div>
</PrintPage>
)
}
export function PrintTheAskPage({ funding, lang, pageNum, totalPages, versionName }: SlideBase & { funding: PitchFunding }) {
const de = lang === 'de'
const amount = Number(funding?.amount_eur) || 0
const isWD = (funding?.instrument || '').toLowerCase() === 'wandeldarlehen'
const targetDate = funding?.target_date ? (() => { const d = new Date(funding.target_date); return `Q${Math.ceil((d.getMonth()+1)/3)} ${d.getFullYear()}` })() : 'TBD'
const uof = funding?.use_of_funds || []
return (
<PrintPage title={de ? 'The Ask' : 'The Ask'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
<SectionTitle subtitle={de ? 'Pre-Seed Finanzierung' : 'Pre-Seed Funding'}>The Ask</SectionTitle>
<div style={{ display: 'flex', gap: '24px', marginTop: '10px', alignItems: 'flex-start' }}>
<div style={{ flexShrink: 0 }}>
<p style={{ fontSize: '36px', fontWeight: 800, color: COLORS.indigo, margin: 0, lineHeight: 1.1 }}>
{amount >= 1_000_000 ? `${(amount / 1_000_000).toFixed(1)} Mio.` : amount >= 1_000 ? `${Math.round(amount / 1_000)}k` : amount.toString()}
<span style={{ fontSize: '16px', color: COLORS.light, marginLeft: '6px' }}>EUR</span>
</p>
<div style={{ marginTop: '10px', display: 'flex', flexDirection: 'column', gap: '5px' }}>
{[
[de ? 'Instrument' : 'Instrument', isWD ? 'Wandeldarlehen' : 'Equity'],
[de ? 'Runde' : 'Round', funding?.round_name || 'Pre-Seed'],
[de ? 'Zieldatum' : 'Target', targetDate],
].map(([k, v]) => (
<div key={k} style={{ display: 'flex', gap: '8px' }}>
<span style={{ fontSize: '9px', color: COLORS.light, minWidth: '70px' }}>{k}</span>
<span style={{ fontSize: '9px', fontWeight: 600, color: COLORS.dark }}>{v}</span>
</div>
))}
</div>
</div>
<div style={{ flex: 1 }}>
<p style={{ fontSize: '9px', fontWeight: 700, color: COLORS.dark, textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: '8px' }}>
{de ? 'Mittelverwendung' : 'Use of Funds'}
</p>
{uof.map(u => (
<div key={u.category} style={{ marginBottom: '5px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
<span style={{ fontSize: '9px', color: COLORS.med }}>{de ? u.label_de : u.label_en}</span>
<span style={{ fontSize: '9px', fontWeight: 700, color: COLORS.indigo }}>{u.percentage}%</span>
</div>
<div style={{ height: '6px', background: '#e0e7ff', borderRadius: '3px', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${u.percentage}%`, background: COLORS.indigo, borderRadius: '3px', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
</div>
</div>
))}
</div>
</div>
</PrintPage>
)
}
@@ -0,0 +1,142 @@
'use client'
import { useEffect } from 'react'
import { Language, PitchData, FMResult, FMAssumption } from '@/lib/types'
import { PrintCoverPage, PrintProblemPage, PrintSolutionPage, PrintProductPage, PrintMarketPage, PrintTeamPage, PrintMilestonesPage, PrintTheAskPage } from './PrintCoreSlides'
import { PrintFinancialsPage, PrintAssumptionsPage, PrintCapTablePage, PrintDisclaimerPage, aggregateAnnualRows } from './PrintFinancialSlides'
interface PrintDeckProps {
pitchData: PitchData
versionName: string
fmResults: FMResult[]
fmAssumptions: FMAssumption[]
financial: boolean
lang: Language
}
const CORE_PAGES = 9
const FINANCIAL_EXTRA = 4
export default function PrintDeck({ pitchData, versionName, fmResults, fmAssumptions, financial, lang }: PrintDeckProps) {
const isWandeldarlehen = (pitchData.funding?.instrument || '').toLowerCase() === 'wandeldarlehen'
const totalPages = financial ? CORE_PAGES + FINANCIAL_EXTRA - (isWandeldarlehen ? 1 : 0) : CORE_PAGES
const annualRows = aggregateAnnualRows(fmResults)
const de = lang === 'de'
useEffect(() => {
const t = setTimeout(() => window.print(), 900)
return () => clearTimeout(t)
}, [])
function p(n: number) {
return { lang, pageNum: n, totalPages, versionName }
}
let financialPage = CORE_PAGES + 1
return (
<>
<style>{`
@media print {
@page { size: A4 landscape; margin: 0; }
body { margin: 0; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.no-print { display: none !important; }
}
@media screen {
body { background: #e5e7eb; }
}
* { box-sizing: border-box; }
`}</style>
{/* Toolbar — hidden at print */}
<div className="no-print" style={{
position: 'sticky',
top: 0,
zIndex: 100,
background: '#1e1b4b',
color: '#fff',
padding: '10px 24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontSize: '13px',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<span style={{ fontWeight: 700 }}>BreakPilot</span>
<span style={{ opacity: 0.6, fontSize: '11px' }}>{versionName}</span>
<span style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '99px', background: financial ? '#7c3aed22' : '#6366f122', color: financial ? '#a78bfa' : '#818cf8' }}>
{financial ? (de ? 'PDF + Finanzen' : 'PDF + Financial') : (de ? 'Standard PDF' : 'Standard PDF')}
</span>
<span style={{ fontSize: '11px', opacity: 0.5 }}>{totalPages} {de ? 'Seiten' : 'pages'}</span>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button
onClick={() => window.print()}
style={{ padding: '6px 16px', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '13px' }}
>
{de ? 'Drucken / Als PDF speichern' : 'Print / Save as PDF'}
</button>
<button
onClick={() => window.close()}
style={{ padding: '6px 12px', background: 'rgba(255,255,255,0.1)', color: '#fff', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '6px', cursor: 'pointer', fontSize: '13px' }}
>
{de ? 'Schließen' : 'Close'}
</button>
</div>
</div>
{/* Print pages */}
<div style={{ padding: '24px 0' }} className="no-print-padding">
{/* Page 1: Cover */}
<PrintCoverPage company={pitchData.company} funding={pitchData.funding} versionName={versionName} lang={lang} />
{/* Page 2: Problem */}
<PrintProblemPage {...p(2)} />
{/* Page 3: Solution */}
<PrintSolutionPage {...p(3)} />
{/* Page 4: Products */}
{pitchData.products?.length > 0 && (
<PrintProductPage products={pitchData.products} {...p(4)} />
)}
{/* Page 5: Market */}
{pitchData.market?.length > 0 && (
<PrintMarketPage market={pitchData.market} {...p(5)} />
)}
{/* Page 6: Team */}
{pitchData.team?.length > 0 && (
<PrintTeamPage team={pitchData.team} {...p(6)} />
)}
{/* Page 7: Milestones */}
{pitchData.milestones?.length > 0 && (
<PrintMilestonesPage milestones={pitchData.milestones} {...p(7)} />
)}
{/* Page 8-9: The Ask (always last of core) */}
<PrintTheAskPage funding={pitchData.funding} {...p(8)} />
{/* Financial annex */}
{financial && (
<>
{annualRows.length > 0 && (
<PrintFinancialsPage annualRows={annualRows} {...p(financialPage++)} />
)}
{fmAssumptions.length > 0 && (
<PrintAssumptionsPage assumptions={fmAssumptions} {...p(financialPage++)} />
)}
{!isWandeldarlehen && (
<PrintCapTablePage {...p(financialPage++)} />
)}
<PrintDisclaimerPage {...p(financialPage)} />
</>
)}
</div>
</>
)
}
@@ -0,0 +1,277 @@
import { PrintPage, SectionTitle, PrintTable, Badge, COLORS } from './PrintLayout'
import { Language, FMResult, FMAssumption } from '@/lib/types'
interface SlideBase { lang: Language; pageNum: number; totalPages: number; versionName: string }
interface AnnualPLRow {
year: number
revenue_eur: number
cogs_eur: number
gross_profit_eur: number
personnel_eur: number
marketing_eur: number
infra_eur: number
ebitda_eur: number
total_customers: number
employees_count: number
}
export function aggregateAnnualRows(results: FMResult[]): AnnualPLRow[] {
const byYear = new Map<number, FMResult[]>()
for (const r of results) {
if (!byYear.has(r.year)) byYear.set(r.year, [])
byYear.get(r.year)!.push(r)
}
return Array.from(byYear.entries())
.sort(([a], [b]) => a - b)
.map(([year, rows]) => {
const last = rows[rows.length - 1]
const revenue = rows.reduce((s, r) => s + r.revenue_eur, 0)
const cogs = rows.reduce((s, r) => s + r.cogs_eur, 0)
const personnel = rows.reduce((s, r) => s + r.personnel_eur, 0)
const marketing = rows.reduce((s, r) => s + r.marketing_eur, 0)
const infra = rows.reduce((s, r) => s + r.infra_eur, 0)
const gross = revenue - cogs
const ebitda = gross - personnel - marketing - infra
return {
year,
revenue_eur: revenue,
cogs_eur: cogs,
gross_profit_eur: gross,
personnel_eur: personnel,
marketing_eur: marketing,
infra_eur: infra,
ebitda_eur: ebitda,
total_customers: last?.total_customers ?? 0,
employees_count: last?.employees_count ?? 0,
}
})
}
function fmtEur(n: number) {
const abs = Math.abs(n)
if (abs >= 1_000_000) return `${(n / 1_000_000).toLocaleString('de-DE', { maximumFractionDigits: 1 })}M`
if (abs >= 1_000) return `${(n / 1_000).toLocaleString('de-DE', { maximumFractionDigits: 0 })}k`
return n.toLocaleString('de-DE', { maximumFractionDigits: 0 })
}
function colorEur(n: number) { return n >= 0 ? '#16a34a' : '#dc2626' }
export function PrintFinancialsPage({ annualRows, lang, pageNum, totalPages, versionName }: SlideBase & { annualRows: AnnualPLRow[] }) {
const de = lang === 'de'
const headers = [
de ? 'Jahr' : 'Year',
de ? 'Umsatz' : 'Revenue',
de ? 'Rohertrag' : 'Gross Profit',
de ? 'Personal' : 'Personnel',
de ? 'Marketing' : 'Marketing',
'Infra',
'EBITDA',
de ? 'Kunden' : 'Customers',
de ? 'MA' : 'FTE',
]
const rows = annualRows.map(r => [
<strong key="y">{r.year}</strong>,
<span key="rev" style={{ color: COLORS.dark }}>{fmtEur(r.revenue_eur)}</span>,
fmtEur(r.gross_profit_eur),
`(${fmtEur(r.personnel_eur)})`,
`(${fmtEur(r.marketing_eur)})`,
`(${fmtEur(r.infra_eur)})`,
<span key="ebitda" style={{ fontWeight: 700, color: colorEur(r.ebitda_eur) }}>{fmtEur(r.ebitda_eur)}</span>,
r.total_customers.toString(),
r.employees_count.toString(),
])
const finalYear = annualRows[annualRows.length - 1]
const breakEvenYear = annualRows.find(r => r.ebitda_eur > 0)?.year
return (
<PrintPage title={de ? 'Finanzprognose' : 'Financial Projections'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
<SectionTitle subtitle={de ? 'AI-First Kostenstruktur — skaliert ohne lineares Personalwachstum' : 'AI-First cost structure — scales without linear headcount growth'}>
{de ? 'Finanzprognose (Planzahlen)' : 'Financial Projections (Plan)'}
</SectionTitle>
<div style={{ marginTop: '10px' }}>
<PrintTable headers={headers} rows={rows} colWidths={['8%', '12%', '12%', '11%', '11%', '9%', '12%', '11%', '6%']} />
</div>
{(finalYear || breakEvenYear) && (
<div style={{ marginTop: '12px', display: 'flex', gap: '12px' }}>
{finalYear && <div style={{ padding: '6px 12px', background: COLORS.indigoLight, borderRadius: '6px', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }}>
<p style={{ fontSize: '8px', color: COLORS.light, margin: '0 0 2px' }}>{de ? 'ARR (letztes Jahr)' : 'ARR (final year)'}</p>
<p style={{ fontSize: '12px', fontWeight: 700, color: COLORS.indigo, margin: 0 }}>{fmtEur(finalYear.revenue_eur)}</p>
</div>}
{finalYear && <div style={{ padding: '6px 12px', background: '#f0fdf4', borderRadius: '6px' }}>
<p style={{ fontSize: '8px', color: COLORS.light, margin: '0 0 2px' }}>{de ? 'Kunden (letztes Jahr)' : 'Customers (final year)'}</p>
<p style={{ fontSize: '12px', fontWeight: 700, color: '#16a34a', margin: 0 }}>{finalYear.total_customers}</p>
</div>}
{breakEvenYear && <div style={{ padding: '6px 12px', background: '#fefce8', borderRadius: '6px' }}>
<p style={{ fontSize: '8px', color: COLORS.light, margin: '0 0 2px' }}>{de ? 'Break-Even' : 'Break-Even'}</p>
<p style={{ fontSize: '12px', fontWeight: 700, color: '#854d0e', margin: 0 }}>{breakEvenYear}</p>
</div>}
</div>
)}
<p style={{ fontSize: '8px', color: COLORS.light, marginTop: '8px' }}>
{de ? '* Planzahlen · Szenario: Base Case · In Klammern = Kosten' : '* Projections · Scenario: Base Case · Parentheses = costs'}
</p>
</PrintPage>
)
}
export function PrintAssumptionsPage({ assumptions, lang, pageNum, totalPages, versionName }: SlideBase & { assumptions: FMAssumption[] }) {
const de = lang === 'de'
const scalars = assumptions
.filter(a => a.value_type === 'scalar')
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
.slice(0, 28)
const byCategory = scalars.reduce<Record<string, FMAssumption[]>>((acc, a) => {
const cat = a.category || 'General'
if (!acc[cat]) acc[cat] = []
acc[cat].push(a)
return acc
}, {})
return (
<PrintPage title={de ? 'Annahmen' : 'Assumptions'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
<SectionTitle subtitle={de ? 'Base Case Szenario — Skalare Annahmen' : 'Base Case Scenario — Scalar Assumptions'}>
{de ? 'Finanzielle Annahmen' : 'Financial Assumptions'}
</SectionTitle>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginTop: '10px' }}>
{Object.entries(byCategory).slice(0, 4).map(([cat, items]) => (
<div key={cat}>
<p style={{ fontSize: '8px', fontWeight: 700, color: COLORS.indigo, textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: '4px', borderBottom: `1px solid ${COLORS.border}`, paddingBottom: '3px' }}>
{cat}
</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '8px' }}>
<tbody>
{items.map(a => (
<tr key={a.key}>
<td style={{ padding: '3px 0', color: COLORS.med, paddingRight: '8px' }}>{de ? a.label_de : a.label_en}</td>
<td style={{ padding: '3px 0', fontWeight: 600, color: COLORS.dark, textAlign: 'right', whiteSpace: 'nowrap' }}>
{typeof a.value === 'number' ? a.value.toLocaleString('de-DE') : String(a.value)}
{a.unit && <span style={{ color: COLORS.light, marginLeft: '2px', fontWeight: 400 }}>{a.unit}</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
</PrintPage>
)
}
const CAP_TABLE_DATA = [
{ name: 'Benjamin Bönisch (CEO)', pct: 37.3, color: '#6366f1' },
{ name: 'Sharang Parnerkar (CTO)', pct: 37.3, color: '#8b5cf6' },
{ name: 'Pre-Seed Investor', pct: 20.0, color: '#f59e0b' },
{ name: 'ESOP Pool', pct: 5.4, color: '#94a3b8' },
]
export function PrintCapTablePage({ lang, pageNum, totalPages, versionName }: SlideBase) {
const de = lang === 'de'
return (
<PrintPage title={de ? 'Cap Table' : 'Cap Table'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
<SectionTitle subtitle={de ? '4 Mio. EUR Pre-Money · 1 Mio. EUR Pre-Seed · Gründung Aug 2026' : 'EUR 4M pre-money · EUR 1M pre-seed · Founding Aug 2026'}>
{de ? 'Investition & Anteilsverteilung' : 'Investment & Share Distribution'}
</SectionTitle>
<div style={{ display: 'flex', gap: '24px', marginTop: '12px', alignItems: 'flex-start' }}>
{/* Stacked bar */}
<div style={{ flex: 1 }}>
<p style={{ fontSize: '9px', fontWeight: 700, color: COLORS.dark, marginBottom: '8px' }}>
{de ? 'Anteilsverteilung nach Pre-Seed' : 'Share Distribution Post Pre-Seed'}
</p>
<div style={{ display: 'flex', height: '20px', borderRadius: '4px', overflow: 'hidden', marginBottom: '10px' }}>
{CAP_TABLE_DATA.map(d => (
<div key={d.name} style={{ width: `${d.pct}%`, backgroundColor: d.color, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
))}
</div>
{CAP_TABLE_DATA.map(d => (
<div key={d.name} style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '5px' }}>
<div style={{ width: '10px', height: '10px', borderRadius: '2px', backgroundColor: d.color, flexShrink: 0, WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
<span style={{ fontSize: '9px', color: COLORS.med, flex: 1 }}>{d.name}</span>
<span style={{ fontSize: '10px', fontWeight: 700, color: COLORS.dark }}>{d.pct}%</span>
</div>
))}
</div>
{/* Deal terms */}
<div style={{ flexShrink: 0, minWidth: '180px' }}>
<p style={{ fontSize: '9px', fontWeight: 700, color: COLORS.dark, marginBottom: '8px' }}>
{de ? 'Konditionen' : 'Deal Terms'}
</p>
<PrintTable
headers={['', '']}
rows={[
[de ? 'Pre-Money' : 'Pre-Money', '4.000.000 EUR'],
[de ? 'Investment' : 'Investment', '1.000.000 EUR'],
[de ? 'Post-Money' : 'Post-Money', '5.000.000 EUR'],
[de ? 'Investor-Anteil' : 'Investor Share', '20 %'],
['ESOP Pool', '5,4 %'],
['INVEST-Zuschuss', '20 %'],
]}
/>
</div>
</div>
</PrintPage>
)
}
const DISCLAIMER_DE = {
heading: 'Rechtlicher Hinweis',
h1: 'Haftungsausschluss',
p1: 'Dieses Dokument wird vorgelegt von Benjamin Boenisch, wohnhaft in Bodman, Deutschland, und Sharang Parnerkar, wohnhaft in Engen, Deutschland (nachfolgend „Gründer"). Die Gründer beabsichtigen die Gründung der BreakPilot GmbH im dritten Quartal 2026. Zum Zeitpunkt der Erstellung dieses Dokuments ist die Gesellschaft weder gegründet noch im Handelsregister eingetragen.',
p2: 'Dieses Dokument stellt weder ein Angebot zum Verkauf noch eine Aufforderung zur Abgabe eines Angebots zum Erwerb von Wertpapieren dar. Es handelt sich nicht um einen Wertpapierprospekt im Sinne des VermAnlG oder der EU-Prospektverordnung.',
p3: 'Dieses Dokument enthält zukunftsgerichtete Aussagen, die auf gegenwärtigen Erwartungen und Annahmen beruhen. Sämtliche Finanzangaben sind Planzahlen und stellen keine Garantie für künftige Ergebnisse dar.',
p4: 'Eine Beteiligung an einem jungen Unternehmen ist mit erheblichen Risiken verbunden, einschließlich des Risikos eines Totalverlusts des eingesetzten Kapitals.',
h2: 'Vertraulichkeit',
p5: 'Dieses Dokument ist vertraulich und wurde ausschließlich für den namentlich eingeladenen Empfänger erstellt. Durch die Kenntnisnahme erklärt sich der Empfänger mit folgenden Bedingungen einverstanden:',
pa: '(a) Geheimhaltung — Inhalt vertraulich behandeln und nicht an Dritte weitergeben.',
pb: '(b) Zweckbindung — Ausschließlich zur Bewertung einer möglichen Beteiligung verwenden.',
pc: '(c) Geltungsdauer — Diese Vertraulichkeitsverpflichtung gilt für drei (3) Jahre ab Übermittlung. Gerichtsstand ist Konstanz, Deutschland.',
footer: 'Stand: April 2026 · Dieser Hinweis ersetzt keine Rechtsberatung.',
}
const DISCLAIMER_EN = {
heading: 'Legal Notice',
h1: 'Disclaimer',
p1: 'This document is presented by Benjamin Boenisch, residing in Bodman, Germany, and Sharang Parnerkar, residing in Engen, Germany (hereinafter "Founders"). The Founders intend to establish BreakPilot GmbH in Q3 2026. At the time of this document, the company is neither founded nor registered.',
p2: 'This document constitutes neither an offer to sell nor a solicitation of an offer to acquire securities. It is not a securities prospectus within the meaning of VermAnlG or the EU Prospectus Regulation.',
p3: 'This document contains forward-looking statements based on current expectations. All financial figures are projections and do not constitute a guarantee of future results.',
p4: 'An investment in a young company involves significant risks, including the risk of total loss of invested capital.',
h2: 'Confidentiality',
p5: 'This document is confidential and prepared exclusively for the personally invited recipient. By accessing, the recipient agrees to:',
pa: '(a) Confidentiality — Treat contents confidentially and not disclose to third parties.',
pb: '(b) Purpose limitation — Use only for evaluating a possible participation.',
pc: '(c) Duration — This obligation applies for three (3) years from transmission. Place of jurisdiction is Konstanz, Germany.',
footer: 'As of: April 2026 · This notice does not replace legal advice.',
}
export function PrintDisclaimerPage({ lang, pageNum, totalPages, versionName }: SlideBase) {
const d = lang === 'de' ? DISCLAIMER_DE : DISCLAIMER_EN
const sectionStyle = { padding: '10px 12px', border: `1px solid ${COLORS.border}`, borderRadius: '6px', marginBottom: '8px' }
const pStyle = { fontSize: '8px', color: COLORS.med, lineHeight: 1.55, margin: '4px 0 0' }
const hStyle = { fontSize: '9px', fontWeight: 700, color: COLORS.indigo, margin: 0, textTransform: 'uppercase' as const, letterSpacing: '0.05em' }
return (
<PrintPage title={d.heading} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
<SectionTitle>{d.heading}</SectionTitle>
<div style={{ marginTop: '8px' }}>
<div style={sectionStyle}>
<p style={hStyle}>{d.h1}</p>
<p style={pStyle}>{d.p1}</p>
<p style={pStyle}>{d.p2}</p>
<p style={pStyle}>{d.p3}</p>
<p style={pStyle}>{d.p4}</p>
</div>
<div style={sectionStyle}>
<p style={hStyle}>{d.h2}</p>
<p style={pStyle}>{d.p5}</p>
<p style={pStyle}>{d.pa}</p>
<p style={pStyle}>{d.pb}</p>
<p style={pStyle}>{d.pc}</p>
</div>
</div>
<p style={{ fontSize: '8px', color: COLORS.light, textAlign: 'center', marginTop: '6px' }}>{d.footer}</p>
</PrintPage>
)
}
@@ -0,0 +1,175 @@
import React from 'react'
const INDIGO = '#6366f1'
const INDIGO_LIGHT = '#eef2ff'
const TEXT_DARK = '#1e1b4b'
const TEXT_MED = '#374151'
const TEXT_LIGHT = '#6b7280'
const BORDER = '#e0e7ff'
interface PrintPageProps {
title: string
pageNum: number
totalPages: number
versionName: string
children: React.ReactNode
noPadding?: boolean
}
export function PrintPage({ title, pageNum, totalPages, versionName, children, noPadding }: PrintPageProps) {
return (
<div style={{
width: '297mm',
height: '210mm',
backgroundColor: '#ffffff',
color: TEXT_DARK,
position: 'relative',
overflow: 'hidden',
pageBreakAfter: 'always',
breakAfter: 'page',
display: 'flex',
flexDirection: 'column',
fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif',
boxSizing: 'border-box',
margin: '0 auto 32px',
boxShadow: '0 4px 24px rgba(0,0,0,0.12)',
}}>
{/* Header bar */}
<div style={{
height: '30px',
backgroundColor: INDIGO,
WebkitPrintColorAdjust: 'exact',
printColorAdjust: 'exact',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 16px',
flexShrink: 0,
}}>
<span style={{ color: '#fff', fontWeight: 700, fontSize: '11px', letterSpacing: '0.02em' }}>
BreakPilot
</span>
<span style={{ color: 'rgba(255,255,255,0.85)', fontSize: '10px' }}>{title}</span>
</div>
{/* Content area */}
<div style={{ flex: 1, overflow: 'hidden', padding: noPadding ? 0 : '14px 20px' }}>
{children}
</div>
{/* Footer bar */}
<div style={{
height: '22px',
borderTop: `1px solid ${BORDER}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 16px',
flexShrink: 0,
}}>
<span style={{ fontSize: '8px', color: TEXT_LIGHT }}>{versionName}</span>
<span style={{ fontSize: '8px', color: INDIGO, fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
CONFIDENTIAL
</span>
<span style={{ fontSize: '8px', color: TEXT_LIGHT }}>{pageNum} / {totalPages}</span>
</div>
</div>
)
}
interface SectionTitleProps {
children: React.ReactNode
subtitle?: string
}
export function SectionTitle({ children, subtitle }: SectionTitleProps) {
return (
<div style={{ marginBottom: '10px' }}>
<h2 style={{
fontSize: '17px',
fontWeight: 700,
color: TEXT_DARK,
borderLeft: `3px solid ${INDIGO}`,
paddingLeft: '10px',
margin: 0,
lineHeight: 1.3,
}}>
{children}
</h2>
{subtitle && (
<p style={{ fontSize: '10px', color: TEXT_LIGHT, marginTop: '4px', marginLeft: '13px' }}>
{subtitle}
</p>
)}
</div>
)
}
interface TableProps {
headers: string[]
rows: (string | React.ReactNode)[][]
colWidths?: string[]
}
export function PrintTable({ headers, rows, colWidths }: TableProps) {
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '9px' }}>
<thead>
<tr>
{headers.map((h, i) => (
<th key={i} style={{
backgroundColor: INDIGO_LIGHT,
color: TEXT_DARK,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.04em',
fontSize: '8px',
padding: '5px 8px',
textAlign: 'left',
width: colWidths?.[i],
WebkitPrintColorAdjust: 'exact',
printColorAdjust: 'exact',
}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri} style={{ backgroundColor: ri % 2 === 0 ? '#ffffff' : '#fafafa' }}>
{row.map((cell, ci) => (
<td key={ci} style={{
padding: '5px 8px',
color: TEXT_MED,
borderBottom: `1px solid ${BORDER}`,
verticalAlign: 'top',
lineHeight: 1.45,
}}>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
export function Badge({ children, color = INDIGO }: { children: React.ReactNode; color?: string }) {
return (
<span style={{
display: 'inline-block',
padding: '1px 7px',
borderRadius: '99px',
backgroundColor: `${color}22`,
color,
fontSize: '8px',
fontWeight: 600,
}}>
{children}
</span>
)
}
export const COLORS = { indigo: INDIGO, indigoLight: INDIGO_LIGHT, dark: TEXT_DARK, med: TEXT_MED, light: TEXT_LIGHT, border: BORDER }
@@ -0,0 +1,101 @@
import { redirect, notFound } from 'next/navigation'
import pool from '@/lib/db'
import { getAdminFromCookie } from '@/lib/admin-auth'
import {
Language, PitchData, PitchCompany, PitchTeamMember, PitchFinancial, PitchMarket,
PitchCompetitor, PitchFeature, PitchMilestone, PitchMetric, PitchFunding, PitchProduct,
FpScenarioRef, FMResult, FMAssumption,
} from '@/lib/types'
import PrintDeck from './_components/PrintDeck'
interface Ctx {
params: Promise<{ versionId: string }>
searchParams: Promise<{ financial?: string; lang?: string }>
}
export default async function PitchPrintPage({ params, searchParams }: Ctx) {
const admin = await getAdminFromCookie()
if (!admin) redirect('/pitch-admin/login')
const { versionId } = await params
const { financial: finParam, lang: langParam } = await searchParams
const financial = finParam === 'true' || finParam === '1'
const lang: Language = langParam === 'en' ? 'en' : 'de'
// Version metadata
const verRes = await pool.query(
`SELECT name FROM pitch_versions WHERE id = $1`,
[versionId],
)
if (verRes.rows.length === 0) notFound()
const versionName: string = verRes.rows[0].name
// Version data tables (snapshot)
const dataRes = await pool.query(
`SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`,
[versionId],
)
const map: Record<string, unknown[]> = {}
for (const row of dataRes.rows) {
map[row.table_name] = typeof row.data === 'string' ? JSON.parse(row.data) : row.data
}
if (Object.keys(map).length === 0) {
return (
<div style={{ padding: '40px', fontFamily: 'system-ui', color: '#374151' }}>
<h2>Version has no data</h2>
<p>Please make sure the version has been populated with pitch data before exporting.</p>
<a href={`/pitch-admin/versions/${versionId}`} style={{ color: '#6366f1' }}> Back to version</a>
</div>
)
}
const pitchData: PitchData = {
company: ((map.company || [])[0] || {}) as PitchCompany,
team: (map.team || []) as PitchTeamMember[],
financials: (map.financials || []) as PitchFinancial[],
market: (map.market || []) as PitchMarket[],
competitors: (map.competitors || []) as PitchCompetitor[],
features: (map.features || []) as PitchFeature[],
milestones: (map.milestones || []) as PitchMilestone[],
metrics: (map.metrics || []) as PitchMetric[],
funding: ((map.funding || [])[0] || {}) as PitchFunding,
products: (map.products || []) as PitchProduct[],
fp_scenarios: (map.fm_scenarios || []) as FpScenarioRef[],
}
// Financial variant: fetch FM results + parse assumptions
let fmResults: FMResult[] = []
let fmAssumptions: FMAssumption[] = []
if (financial) {
const scenarios = (map.fm_scenarios || []) as FpScenarioRef[]
const defaultScenario = scenarios.find(s => s.is_default) ?? scenarios[0] ?? null
if (defaultScenario?.id) {
const resultsRes = await pool.query(
`SELECT * FROM pitch_fm_results WHERE scenario_id = $1 ORDER BY month`,
[defaultScenario.id],
)
fmResults = resultsRes.rows as FMResult[]
}
const rawAssumptions = (map.fm_assumptions || []) as Array<Record<string, unknown>>
fmAssumptions = rawAssumptions.map(a => ({
...a,
value: typeof a.value === 'string' ? JSON.parse(a.value as string) : a.value,
})) as FMAssumption[]
}
return (
<PrintDeck
pitchData={pitchData}
versionName={versionName}
fmResults={fmResults}
fmAssumptions={fmAssumptions}
financial={financial}
lang={lang}
/>
)
}