Merge branch 'main' of ssh://gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-core
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 47s
CI / test-python-voice (push) Successful in 38s
CI / test-bqas (push) Successful in 37s

This commit is contained in:
Benjamin Admin
2026-05-06 21:06:19 +02:00
21 changed files with 264 additions and 120 deletions
@@ -19,7 +19,7 @@ export async function GET(request: NextRequest, ctx: RouteContext) {
pool.query(
`SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count,
i.created_at, i.updated_at, i.first_activity_at, i.data_masked_at,
i.assigned_version_id,
i.assigned_version_id, i.is_showcase,
v.name AS version_name, v.status AS version_status
FROM pitch_investors i
LEFT JOIN pitch_versions v ON v.id = i.assigned_version_id
@@ -68,14 +68,14 @@ export async function PATCH(request: NextRequest, ctx: RouteContext) {
const { id } = await ctx.params
const body = await request.json().catch(() => ({}))
const { name, company, assigned_version_id } = body
const { name, company, assigned_version_id, is_showcase } = body
if (name === undefined && company === undefined && assigned_version_id === undefined) {
return NextResponse.json({ error: 'name, company, or assigned_version_id required' }, { status: 400 })
if (name === undefined && company === undefined && assigned_version_id === undefined && is_showcase === undefined) {
return NextResponse.json({ error: 'name, company, assigned_version_id, or is_showcase required' }, { status: 400 })
}
const before = await pool.query(
`SELECT name, company, assigned_version_id FROM pitch_investors WHERE id = $1`,
`SELECT name, company, assigned_version_id, is_showcase FROM pitch_investors WHERE id = $1`,
[id],
)
if (before.rows.length === 0) {
@@ -99,15 +99,18 @@ export async function PATCH(request: NextRequest, ctx: RouteContext) {
// Use null to clear version assignment, undefined to leave unchanged
const versionValue = assigned_version_id === undefined ? before.rows[0].assigned_version_id : (assigned_version_id || null)
const showcaseValue = is_showcase !== undefined ? Boolean(is_showcase) : before.rows[0].is_showcase
const { rows } = await pool.query(
`UPDATE pitch_investors SET
name = COALESCE($1, name),
company = COALESCE($2, company),
assigned_version_id = $4,
is_showcase = $5,
updated_at = NOW()
WHERE id = $3
RETURNING id, email, name, company, status, assigned_version_id`,
[name ?? null, company ?? null, id, versionValue],
RETURNING id, email, name, company, status, assigned_version_id, is_showcase`,
[name ?? null, company ?? null, id, versionValue, showcaseValue],
)
const action = assigned_version_id !== undefined && assigned_version_id !== before.rows[0].assigned_version_id
+1 -1
View File
@@ -14,7 +14,7 @@ export async function GET() {
}
const { rows } = await pool.query(
`SELECT id, email, name, company, status, last_login_at, login_count, created_at
`SELECT id, email, name, company, status, last_login_at, login_count, created_at, is_showcase
FROM pitch_investors WHERE id = $1`,
[session.sub]
)
+66 -35
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { getSessionFromCookie } from '@/lib/auth'
import { SLIDE_ORDER } from '@/lib/slide-order'
import { SLIDE_ORDER, SHOWCASE_HIDDEN_SLIDES } from '@/lib/slide-order'
const LITELLM_URL = process.env.LITELLM_URL || 'https://llm-dev.meghsakha.com'
const LITELLM_MODEL = process.env.LITELLM_MODEL || 'gpt-oss-120b'
@@ -115,6 +115,19 @@ KONKRETES BEISPIEL einer vollständigen Antwort:
WICHTIG: Vergiss NIEMALS die Folgefragen! Sie sind PFLICHT.`
// Prepended to system content for showcase (customer demo) sessions
const SHOWCASE_GUARD = `## MODUS: PRODUKT-DEMO (KEIN INVESTOREN-PITCH)
Du bist im Showcase-Modus. Du beantwortest Fragen potenzieller Kunden über das Produkt — NICHT über Investitionsdetails.
ABSOLUTES VERBOT — niemals erwähnen oder beantworten:
- Finanzierungssumme, Investitionsbedarf, The Ask, Bewertung, Equity, Cap Table
- Finanzprognosen, Umsatzziele, Cashflow, Liquidität, Burn Rate
- Wandeldarlehen, SAFE, Gesellschafteranteile oder Kapitalstruktur
- Irgendwelche Zahlen aus dem Finanzplan oder Finanzierungsrunden
Wenn danach gefragt wird: "Für geschäftliche Details wenden Sie sich gerne direkt an unser Team."
Fokus: Produkt-Features, Compliance-Module, technische Architektur, Kundennutzen, Markt, Team.`
async function loadFpLiquiditaetSummary(scenarioName: string): Promise<string> {
try {
const { rows } = await pool.query(
@@ -199,7 +212,7 @@ function extractMeta(
}
}
async function loadPitchContext(versionId?: string | null): Promise<PitchContextResult> {
async function loadPitchContext(versionId?: string | null, isShowcase = false): Promise<PitchContextResult> {
try {
// Version-specific data path
if (versionId) {
@@ -225,11 +238,11 @@ async function loadPitchContext(versionId?: string | null): Promise<PitchContext
const versionName = vNameRes.rows[0]?.name ?? ''
const meta = extractMeta(versionName, fmScenarios, funding, financials)
const fpSummary = meta.scenarioName ? await loadFpLiquiditaetSummary(meta.scenarioName) : ''
const fpSummary = (!isShowcase && meta.scenarioName) ? await loadFpLiquiditaetSummary(meta.scenarioName) : ''
return {
contextString: buildContextString(company, team, financials, market, products, funding, features, fpSummary),
meta,
contextString: buildContextString(company, team, financials, market, products, funding, features, fpSummary, isShowcase),
meta: isShowcase ? { ...DEFAULT_META, versionName } : meta,
}
}
@@ -249,9 +262,9 @@ async function loadPitchContext(versionId?: string | null): Promise<PitchContext
return {
contextString: buildContextString(
company.rows[0], team.rows, financials.rows, market.rows,
products.rows, funding.rows[0], features.rows, ''
products.rows, funding.rows[0], features.rows, '', isShowcase
),
meta,
meta: isShowcase ? DEFAULT_META : meta,
}
} finally {
client.release()
@@ -264,8 +277,19 @@ async function loadPitchContext(versionId?: string | null): Promise<PitchContext
function buildContextString(
company: unknown, team: unknown, financials: unknown, market: unknown,
products: unknown, funding: unknown, features: unknown, fpSummary: string
products: unknown, funding: unknown, features: unknown, fpSummary: string,
omitFinancials = false
): string {
const finSection = omitFinancials ? '' : `
### Finanzprognosen (5-Jahres-Plan)
${JSON.stringify(financials, null, 2)}
`
const fundSection = omitFinancials ? '' : `
### Finanzierung
${JSON.stringify(funding, null, 2)}
`
const fpSection = omitFinancials ? '' : (fpSummary ? '\n' + fpSummary : '')
return `
## Unternehmensdaten (für präzise Antworten nutzen)
@@ -274,22 +298,16 @@ ${JSON.stringify(company, null, 2)}
### Team
${JSON.stringify(team, null, 2)}
### Finanzprognosen (5-Jahres-Plan)
${JSON.stringify(financials, null, 2)}
${finSection}
### Markt (TAM/SAM/SOM)
${JSON.stringify(market, null, 2)}
### Produkte
${JSON.stringify(products, null, 2)}
### Finanzierung
${JSON.stringify(funding, null, 2)}
${fundSection}
### Differenzierende Features (nur bei ComplAI)
${JSON.stringify(features, null, 2)}
${fpSummary ? '\n' + fpSummary : ''}
${fpSection}
`
}
@@ -303,22 +321,24 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
}
// Resolve investor's assigned version so the AI sees the correct scenario data
// Resolve investor's assigned version and showcase flag
let versionId: string | null = null
let isShowcase = false
try {
const session = await getSessionFromCookie()
if (session?.sub) {
const inv = await pool.query(
`SELECT assigned_version_id FROM pitch_investors WHERE id = $1`,
`SELECT assigned_version_id, is_showcase FROM pitch_investors WHERE id = $1`,
[session.sub]
)
versionId = inv.rows[0]?.assigned_version_id ?? null
isShowcase = inv.rows[0]?.is_showcase === true
}
} catch {
// Non-fatal: fall back to base tables
}
const { contextString, meta } = await loadPitchContext(versionId)
const { contextString, meta } = await loadPitchContext(versionId, isShowcase)
// Build dynamic VERSIONS-ISOLATION and Kernbotschaft #9 from actual version data
const fmt = (n: number) => n.toLocaleString('de-DE')
@@ -340,40 +360,51 @@ export async function POST(request: NextRequest) {
const dynamicFinanzplanKernbotschaft = `9. Finanzplan: "Gründung August 2026. Pre-Seed über ${fundingStr}. ${customersStr} und ${revM} Umsatz bis 2030. ${employeesStr}."`
let systemContent = SYSTEM_PROMPT_PART1
+ '\n' + dynamicFinanzplanKernbotschaft
+ SYSTEM_PROMPT_PART2
+ '\n\n' + dynamicVersionIsolation
+ SYSTEM_PROMPT_PART3
let systemContent: string
if (isShowcase) {
// Showcase: product-only context, no financial details anywhere
systemContent = SHOWCASE_GUARD
+ '\n\n' + SYSTEM_PROMPT_PART1
+ SYSTEM_PROMPT_PART2
+ SYSTEM_PROMPT_PART3
} else {
systemContent = SYSTEM_PROMPT_PART1
+ '\n' + dynamicFinanzplanKernbotschaft
+ SYSTEM_PROMPT_PART2
+ '\n\n' + dynamicVersionIsolation
+ SYSTEM_PROMPT_PART3
}
if (contextString) {
systemContent += '\n' + contextString
}
// FAQ context: relevant pre-researched answers as basis for the LLM
// IMPORTANT: FAQ entries contain hardcoded numbers written for specific scenarios.
// They are hints only — the version-specific Unternehmensdaten above always take precedence.
if (faqContext && typeof faqContext === 'string') {
// FAQ context — skip for showcase (may contain financial hints)
if (!isShowcase && faqContext && typeof faqContext === 'string') {
systemContent += '\n' + faqContext
systemContent += '\n\n## Versions-Datenvorrang (ABSOLUT VERBINDLICH)\nWenn die vorrecherchierten Antworten oben Zahlen, Beträge oder Details nennen, die von den "Unternehmensdaten" oder dem "Finanzplan-Liquidität" weiter oben abweichen, haben die Unternehmensdaten IMMER Vorrang. Die FAQ-Antworten sind allgemein formuliert und könnten veraltete oder szenario-fremde Zahlen enthalten. Nutze sie nur für Struktur und Formulierung — die konkreten Zahlen kommen ausschließlich aus den Unternehmensdaten dieses Investors.'
}
// Slide context for contextual awareness
// Slide context for contextual awareness — filter hidden slides for showcase
const visibleSlideOrder = isShowcase
? SLIDE_ORDER.filter(id => !SHOWCASE_HIDDEN_SLIDES.has(id))
: SLIDE_ORDER
if (slideContext) {
const visited: number[] = slideContext.visitedSlides || []
const currentSlideId = slideContext.currentSlide
const currentSlideName = SLIDE_DISPLAY_NAMES[currentSlideId]?.[lang] || currentSlideId
const notYetSeen = SLIDE_ORDER
const notYetSeen = visibleSlideOrder
.map((id, idx) => ({ id, idx, name: SLIDE_DISPLAY_NAMES[id]?.[lang] || id }))
.filter(s => !visited.includes(s.idx))
.map(s => `${s.idx + 1}. ${s.name}`)
systemContent += `\n\n## Slide-Kontext (WICHTIG für kontextuelle Antworten)
- Aktuelle Slide: "${currentSlideName}" (Nr. ${slideContext.currentIndex + 1} von ${slideCount})
- Bereits besuchte Slides: ${visited.map((i: number) => SLIDE_DISPLAY_NAMES[SLIDE_ORDER[i]]?.[lang] || SLIDE_ORDER[i]).filter(Boolean).join(', ')}
- Aktuelle Slide: "${currentSlideName}" (Nr. ${slideContext.currentIndex + 1} von ${visibleSlideOrder.length})
- Bereits besuchte Slides: ${visited.map((i: number) => SLIDE_DISPLAY_NAMES[visibleSlideOrder[i]]?.[lang] || visibleSlideOrder[i]).filter(Boolean).join(', ')}
- Noch nicht gesehene Slides: ${notYetSeen.join(', ')}
- Ist Erstbesuch: ${visited.length <= 1 ? 'JA — Investor hat gerade erst den Pitch geöffnet' : 'Nein'}
- Verfügbare Slide-IDs für [GOTO:id]: ${SLIDE_ORDER.join(', ')}
- Ist Erstbesuch: ${visited.length <= 1 ? 'JA — Besucher hat gerade erst die Präsentation geöffnet' : 'Nein'}
- Verfügbare Slide-IDs für [GOTO:id]: ${visibleSlideOrder.join(', ')}
`
}
+1
View File
@@ -39,6 +39,7 @@ export async function GET() {
metrics: map.metrics || [],
funding: (map.funding || [])[0] || null,
products: map.products || [],
fp_scenarios: map.fm_scenarios || [],
})
}
@@ -48,9 +48,7 @@ export async function GET(
return NextResponse.json({ error: `Unknown sheet: ${sheetName}` }, { status: 400 })
}
// Only admin callers may query an arbitrary scenarioId; investors always see the default
const isAdmin = validateAdminSecret(request)
const scenarioId = isAdmin ? request.nextUrl.searchParams.get('scenarioId') : null
const scenarioId = request.nextUrl.searchParams.get('scenarioId')
try {
let query = `SELECT * FROM ${table}`
+13 -5
View File
@@ -3,25 +3,33 @@ import pool from '@/lib/db'
import { SHEET_LIST } from '@/lib/finanzplan/types'
export async function GET(request: NextRequest) {
// Only expose scenario list to admin callers (bearer token)
// Only expose full scenario list to admin callers (bearer token)
const secret = process.env.PITCH_ADMIN_SECRET
const auth = request.headers.get('authorization') ?? ''
const isAdmin = secret && auth === `Bearer ${secret}`
// Allow callers to pass a scenarioId for row counts (e.g. investor's assigned scenario)
const scenarioId = request.nextUrl.searchParams.get('scenarioId')
try {
// Investors see only the default scenario — no names of other scenarios leaked
const scenarios = isAdmin
? await pool.query('SELECT * FROM fp_scenarios ORDER BY is_default DESC, name')
: await pool.query('SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1')
// Get row counts per sheet
// Get row counts per sheet using the caller's scenario
const sheets = await Promise.all(
SHEET_LIST.map(async (s) => {
const tableName = `fp_${s.name}`
try {
const { rows } = await pool.query(
`SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE is_editable = true) as editable FROM ${tableName} WHERE scenario_id = (SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1)`
)
const { rows } = scenarioId
? await pool.query(
`SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE is_editable = true) as editable FROM ${tableName} WHERE scenario_id = $1`,
[scenarioId]
)
: await pool.query(
`SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE is_editable = true) as editable FROM ${tableName} WHERE scenario_id = (SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1)`
)
return { ...s, rows: parseInt(rows[0]?.total || '0'), editable_rows: parseInt(rows[0]?.editable || '0') }
} catch {
return s
@@ -49,7 +49,7 @@ export async function GET(request: NextRequest, ctx: Ctx) {
metrics: map.metrics || [],
funding: (map.funding || [])[0] || null,
products: map.products || [],
fm_scenarios: map.fm_scenarios || [],
fp_scenarios: map.fm_scenarios || [],
fm_assumptions: map.fm_assumptions || [],
_version: { name: ver.rows[0].name, status: ver.rows[0].status },
})
@@ -21,6 +21,7 @@ interface InvestorDetail {
assigned_version_id: string | null
version_name: string | null
version_status: string | null
is_showcase: boolean
}
sessions: Array<{
id: string
@@ -293,7 +294,7 @@ export default function InvestorDetailPage() {
{/* Version assignment */}
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
<h2 className="text-sm font-semibold text-white mb-3">Pitch Version</h2>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 flex-wrap">
<select
value={inv.assigned_version_id || ''}
onChange={async (e) => {
@@ -318,10 +319,35 @@ export default function InvestorDetailPage() {
</select>
<span className="text-xs text-white/40">
{inv.assigned_version_id
? `Investor sees version "${inv.version_name || ''}"`
: 'Investor sees default pitch data'}
? `Sees version "${inv.version_name || ''}"`
: 'Sees default pitch data'}
</span>
</div>
{/* Showcase toggle */}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-white/[0.06]">
<div>
<div className="text-sm text-white font-medium">Showcase mode</div>
<div className="text-xs text-white/40 mt-0.5">Hides financials, The Ask, and investor-only slides for customer demos</div>
</div>
<button
onClick={async () => {
setBusy(true)
const res = await fetch(`/api/admin/investors/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_showcase: !inv.is_showcase }),
})
setBusy(false)
if (res.ok) { flashToast(inv.is_showcase ? 'Switched to investor mode' : 'Switched to showcase mode'); load() }
else { flashToast('Update failed') }
}}
disabled={busy}
className={`relative w-11 h-6 rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:ring-indigo-500 ${inv.is_showcase ? 'bg-indigo-500' : 'bg-white/10'}`}
>
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform duration-200 ${inv.is_showcase ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
</div>
</section>
{/* Audit log for this investor */}
+22 -5
View File
@@ -13,6 +13,8 @@ interface NavigationFABProps {
onGoToSlide: (index: number) => void
lang: Language
onToggleLanguage: () => void
slideNames?: string[]
onPresenterStart?: () => void
}
export default function NavigationFAB({
@@ -22,6 +24,8 @@ export default function NavigationFAB({
onGoToSlide,
lang,
onToggleLanguage,
slideNames: slideNamesProp,
onPresenterStart,
}: NavigationFABProps) {
const [isOpen, setIsOpen] = useState(false)
const [isFullscreen, setIsFullscreen] = useState(false)
@@ -35,6 +39,7 @@ export default function NavigationFAB({
})
}, [])
const i = t(lang)
const activeSlideNames = slideNamesProp ?? i.slideNames
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
@@ -71,12 +76,12 @@ export default function NavigationFAB({
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ duration: 0.2 }}
className="w-[300px] max-h-[80vh] rounded-2xl overflow-hidden
className="w-[300px] max-h-[80vh] flex flex-col rounded-2xl overflow-hidden
bg-black/80 backdrop-blur-xl border border-white/10
shadow-2xl shadow-black/50"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b border-white/10">
<span className="text-sm font-semibold text-white">{i.nav.slides}</span>
<button
onClick={() => setIsOpen(false)}
@@ -88,8 +93,8 @@ export default function NavigationFAB({
</div>
{/* Slide List */}
<div className="overflow-y-auto max-h-[55vh] py-2">
{i.slideNames.map((name, idx) => {
<div className="flex-1 overflow-y-auto py-2">
{activeSlideNames.map((name, idx) => {
const isActive = idx === currentIndex
const isVisited = visitedSlides.has(idx)
const isAI = idx === totalSlides - 1
@@ -129,7 +134,7 @@ export default function NavigationFAB({
</div>
{/* Footer */}
<div className="border-t border-white/10 px-4 py-3 space-y-2">
<div className="shrink-0 border-t border-white/10 px-4 py-3 space-y-2">
{/* Language Toggle */}
<button
onClick={onToggleLanguage}
@@ -164,6 +169,18 @@ export default function NavigationFAB({
</div>
</button>
{/* AI Presenter */}
{onPresenterStart && (
<button
onClick={() => { onPresenterStart(); setIsOpen(false) }}
className="w-full flex items-center justify-between px-3 py-2 rounded-lg
bg-indigo-500/10 hover:bg-indigo-500/20 transition-colors text-sm"
>
<span className="text-indigo-300">{lang === 'de' ? 'KI-Präsentation starten' : 'Start AI Presenter'}</span>
<Bot className="w-4 h-4 text-indigo-400" />
</button>
)}
{/* Fullscreen */}
<button
onClick={toggleFullscreen}
+32 -16
View File
@@ -2,12 +2,14 @@
import { useCallback, useEffect, useState } from 'react'
import { AnimatePresence } from 'framer-motion'
import { useSlideNavigation } from '@/lib/hooks/useSlideNavigation'
import { useSlideNavigation, SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
import { SHOWCASE_HIDDEN_SLIDES } from '@/lib/slide-order'
import { useKeyboard } from '@/lib/hooks/useKeyboard'
import { usePitchData } from '@/lib/hooks/usePitchData'
import { usePresenterMode } from '@/lib/hooks/usePresenterMode'
import { useAuditTracker } from '@/lib/hooks/useAuditTracker'
import { Language, PitchData } from '@/lib/types'
import { t } from '@/lib/i18n'
import { Investor } from '@/lib/hooks/useAuth'
import Link from 'next/link'
@@ -68,18 +70,28 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
const data = previewData || fetched.data
const loading = previewData ? false : fetched.loading
const error = previewData ? null : fetched.error
const nav = useSlideNavigation()
const [fabOpen, setFabOpen] = useState(false)
const isWandeldarlehen = (data?.funding?.instrument || '').toLowerCase() === 'wandeldarlehen'
const isShowcase = investor?.is_showcase === true
// For version previews: use the version's default FM scenario instead of base table default
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fmScenarios = (previewData as any)?.fm_scenarios as Array<{ id: string; is_default?: boolean }> | undefined
const preferredScenarioId = fmScenarios?.[0]?.is_default
? fmScenarios[0].id
: fmScenarios?.length === 1
? fmScenarios[0].id
: null
// Derive fp_scenario IDs from version snapshot (fm_scenarios stores fp_scenario IDs directly)
const fpScenarios = data?.fp_scenarios || []
const fpBaseScenarioId = fpScenarios.find(s => s.is_default)?.id ?? fpScenarios[0]?.id ?? null
const preferredScenarioId = fpBaseScenarioId
// Showcase mode: filter out investor/financial slides
const activeSlideOrder = isShowcase
? SLIDE_ORDER.filter(s => !SHOWCASE_HIDDEN_SLIDES.has(s))
: SLIDE_ORDER
const nav = useSlideNavigation(activeSlideOrder)
// Map active slide IDs → localized names for sidebar/overview
const i18n = t(lang)
const activeSlideNames = activeSlideOrder.map(id => {
const idx = SLIDE_ORDER.indexOf(id)
return idx >= 0 ? i18n.slideNames[idx] : id
})
// Skip cap-table slide for Wandeldarlehen versions
useEffect(() => {
@@ -93,6 +105,7 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
currentSlide: nav.currentIndex,
totalSlides: nav.totalSlides,
language: lang,
slideOrder: activeSlideOrder,
})
// Audit tracking
@@ -163,7 +176,7 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
/>
)
case 'executive-summary':
return <ExecutiveSummarySlide lang={lang} data={data} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} />
return <ExecutiveSummarySlide lang={lang} data={data} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} fpBaseScenarioId={fpBaseScenarioId} />
case 'cover':
return <CoverSlide lang={lang} onNext={nav.nextSlide} funding={data.funding} />
case 'problem':
@@ -189,7 +202,7 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
case 'team':
return <TeamSlide lang={lang} team={data.team} />
case 'financials':
return <FinancialsSlide lang={lang} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} />
return <FinancialsSlide lang={lang} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} fpBaseScenarioId={fpBaseScenarioId} />
case 'the-ask':
return <TheAskSlide lang={lang} funding={data.funding} isWandeldarlehen={isWandeldarlehen} />
case 'cap-table':
@@ -200,7 +213,7 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
case 'ai-qa':
return <AIQASlide lang={lang} />
case 'annex-assumptions':
return <AssumptionsSlide lang={lang} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} />
return <AssumptionsSlide lang={lang} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} fpScenarios={fpScenarios} />
case 'annex-architecture':
return <ArchitectureSlide lang={lang} />
case 'annex-gtm':
@@ -216,7 +229,7 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
case 'annex-strategy':
return <StrategySlide lang={lang} isWandeldarlehen={isWandeldarlehen} />
case 'annex-finanzplan':
return <FinanzplanSlide lang={lang} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} />
return <FinanzplanSlide lang={lang} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} fpBaseScenarioId={fpBaseScenarioId} fpScenarios={fpScenarios} />
case 'annex-glossary':
return <GlossarySlide lang={lang} />
case 'risks':
@@ -239,8 +252,8 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
{/* Investor watermark */}
{investor && <Watermark text={investor.email} />}
{/* Data Room link — only for real investor sessions, not preview */}
{investor && !previewData && (
{/* Data Room link — only for real investor sessions, not preview, not showcase */}
{investor && !previewData && !isShowcase && (
<Link
href="/dataroom"
className="fixed top-4 right-4 z-40 flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-white/[0.06] border border-white/[0.08] text-white/50 hover:text-white/80 hover:bg-white/[0.1] backdrop-blur-sm transition-all text-xs"
@@ -280,6 +293,8 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
onGoToSlide={nav.goToSlide}
lang={lang}
onToggleLanguage={onToggleLanguage}
slideNames={activeSlideNames}
onPresenterStart={isShowcase ? presenter.start : undefined}
/>
{/* Presenter UI */}
@@ -305,6 +320,7 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
onGoToSlide={nav.goToSlide}
onClose={() => nav.setShowOverview(false)}
lang={lang}
slideNames={activeSlideNames}
/>
)}
</AnimatePresence>
+4 -2
View File
@@ -9,10 +9,12 @@ interface SlideOverviewProps {
onGoToSlide: (index: number) => void
onClose: () => void
lang: Language
slideNames?: string[]
}
export default function SlideOverview({ currentIndex, onGoToSlide, onClose, lang }: SlideOverviewProps) {
export default function SlideOverview({ currentIndex, onGoToSlide, onClose, lang, slideNames: slideNamesProp }: SlideOverviewProps) {
const i = t(lang)
const activeSlideNames = slideNamesProp ?? i.slideNames
return (
<motion.div
@@ -28,7 +30,7 @@ export default function SlideOverview({ currentIndex, onGoToSlide, onClose, lang
className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 max-w-5xl w-full"
onClick={(e) => e.stopPropagation()}
>
{i.slideNames.map((name, idx) => (
{activeSlideNames.map((name, idx) => (
<motion.button
key={idx}
initial={{ opacity: 0, y: 20 }}
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { Language } from '@/lib/types'
import { Language, FpScenarioRef } from '@/lib/types'
import { t } from '@/lib/i18n'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
@@ -13,6 +13,7 @@ interface AssumptionsSlideProps {
investorId?: string | null
preferredScenarioId?: string | null
isWandeldarlehen?: boolean
fpScenarios?: FpScenarioRef[]
}
interface SheetRow {
@@ -72,7 +73,7 @@ async function loadScenarioKPIs(scenarioId: string | null): Promise<ScenarioKPIs
}
}
export default function AssumptionsSlide({ lang, isWandeldarlehen }: AssumptionsSlideProps) {
export default function AssumptionsSlide({ lang, isWandeldarlehen, fpScenarios }: AssumptionsSlideProps) {
const i = t(lang)
const de = lang === 'de'
@@ -80,14 +81,19 @@ export default function AssumptionsSlide({ lang, isWandeldarlehen }: Assumptions
useEffect(() => {
async function load() {
const baseId = isWandeldarlehen ? 'c0000000-0000-0000-0000-000000000200' : null
const bearId = isWandeldarlehen ? 'd0000000-0000-0000-0000-000000000201' : 'd0000000-0000-0000-0000-000000000301'
const bullId = isWandeldarlehen ? 'd0000000-0000-0000-0000-000000000202' : 'd0000000-0000-0000-0000-000000000302'
const scenarios = fpScenarios || []
const find = (role: 'bear' | 'bull' | 'base') => {
if (role === 'base') return scenarios.find(s => s.is_default)?.id ?? null
return scenarios.find(s => s.name.toLowerCase().includes(role))?.id ?? null
}
const baseId = find('base')
const bearId = find('bear')
const bullId = find('bull')
const [bear, base, bull] = await Promise.all([loadScenarioKPIs(bearId), loadScenarioKPIs(baseId), loadScenarioKPIs(bullId)])
setScenarioData({ bear, base, bull })
}
load()
}, [isWandeldarlehen])
}, [fpScenarios])
const bear = scenarioData?.bear || { arr: 0, customers: 0, headcount: 0, cash: 0, breakEvenYear: '—' }
const base = scenarioData?.base || { arr: 0, customers: 0, headcount: 0, cash: 0, breakEvenYear: '—' }
@@ -16,15 +16,16 @@ interface ExecutiveSummarySlideProps {
investorId?: string | null
preferredScenarioId?: string | null
isWandeldarlehen?: boolean
fpBaseScenarioId?: string | null
}
export default function ExecutiveSummarySlide({ lang, data, investorId, preferredScenarioId, isWandeldarlehen }: ExecutiveSummarySlideProps) {
export default function ExecutiveSummarySlide({ lang, data, investorId, preferredScenarioId, isWandeldarlehen, fpBaseScenarioId }: ExecutiveSummarySlideProps) {
const i = t(lang)
const es = i.executiveSummary
const de = lang === 'de'
// Unternehmensentwicklung from fp_* tables (source of truth)
const { kpis: fpKPIs } = useFpKPIs(isWandeldarlehen)
const { kpis: fpKPIs } = useFpKPIs(fpBaseScenarioId)
// Pipeline stats from DB
const [pipelineStats, setPipelineStats] = useState<Record<string, { value: number }>>({})
@@ -25,9 +25,10 @@ interface FinancialsSlideProps {
investorId: string | null
preferredScenarioId?: string | null
isWandeldarlehen?: boolean
fpBaseScenarioId?: string | null
}
export default function FinancialsSlide({ lang, investorId, preferredScenarioId, isWandeldarlehen }: FinancialsSlideProps) {
export default function FinancialsSlide({ lang, investorId, preferredScenarioId, isWandeldarlehen, fpBaseScenarioId }: FinancialsSlideProps) {
const i = t(lang)
const fm = useFinancialModel(investorId, preferredScenarioId)
const [activeTab, setActiveTab] = useState<FinTab>('overview')
@@ -38,7 +39,7 @@ export default function FinancialsSlide({ lang, investorId, preferredScenarioId,
const lastResult = activeResults?.results[activeResults.results.length - 1]
// KPI cards from fp_* tables (source of truth)
const { last: fpLast, kpis: fpKPIs } = useFpKPIs(isWandeldarlehen)
const { last: fpLast, kpis: fpKPIs } = useFpKPIs(fpBaseScenarioId)
const kpiArr = fpLast?.arr || summary?.final_arr || 0
const kpiCustomers = fpLast?.customers || summary?.final_customers || 0
const kpiEbit = fpKPIs?.y2029?.ebit // First profitable year
@@ -1,7 +1,7 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Language } from '@/lib/types'
import { Language, FpScenarioRef } from '@/lib/types'
import ProjectionFooter from '../ui/ProjectionFooter'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
@@ -20,14 +20,16 @@ interface FinanzplanSlideProps {
investorId?: string | null
preferredScenarioId?: string | null
isWandeldarlehen?: boolean
fpBaseScenarioId?: string | null
fpScenarios?: FpScenarioRef[]
}
export default function FinanzplanSlide({ lang, investorId, preferredScenarioId, isWandeldarlehen }: FinanzplanSlideProps) {
export default function FinanzplanSlide({ lang, investorId, preferredScenarioId, isWandeldarlehen, fpBaseScenarioId, fpScenarios }: FinanzplanSlideProps) {
const [sheets, setSheets] = useState<SheetMeta[]>([])
const [scenarios, setScenarios] = useState<FpScenario[]>([])
const [openCats, setOpenCats] = useState<Set<string>>(new Set())
const toggleCat = (cat: string) => setOpenCats(prev => { const n = new Set(prev); n.has(cat) ? n.delete(cat) : n.add(cat); return n })
const [selectedScenarioId, setSelectedScenarioId] = useState<string>('')
const [selectedScenarioId, setSelectedScenarioId] = useState<string>(fpBaseScenarioId ?? '')
const [activeSheet, setActiveSheet] = useState<string>('guv')
const [rows, setRows] = useState<SheetRow[]>([])
const [loading, setLoading] = useState(false)
@@ -96,18 +98,20 @@ export default function FinanzplanSlide({ lang, investorId, preferredScenarioId,
loadKPIs()
}, [selectedScenarioId])
// Load sheet list + scenarios
// Load sheet list; populate scenario selector from version data or API fallback
useEffect(() => {
fetch('/api/finanzplan', { cache: 'no-store' })
const listParam = fpBaseScenarioId ? `?scenarioId=${fpBaseScenarioId}` : ''
fetch(`/api/finanzplan${listParam}`, { cache: 'no-store' })
.then(r => r.json())
.then(data => {
setSheets(data.sheets || [])
const scens: FpScenario[] = data.scenarios || []
// Use version fp_scenarios if available, else fall back to API list
const scens: FpScenario[] = fpScenarios && fpScenarios.length > 0
? fpScenarios.map(s => ({ id: s.id, name: s.name, is_default: s.is_default ?? false, color: s.color ?? '#6366f1', description: s.description ?? '' }))
: data.scenarios || []
setScenarios(scens)
// Pick scenario: Wandeldarlehen version → WD scenario, otherwise default
if (!selectedScenarioId) {
const wdScenario = isWandeldarlehen ? scens.find(s => s.name.toLowerCase().includes('wandeldarlehen') && !s.name.toLowerCase().includes('bear') && !s.name.toLowerCase().includes('bull')) : null
const def = wdScenario ?? scens.find(s => s.is_default) ?? scens[0]
const def = scens.find(s => s.is_default) ?? scens[0]
if (def) setSelectedScenarioId(def.id)
}
})
+1
View File
@@ -11,6 +11,7 @@ export interface Investor {
last_login_at: string | null
login_count: number
created_at: string
is_showcase: boolean
}
export function useAuth() {
+5 -5
View File
@@ -31,14 +31,14 @@ interface SheetRow {
* Loads annual KPIs directly from fp_* tables (source of truth).
* Returns a map of year keys (y2026-y2030) to KPI objects.
*/
export function useFpKPIs(isWandeldarlehen?: boolean) {
export function useFpKPIs(fpBaseScenarioId?: string | null) {
const [kpis, setKpis] = useState<Record<string, FpAnnualKPIs>>({})
const [loading, setLoading] = useState(true)
useEffect(() => {
async function load() {
try {
const param = isWandeldarlehen ? '?scenarioId=c0000000-0000-0000-0000-000000000200' : ''
const param = fpBaseScenarioId ? `?scenarioId=${fpBaseScenarioId}` : ''
const [guvRes, liqRes, persRes, kundenRes] = await Promise.all([
fetch(`/api/finanzplan/guv${param}`, { cache: 'no-store' }),
fetch(`/api/finanzplan/liquiditaet${param}`, { cache: 'no-store' }),
@@ -102,7 +102,7 @@ export function useFpKPIs(isWandeldarlehen?: boolean) {
setLoading(false)
}
load()
}, [isWandeldarlehen])
}, [fpBaseScenarioId])
// Use of Funds: compute spending breakdown m8-m24 (funding period)
const [useOfFunds, setUseOfFunds] = useState<Array<{ category: string; label_de: string; label_en: string; percentage: number }>>([])
@@ -110,7 +110,7 @@ export function useFpKPIs(isWandeldarlehen?: boolean) {
useEffect(() => {
async function loadUoF() {
try {
const param = isWandeldarlehen ? '?scenarioId=c0000000-0000-0000-0000-000000000200' : ''
const param = fpBaseScenarioId ? `?scenarioId=${fpBaseScenarioId}` : ''
const [persRes, betriebRes, investRes] = await Promise.all([
fetch(`/api/finanzplan/personalkosten${param}`, { cache: 'no-store' }),
fetch(`/api/finanzplan/betriebliche${param}`, { cache: 'no-store' }),
@@ -168,7 +168,7 @@ export function useFpKPIs(isWandeldarlehen?: boolean) {
} catch { /* ignore */ }
}
loadUoF()
}, [isWandeldarlehen])
}, [fpBaseScenarioId])
const last = kpis.y2030
return { kpis, loading, last, useOfFunds }
+17 -9
View File
@@ -5,6 +5,7 @@ import { Language } from '../types'
import { PresenterState, SlideScript } from '../presenter/types'
import { PRESENTER_SCRIPT } from '../presenter/presenter-script'
import { SLIDE_ORDER } from './useSlideNavigation'
import { SlideId } from '../types'
interface UsePresenterModeConfig {
goToSlide: (index: number) => void
@@ -12,6 +13,7 @@ interface UsePresenterModeConfig {
totalSlides: number
language: Language
ttsEnabled?: boolean
slideOrder?: SlideId[]
}
interface UsePresenterModeReturn {
@@ -57,8 +59,8 @@ interface SlideAudioPlan {
segments: AudioSegment[]
}
function buildSlideAudioPlan(slideIdx: number, lang: Language): SlideAudioPlan | null {
const slideId = SLIDE_ORDER[slideIdx]
function buildSlideAudioPlan(slideIdx: number, lang: Language, activeSlideOrder: SlideId[]): SlideAudioPlan | null {
const slideId = activeSlideOrder[slideIdx]
const script = PRESENTER_SCRIPT.find(s => s.slideId === slideId)
if (!script || script.paragraphs.length === 0) return null
@@ -121,7 +123,9 @@ export function usePresenterMode({
totalSlides,
language,
ttsEnabled: initialTtsEnabled = true,
slideOrder: slideOrderProp,
}: UsePresenterModeConfig): UsePresenterModeReturn {
const activeSlideOrder = slideOrderProp ?? SLIDE_ORDER
const [state, setState] = useState<PresenterState>('idle')
const [currentParagraph, setCurrentParagraph] = useState(0)
const [displayText, setDisplayText] = useState('')
@@ -193,7 +197,7 @@ export function usePresenterMode({
playSlideRef.current = async (slideIdx: number) => {
if (stateRef.current !== 'presenting') return
const plan = buildSlideAudioPlan(slideIdx, language)
const plan = buildSlideAudioPlan(slideIdx, language, activeSlideOrder)
if (!plan) {
// No script for this slide — skip to next
if (slideIdx < totalSlides - 1) {
@@ -216,7 +220,7 @@ export function usePresenterMode({
// Pre-fetch next slide's audio in background
if (slideIdx < totalSlides - 1) {
const nextPlan = buildSlideAudioPlan(slideIdx + 1, language)
const nextPlan = buildSlideAudioPlan(slideIdx + 1, language, activeSlideOrder)
if (nextPlan) fetchAudio(nextPlan.fullText, language).catch(() => {})
}
@@ -329,7 +333,7 @@ export function usePresenterMode({
setIsSpeaking(false)
}
}
}, [language, totalSlides, goToSlide, ttsAvailable, ttsEnabled])
}, [language, totalSlides, goToSlide, ttsAvailable, ttsEnabled, activeSlideOrder])
const start = useCallback(() => {
unlockAudio()
@@ -410,14 +414,18 @@ export function usePresenterMode({
}
}, [unlockAudio, start, stop])
// Calculate overall progress
// Calculate overall progress against the active slide order's scripts
const progress = (() => {
if (state === 'idle') return 0
const totalScripts = PRESENTER_SCRIPT.length
const currentScriptIdx = PRESENTER_SCRIPT.findIndex(s => s.slideId === SLIDE_ORDER[currentSlide])
const currentSlideId = activeSlideOrder[currentSlide]
const activeScripts = activeSlideOrder
.map(id => PRESENTER_SCRIPT.find(s => s.slideId === id))
.filter(Boolean) as typeof PRESENTER_SCRIPT
const totalScripts = activeScripts.length || 1
const currentScriptIdx = activeScripts.findIndex(s => s.slideId === currentSlideId)
if (currentScriptIdx < 0) return (currentSlide / totalSlides) * 100
const script = PRESENTER_SCRIPT[currentScriptIdx]
const script = activeScripts[currentScriptIdx]
const slideProgress = script.paragraphs.length > 0
? currentParagraph / script.paragraphs.length
: 0
+11 -9
View File
@@ -1,21 +1,23 @@
'use client'
import { useState, useCallback } from 'react'
import { SlideId } from '../types'
import { SLIDE_ORDER, TOTAL_SLIDES } from '../slide-order'
// Re-export for backwards compatibility
export { SLIDE_ORDER, TOTAL_SLIDES }
export function useSlideNavigation() {
export function useSlideNavigation(slideOrder: SlideId[] = SLIDE_ORDER) {
const total = slideOrder.length
const [currentIndex, setCurrentIndex] = useState(0)
const [direction, setDirection] = useState(0)
const [visitedSlides, setVisitedSlides] = useState<Set<number>>(new Set([0]))
const [showOverview, setShowOverview] = useState(false)
const currentSlide = SLIDE_ORDER[currentIndex]
const currentSlide = slideOrder[currentIndex]
const goToSlide = useCallback((index: number) => {
if (index < 0 || index >= TOTAL_SLIDES) return
if (index < 0 || index >= total) return
setDirection(index > currentIndex ? 1 : -1)
setCurrentIndex(index)
setVisitedSlides(prev => new Set([...prev, index]))
@@ -23,10 +25,10 @@ export function useSlideNavigation() {
}, [currentIndex])
const nextSlide = useCallback(() => {
if (currentIndex < TOTAL_SLIDES - 1) {
if (currentIndex < total - 1) {
goToSlide(currentIndex + 1)
}
}, [currentIndex, goToSlide])
}, [currentIndex, goToSlide, total])
const prevSlide = useCallback(() => {
if (currentIndex > 0) {
@@ -35,7 +37,7 @@ export function useSlideNavigation() {
}, [currentIndex, goToSlide])
const goToFirst = useCallback(() => goToSlide(0), [goToSlide])
const goToLast = useCallback(() => goToSlide(TOTAL_SLIDES - 1), [goToSlide])
const goToLast = useCallback(() => goToSlide(total - 1), [goToSlide, total])
const toggleOverview = useCallback(() => {
setShowOverview(prev => !prev)
@@ -47,8 +49,8 @@ export function useSlideNavigation() {
direction,
visitedSlides,
showOverview,
totalSlides: TOTAL_SLIDES,
slideOrder: SLIDE_ORDER,
totalSlides: total,
slideOrder,
goToSlide,
nextSlide,
prevSlide,
@@ -57,6 +59,6 @@ export function useSlideNavigation() {
toggleOverview,
setShowOverview,
isFirst: currentIndex === 0,
isLast: currentIndex === TOTAL_SLIDES - 1,
isLast: currentIndex === total - 1,
}
}
+10
View File
@@ -1,5 +1,15 @@
import { SlideId } from './types'
// Slides hidden in showcase (customer) mode — financial and investor-specific content
export const SHOWCASE_HIDDEN_SLIDES = new Set<SlideId>([
'financials',
'the-ask',
'cap-table',
'annex-assumptions',
'annex-finanzplan',
'risks',
])
export const SLIDE_ORDER: SlideId[] = [
'intro-presenter',
'executive-summary',
+9
View File
@@ -114,6 +114,14 @@ export interface PitchProduct {
operating_cost_eur: number
}
export interface FpScenarioRef {
id: string
name: string
is_default?: boolean
color?: string
description?: string
}
export interface PitchData {
company: PitchCompany
team: PitchTeamMember[]
@@ -125,6 +133,7 @@ export interface PitchData {
metrics: PitchMetric[]
funding: PitchFunding
products: PitchProduct[]
fp_scenarios?: FpScenarioRef[]
}
// Financial Model Types