feat: add pitch-deck service to core infrastructure
Migrated pitch-deck from breakpilot-pwa to breakpilot-core. Container: bp-core-pitch-deck on port 3012. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
109
pitch-deck/lib/hooks/useFinancialModel.ts
Normal file
109
pitch-deck/lib/hooks/useFinancialModel.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { FMScenario, FMResult, FMComputeResponse } from '../types'
|
||||
|
||||
export function useFinancialModel() {
|
||||
const [scenarios, setScenarios] = useState<FMScenario[]>([])
|
||||
const [activeScenarioId, setActiveScenarioId] = useState<string | null>(null)
|
||||
const [compareMode, setCompareMode] = useState(false)
|
||||
const [results, setResults] = useState<Map<string, FMComputeResponse>>(new Map())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [computing, setComputing] = useState(false)
|
||||
const computeTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Load scenarios on mount
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch('/api/financial-model')
|
||||
if (res.ok) {
|
||||
const data: FMScenario[] = await res.json()
|
||||
setScenarios(data)
|
||||
const defaultScenario = data.find(s => s.is_default) || data[0]
|
||||
if (defaultScenario) {
|
||||
setActiveScenarioId(defaultScenario.id)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load financial model:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
// Compute when active scenario changes
|
||||
useEffect(() => {
|
||||
if (activeScenarioId && !results.has(activeScenarioId)) {
|
||||
compute(activeScenarioId)
|
||||
}
|
||||
}, [activeScenarioId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const compute = useCallback(async (scenarioId: string) => {
|
||||
setComputing(true)
|
||||
try {
|
||||
const res = await fetch('/api/financial-model/compute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scenarioId }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data: FMComputeResponse = await res.json()
|
||||
setResults(prev => new Map(prev).set(scenarioId, data))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Compute failed:', err)
|
||||
} finally {
|
||||
setComputing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateAssumption = useCallback(async (scenarioId: string, key: string, value: number | number[]) => {
|
||||
// Optimistic update in local state
|
||||
setScenarios(prev => prev.map(s => {
|
||||
if (s.id !== scenarioId) return s
|
||||
return {
|
||||
...s,
|
||||
assumptions: s.assumptions.map(a => a.key === key ? { ...a, value } : a),
|
||||
}
|
||||
}))
|
||||
|
||||
// Save to DB
|
||||
await fetch('/api/financial-model/assumptions', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scenarioId, key, value }),
|
||||
})
|
||||
|
||||
// Debounced recompute
|
||||
if (computeTimer.current) clearTimeout(computeTimer.current)
|
||||
computeTimer.current = setTimeout(() => compute(scenarioId), 300)
|
||||
}, [compute])
|
||||
|
||||
const computeAll = useCallback(async () => {
|
||||
for (const s of scenarios) {
|
||||
await compute(s.id)
|
||||
}
|
||||
}, [scenarios, compute])
|
||||
|
||||
const activeScenario = scenarios.find(s => s.id === activeScenarioId) || null
|
||||
const activeResults = activeScenarioId ? results.get(activeScenarioId) || null : null
|
||||
|
||||
return {
|
||||
scenarios,
|
||||
activeScenario,
|
||||
activeScenarioId,
|
||||
setActiveScenarioId,
|
||||
activeResults,
|
||||
results,
|
||||
loading,
|
||||
computing,
|
||||
compareMode,
|
||||
setCompareMode,
|
||||
compute,
|
||||
computeAll,
|
||||
updateAssumption,
|
||||
}
|
||||
}
|
||||
98
pitch-deck/lib/hooks/useKeyboard.ts
Normal file
98
pitch-deck/lib/hooks/useKeyboard.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useCallback } from 'react'
|
||||
|
||||
interface UseKeyboardProps {
|
||||
onNext: () => void
|
||||
onPrev: () => void
|
||||
onFirst: () => void
|
||||
onLast: () => void
|
||||
onOverview: () => void
|
||||
onFullscreen: () => void
|
||||
onLanguageToggle: () => void
|
||||
onMenuToggle: () => void
|
||||
onGoToSlide: (index: number) => void
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export function useKeyboard({
|
||||
onNext,
|
||||
onPrev,
|
||||
onFirst,
|
||||
onLast,
|
||||
onOverview,
|
||||
onFullscreen,
|
||||
onLanguageToggle,
|
||||
onMenuToggle,
|
||||
onGoToSlide,
|
||||
enabled = true,
|
||||
}: UseKeyboardProps) {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (!enabled) return
|
||||
|
||||
const target = e.target as HTMLElement
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
onNext()
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
onPrev()
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
onOverview()
|
||||
break
|
||||
case 'f':
|
||||
case 'F':
|
||||
e.preventDefault()
|
||||
onFullscreen()
|
||||
break
|
||||
case 'Home':
|
||||
e.preventDefault()
|
||||
onFirst()
|
||||
break
|
||||
case 'End':
|
||||
e.preventDefault()
|
||||
onLast()
|
||||
break
|
||||
case 'l':
|
||||
case 'L':
|
||||
e.preventDefault()
|
||||
onLanguageToggle()
|
||||
break
|
||||
case 'm':
|
||||
case 'M':
|
||||
e.preventDefault()
|
||||
onMenuToggle()
|
||||
break
|
||||
case '1':
|
||||
case '2':
|
||||
case '3':
|
||||
case '4':
|
||||
case '5':
|
||||
case '6':
|
||||
case '7':
|
||||
case '8':
|
||||
case '9':
|
||||
e.preventDefault()
|
||||
onGoToSlide(parseInt(e.key) - 1)
|
||||
break
|
||||
}
|
||||
},
|
||||
[enabled, onNext, onPrev, onFirst, onLast, onOverview, onFullscreen, onLanguageToggle, onMenuToggle, onGoToSlide]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleKeyDown])
|
||||
}
|
||||
39
pitch-deck/lib/hooks/useLanguage.ts
Normal file
39
pitch-deck/lib/hooks/useLanguage.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
|
||||
import { Language } from '../types'
|
||||
import React from 'react'
|
||||
|
||||
interface LanguageContextType {
|
||||
lang: Language
|
||||
toggleLanguage: () => void
|
||||
setLanguage: (lang: Language) => void
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextType>({
|
||||
lang: 'de',
|
||||
toggleLanguage: () => {},
|
||||
setLanguage: () => {},
|
||||
})
|
||||
|
||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [lang, setLang] = useState<Language>('de')
|
||||
|
||||
const toggleLanguage = useCallback(() => {
|
||||
setLang(prev => prev === 'de' ? 'en' : 'de')
|
||||
}, [])
|
||||
|
||||
const setLanguage = useCallback((newLang: Language) => {
|
||||
setLang(newLang)
|
||||
}, [])
|
||||
|
||||
return React.createElement(
|
||||
LanguageContext.Provider,
|
||||
{ value: { lang, toggleLanguage, setLanguage } },
|
||||
children
|
||||
)
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
return useContext(LanguageContext)
|
||||
}
|
||||
29
pitch-deck/lib/hooks/usePitchData.ts
Normal file
29
pitch-deck/lib/hooks/usePitchData.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { PitchData } from '../types'
|
||||
|
||||
export function usePitchData() {
|
||||
const [data, setData] = useState<PitchData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const res = await fetch('/api/data')
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const json = await res.json()
|
||||
setData(json)
|
||||
} catch (err) {
|
||||
console.error('Failed to load pitch data:', err)
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
return { data, loading, error }
|
||||
}
|
||||
77
pitch-deck/lib/hooks/useSlideNavigation.ts
Normal file
77
pitch-deck/lib/hooks/useSlideNavigation.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { SlideId } from '../types'
|
||||
|
||||
const SLIDE_ORDER: SlideId[] = [
|
||||
'cover',
|
||||
'problem',
|
||||
'solution',
|
||||
'product',
|
||||
'how-it-works',
|
||||
'market',
|
||||
'business-model',
|
||||
'traction',
|
||||
'competition',
|
||||
'team',
|
||||
'financials',
|
||||
'the-ask',
|
||||
'ai-qa',
|
||||
]
|
||||
|
||||
export const TOTAL_SLIDES = SLIDE_ORDER.length
|
||||
|
||||
export function useSlideNavigation() {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [direction, setDirection] = useState(0)
|
||||
const [visitedSlides, setVisitedSlides] = useState<Set<number>>(new Set([0]))
|
||||
const [showOverview, setShowOverview] = useState(false)
|
||||
|
||||
const currentSlide = SLIDE_ORDER[currentIndex]
|
||||
|
||||
const goToSlide = useCallback((index: number) => {
|
||||
if (index < 0 || index >= TOTAL_SLIDES) return
|
||||
setDirection(index > currentIndex ? 1 : -1)
|
||||
setCurrentIndex(index)
|
||||
setVisitedSlides(prev => new Set([...prev, index]))
|
||||
setShowOverview(false)
|
||||
}, [currentIndex])
|
||||
|
||||
const nextSlide = useCallback(() => {
|
||||
if (currentIndex < TOTAL_SLIDES - 1) {
|
||||
goToSlide(currentIndex + 1)
|
||||
}
|
||||
}, [currentIndex, goToSlide])
|
||||
|
||||
const prevSlide = useCallback(() => {
|
||||
if (currentIndex > 0) {
|
||||
goToSlide(currentIndex - 1)
|
||||
}
|
||||
}, [currentIndex, goToSlide])
|
||||
|
||||
const goToFirst = useCallback(() => goToSlide(0), [goToSlide])
|
||||
const goToLast = useCallback(() => goToSlide(TOTAL_SLIDES - 1), [goToSlide])
|
||||
|
||||
const toggleOverview = useCallback(() => {
|
||||
setShowOverview(prev => !prev)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
currentIndex,
|
||||
currentSlide,
|
||||
direction,
|
||||
visitedSlides,
|
||||
showOverview,
|
||||
totalSlides: TOTAL_SLIDES,
|
||||
slideOrder: SLIDE_ORDER,
|
||||
goToSlide,
|
||||
nextSlide,
|
||||
prevSlide,
|
||||
goToFirst,
|
||||
goToLast,
|
||||
toggleOverview,
|
||||
setShowOverview,
|
||||
isFirst: currentIndex === 0,
|
||||
isLast: currentIndex === TOTAL_SLIDES - 1,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user