feat(pitch): showcase mode — per-investor toggle hides financial/investor slides for customer demos
Build pitch-deck / build-push-deploy (push) Successful in 1m35s
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 39s
CI / test-python-voice (push) Successful in 32s
CI / test-bqas (push) Successful in 30s

Adds is_showcase boolean to pitch_investors; when set, filters out financials,
the ask, cap table, assumptions, finanzplan, risks, and intro-presenter slides.
Slide navigation is fully dynamic — progress bar and counts update accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-05-04 22:41:15 +02:00
parent f2184be02f
commit 30a9165497
7 changed files with 74 additions and 22 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]
)
@@ -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 */}
+10 -2
View File
@@ -2,7 +2,8 @@
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'
@@ -68,15 +69,22 @@ 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
// 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)
// Skip cap-table slide for Wandeldarlehen versions
useEffect(() => {
if (nav.currentSlide === 'cap-table' && isWandeldarlehen) {
+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() {
+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,
}
}
+12
View File
@@ -1,5 +1,17 @@
import { SlideId } from './types'
// Slides hidden in showcase (customer) mode — financial and investor-specific content
export const SHOWCASE_HIDDEN_SLIDES = new Set<SlideId>([
'intro-presenter',
'executive-summary',
'financials',
'the-ask',
'cap-table',
'annex-assumptions',
'annex-finanzplan',
'risks',
])
export const SLIDE_ORDER: SlideId[] = [
'intro-presenter',
'executive-summary',