diff --git a/pitch-deck/app/api/admin/investors/[id]/route.ts b/pitch-deck/app/api/admin/investors/[id]/route.ts index 15ef3cf..343b489 100644 --- a/pitch-deck/app/api/admin/investors/[id]/route.ts +++ b/pitch-deck/app/api/admin/investors/[id]/route.ts @@ -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 diff --git a/pitch-deck/app/api/auth/me/route.ts b/pitch-deck/app/api/auth/me/route.ts index 5d053d8..55dd2ec 100644 --- a/pitch-deck/app/api/auth/me/route.ts +++ b/pitch-deck/app/api/auth/me/route.ts @@ -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] ) diff --git a/pitch-deck/app/api/chat/route.ts b/pitch-deck/app/api/chat/route.ts index f274842..8203456 100644 --- a/pitch-deck/app/api/chat/route.ts +++ b/pitch-deck/app/api/chat/route.ts @@ -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 { try { const { rows } = await pool.query( @@ -199,7 +212,7 @@ function extractMeta( } } -async function loadPitchContext(versionId?: string | null): Promise { +async function loadPitchContext(versionId?: string | null, isShowcase = false): Promise { try { // Version-specific data path if (versionId) { @@ -225,11 +238,11 @@ async function loadPitchContext(versionId?: string | null): Promise 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(', ')} ` } diff --git a/pitch-deck/app/api/data/route.ts b/pitch-deck/app/api/data/route.ts index cffd15a..0be22e3 100644 --- a/pitch-deck/app/api/data/route.ts +++ b/pitch-deck/app/api/data/route.ts @@ -39,6 +39,7 @@ export async function GET() { metrics: map.metrics || [], funding: (map.funding || [])[0] || null, products: map.products || [], + fp_scenarios: map.fm_scenarios || [], }) } diff --git a/pitch-deck/app/api/finanzplan/[sheetName]/route.ts b/pitch-deck/app/api/finanzplan/[sheetName]/route.ts index 6893af9..b9d6ad0 100644 --- a/pitch-deck/app/api/finanzplan/[sheetName]/route.ts +++ b/pitch-deck/app/api/finanzplan/[sheetName]/route.ts @@ -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}` diff --git a/pitch-deck/app/api/finanzplan/route.ts b/pitch-deck/app/api/finanzplan/route.ts index f975aae..49ba781 100644 --- a/pitch-deck/app/api/finanzplan/route.ts +++ b/pitch-deck/app/api/finanzplan/route.ts @@ -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 diff --git a/pitch-deck/app/api/preview-data/[versionId]/route.ts b/pitch-deck/app/api/preview-data/[versionId]/route.ts index 4d2d361..d71bdeb 100644 --- a/pitch-deck/app/api/preview-data/[versionId]/route.ts +++ b/pitch-deck/app/api/preview-data/[versionId]/route.ts @@ -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 }, }) diff --git a/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx b/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx index 385c33f..640bfb8 100644 --- a/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx +++ b/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx @@ -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 */}

Pitch Version

-
+
{inv.assigned_version_id - ? `Investor sees version "${inv.version_name || ''}"` - : 'Investor sees default pitch data'} + ? `Sees version "${inv.version_name || ''}"` + : 'Sees default pitch data'}
+ + {/* Showcase toggle */} +
+
+
Showcase mode
+
Hides financials, The Ask, and investor-only slides — for customer demos
+
+ +
{/* Audit log for this investor */} diff --git a/pitch-deck/components/NavigationFAB.tsx b/pitch-deck/components/NavigationFAB.tsx index 38357fd..06fad47 100644 --- a/pitch-deck/components/NavigationFAB.tsx +++ b/pitch-deck/components/NavigationFAB.tsx @@ -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 */} -
+
{i.nav.slides}
{/* Slide List */} -
- {i.slideNames.map((name, idx) => { +
+ {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({
{/* Footer */} -
+
{/* Language Toggle */} + {/* AI Presenter */} + {onPresenterStart && ( + + )} + {/* Fullscreen */}