feat(pitch-admin): structured form editors, bilingual fields, version preview
Some checks failed
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 32s
CI / test-python-voice (push) Successful in 32s
CI / test-bqas (push) Successful in 32s
CI / Deploy (push) Failing after 4s
Build pitch-deck / build-and-push (push) Failing after 59s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped

Replaces raw JSON textarea in version editor with proper form UIs:

- Company: single-record form with side-by-side DE/EN tagline + mission
- Team: expandable card list with bilingual role/bio, expertise tags
- Financials: year-by-year table with numeric inputs
- Market: TAM/SAM/SOM row table
- Competitors: card list with strengths/weaknesses tag arrays
- Features: card list with DE/EN names + checkbox matrix
- Milestones: card list with DE/EN title/description + status dropdown
- Metrics: card list with DE/EN labels
- Funding: form + nested use_of_funds table
- Products: card list with DE/EN capabilities + feature tag arrays
- FM Scenarios: card list with color picker
- FM Assumptions: row table

Shared editor primitives (components/pitch-admin/editors/):
  BilingualField, FormField, ArrayField, RowTable, CardList

"Edit as JSON" toggle preserved as escape hatch on every tab.

Preview: admin clicks "Preview" on version editor → opens
/pitch-preview/[versionId] in new tab showing the full pitch deck
with that version's data. Admin-cookie gated (no investor auth).
Yellow "PREVIEW MODE" banner at top.

Also fixes the [object Object] inline table type cast in FM editor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-10 10:34:42 +02:00
parent edadf39445
commit ea752088f6
11 changed files with 915 additions and 92 deletions

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { getAdminFromCookie } from '@/lib/admin-auth'
interface Ctx { params: Promise<{ versionId: string }> }
export async function GET(request: NextRequest, ctx: Ctx) {
// Admin-only: verify admin session
const admin = await getAdminFromCookie()
if (!admin) {
return NextResponse.json({ error: 'Admin access required for preview' }, { status: 401 })
}
const { versionId } = await ctx.params
// Load version data
const { rows } = await pool.query(
`SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`,
[versionId],
)
if (rows.length === 0) {
return NextResponse.json({ error: 'Version not found or has no data' }, { status: 404 })
}
const map: Record<string, unknown[]> = {}
for (const row of rows) {
map[row.table_name] = typeof row.data === 'string' ? JSON.parse(row.data) : row.data
}
// Return PitchData format
return NextResponse.json({
company: (map.company || [])[0] || null,
team: map.team || [],
financials: map.financials || [],
market: map.market || [],
competitors: map.competitors || [],
features: map.features || [],
milestones: map.milestones || [],
metrics: map.metrics || [],
funding: (map.funding || [])[0] || null,
products: map.products || [],
})
}

View File

@@ -132,7 +132,7 @@ export default function EditScenarioPage() {
const isObjectArray = Array.isArray(a.value) && a.value.length > 0 && typeof a.value[0] === 'object' && a.value[0] !== null const isObjectArray = Array.isArray(a.value) && a.value.length > 0 && typeof a.value[0] === 'object' && a.value[0] !== null
if (isObjectArray) { if (isObjectArray) {
const rows = isEdited ? (JSON.parse(edits[a.id]) as Record<string, unknown>[]) : (a.value as Record<string, unknown>[]) const rows = isEdited ? (JSON.parse(edits[a.id]) as Record<string, unknown>[]) : (a.value as unknown as Record<string, unknown>[])
const cols = Object.keys(rows[0] || {}) const cols = Object.keys(rows[0] || {})
return ( return (

View File

@@ -3,34 +3,28 @@
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 } from 'lucide-react' import { ArrowLeft, Lock, Save, GitFork, Eye, Code } from 'lucide-react'
import BilingualField from '@/components/pitch-admin/editors/BilingualField'
import FormField from '@/components/pitch-admin/editors/FormField'
import ArrayField from '@/components/pitch-admin/editors/ArrayField'
import RowTable from '@/components/pitch-admin/editors/RowTable'
import CardList from '@/components/pitch-admin/editors/CardList'
const TABLE_LABELS: Record<string, string> = { const TABLE_LABELS: Record<string, string> = {
company: 'Company', company: 'Company', team: 'Team', financials: 'Financials', market: 'Market',
team: 'Team', competitors: 'Competitors', features: 'Features', milestones: 'Milestones',
financials: 'Financials', metrics: 'Metrics', funding: 'Funding', products: 'Products',
market: 'Market', fm_scenarios: 'FM Scenarios', fm_assumptions: 'FM Assumptions',
competitors: 'Competitors',
features: 'Features',
milestones: 'Milestones',
metrics: 'Metrics',
funding: 'Funding',
products: 'Products',
fm_scenarios: 'FM Scenarios',
fm_assumptions: 'FM Assumptions',
} }
const TABLE_NAMES = Object.keys(TABLE_LABELS) const TABLE_NAMES = Object.keys(TABLE_LABELS)
interface Version { interface Version {
id: string id: string; name: string; description: string | null
name: string status: 'draft' | 'committed'; parent_id: string | null; committed_at: string | null
description: string | null
status: 'draft' | 'committed'
parent_id: string | null
committed_at: string | null
} }
type R = Record<string, unknown>
export default function VersionEditorPage() { export default function VersionEditorPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const router = useRouter() const router = useRouter()
@@ -38,9 +32,10 @@ export default function VersionEditorPage() {
const [allData, setAllData] = useState<Record<string, unknown[]>>({}) const [allData, setAllData] = useState<Record<string, unknown[]>>({})
const [activeTab, setActiveTab] = useState('company') const [activeTab, setActiveTab] = useState('company')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [editorValue, setEditorValue] = useState('')
const [dirty, setDirty] = useState(false) const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [jsonMode, setJsonMode] = useState(false)
const [jsonText, setJsonText] = useState('')
const [toast, setToast] = useState<string | null>(null) const [toast, setToast] = useState<string | null>(null)
function flashToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3000) } function flashToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3000) }
@@ -48,53 +43,55 @@ export default function VersionEditorPage() {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true) setLoading(true)
const res = await fetch(`/api/admin/versions/${id}`) const res = await fetch(`/api/admin/versions/${id}`)
if (res.ok) { if (res.ok) { const d = await res.json(); setVersion(d.version); setAllData(d.data) }
const d = await res.json()
setVersion(d.version)
setAllData(d.data)
}
setLoading(false) setLoading(false)
}, [id]) }, [id])
useEffect(() => { if (id) load() }, [id, load]) useEffect(() => { if (id) load() }, [id, load])
// When tab changes, set editor value // Sync JSON text when switching tabs or toggling JSON mode
useEffect(() => { useEffect(() => {
const data = allData[activeTab] if (jsonMode) setJsonText(JSON.stringify(allData[activeTab] || [], null, 2))
if (data !== undefined) { }, [activeTab, jsonMode, allData])
setEditorValue(JSON.stringify(data, null, 2))
setDirty(false) function updateData(newData: unknown[]) {
} setAllData(prev => ({ ...prev, [activeTab]: newData }))
}, [activeTab, allData]) setDirty(true)
}
function updateRecord(index: number, key: string, value: unknown) {
const arr = [...(allData[activeTab] as R[] || [])]
arr[index] = { ...arr[index], [key]: value }
updateData(arr)
}
// For single-record tables (company, funding)
function updateSingle(key: string, value: unknown) { updateRecord(0, key, value) }
async function saveTable() { async function saveTable() {
let parsed: unknown let data: unknown
try { if (jsonMode) {
parsed = JSON.parse(editorValue) try { data = JSON.parse(jsonText) } catch { flashToast('Invalid JSON'); return }
} catch { } else {
flashToast('Invalid JSON') data = allData[activeTab]
return
} }
setSaving(true) setSaving(true)
const res = await fetch(`/api/admin/versions/${id}/data/${activeTab}`, { const res = await fetch(`/api/admin/versions/${id}/data/${activeTab}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: parsed }), body: JSON.stringify({ data }),
}) })
setSaving(false) setSaving(false)
if (res.ok) { if (res.ok) {
setDirty(false) setDirty(false)
setAllData(prev => ({ ...prev, [activeTab]: Array.isArray(parsed) ? parsed : [parsed] })) if (jsonMode) setAllData(prev => ({ ...prev, [activeTab]: Array.isArray(data) ? data : [data] }))
flashToast('Saved') flashToast('Saved')
} else { } else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Save failed') }
const d = await res.json().catch(() => ({}))
flashToast(d.error || 'Save failed')
}
} }
async function commitVersion() { async function commitVersion() {
if (!confirm('Commit this version? It becomes immutable and available for investor assignment.')) return if (!confirm('Commit this version? It becomes immutable.')) return
const res = await fetch(`/api/admin/versions/${id}/commit`, { method: 'POST' }) const res = await fetch(`/api/admin/versions/${id}/commit`, { method: 'POST' })
if (res.ok) { flashToast('Committed'); load() } if (res.ok) { flashToast('Committed'); load() }
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') } else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
@@ -104,20 +101,328 @@ export default function VersionEditorPage() {
const name = prompt('Name for the new draft:') const name = prompt('Name for the new draft:')
if (!name) return if (!name) return
const res = await fetch(`/api/admin/versions/${id}/fork`, { const res = await fetch(`/api/admin/versions/${id}/fork`, {
method: 'POST', method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
}) })
if (res.ok) { if (res.ok) { const d = await res.json(); router.push(`/pitch-admin/versions/${d.version.id}`) }
const d = await res.json() else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
router.push(`/pitch-admin/versions/${d.version.id}`)
} else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
} }
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div> if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
if (!version) return <div className="text-rose-400">Version not found</div> if (!version) return <div className="text-rose-400">Version not found</div>
const isDraft = version.status === 'draft' const isDraft = version.status === 'draft'
const data = allData[activeTab] || []
const single = (data as R[])[0] || {} as R
function renderEditor() {
if (jsonMode) {
return (
<textarea
value={jsonText}
onChange={e => { setJsonText(e.target.value); setDirty(true) }}
readOnly={!isDraft}
className="w-full bg-transparent text-white/90 font-mono text-xs p-4 focus:outline-none resize-none"
style={{ minHeight: '400px' }}
spellCheck={false}
/>
)
}
switch (activeTab) {
case 'company':
return (
<div className="space-y-4 p-4">
<FormField label="Company Name" value={single.name as string || ''} onChange={v => updateSingle('name', v)} />
<div className="grid grid-cols-2 gap-4">
<FormField label="Legal Form" value={single.legal_form as string || ''} onChange={v => updateSingle('legal_form', v)} placeholder="GmbH" />
<FormField label="Founding Date" value={single.founding_date as string || ''} onChange={v => updateSingle('founding_date', v)} type="date" />
</div>
<BilingualField label="Tagline" valueDe={single.tagline_de as string || ''} valueEn={single.tagline_en as string || ''} onChangeDe={v => updateSingle('tagline_de', v)} onChangeEn={v => updateSingle('tagline_en', v)} />
<BilingualField label="Mission" valueDe={single.mission_de as string || ''} valueEn={single.mission_en as string || ''} onChangeDe={v => updateSingle('mission_de', v)} onChangeEn={v => updateSingle('mission_en', v)} multiline />
<div className="grid grid-cols-2 gap-4">
<FormField label="Website" value={single.website as string || ''} onChange={v => updateSingle('website', v)} type="url" />
<FormField label="HQ City" value={single.hq_city as string || ''} onChange={v => updateSingle('hq_city', v)} />
</div>
</div>
)
case 'team':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="name"
subtitleKey="role_en"
addLabel="Add team member"
renderCard={(item, update) => (
<div className="space-y-3">
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
<BilingualField label="Role" valueDe={item.role_de as string || ''} valueEn={item.role_en as string || ''} onChangeDe={v => update('role_de', v)} onChangeEn={v => update('role_en', v)} />
<BilingualField label="Bio" valueDe={item.bio_de as string || ''} valueEn={item.bio_en as string || ''} onChangeDe={v => update('bio_de', v)} onChangeEn={v => update('bio_en', v)} multiline />
<div className="grid grid-cols-2 gap-4">
<FormField label="Equity %" value={item.equity_pct as number || 0} onChange={v => update('equity_pct', v)} type="number" />
<FormField label="LinkedIn" value={item.linkedin_url as string || ''} onChange={v => update('linkedin_url', v)} type="url" />
</div>
<ArrayField label="Expertise" values={(item.expertise as string[]) || []} onChange={v => update('expertise', v)} />
</div>
)}
/>
</div>
)
case 'financials':
return (
<div className="p-4">
<RowTable
rows={data as R[]}
onChange={updateData}
columns={[
{ key: 'year', label: 'Year', type: 'number' },
{ key: 'revenue_eur', label: 'Revenue (EUR)', type: 'number' },
{ key: 'costs_eur', label: 'Costs (EUR)', type: 'number' },
{ key: 'mrr_eur', label: 'MRR (EUR)', type: 'number' },
{ key: 'arr_eur', label: 'ARR (EUR)', type: 'number' },
{ key: 'customers_count', label: 'Customers', type: 'number' },
{ key: 'employees_count', label: 'Employees', type: 'number' },
{ key: 'burn_rate_eur', label: 'Burn (EUR)', type: 'number' },
]}
addLabel="Add year"
/>
</div>
)
case 'market':
return (
<div className="p-4">
<RowTable
rows={data as R[]}
onChange={updateData}
columns={[
{ key: 'market_segment', label: 'Segment' },
{ key: 'label', label: 'Label' },
{ key: 'value_eur', label: 'Value (EUR)', type: 'number' },
{ key: 'growth_rate_pct', label: 'Growth %', type: 'number' },
{ key: 'source', label: 'Source' },
]}
/>
</div>
)
case 'competitors':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="name"
subtitleKey="website"
addLabel="Add competitor"
renderCard={(item, update) => (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
<FormField label="Website" value={item.website as string || ''} onChange={v => update('website', v)} type="url" />
</div>
<div className="grid grid-cols-2 gap-4">
<FormField label="Customers" value={item.customers_count as number || 0} onChange={v => update('customers_count', v)} type="number" />
<FormField label="Pricing Range" value={item.pricing_range as string || ''} onChange={v => update('pricing_range', v)} />
</div>
<ArrayField label="Strengths" values={(item.strengths as string[]) || []} onChange={v => update('strengths', v)} />
<ArrayField label="Weaknesses" values={(item.weaknesses as string[]) || []} onChange={v => update('weaknesses', v)} />
</div>
)}
/>
</div>
)
case 'features':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="feature_name_en"
subtitleKey="category"
addLabel="Add feature"
renderCard={(item, update) => (
<div className="space-y-3">
<BilingualField label="Feature Name" valueDe={item.feature_name_de as string || ''} valueEn={item.feature_name_en as string || ''} onChangeDe={v => update('feature_name_de', v)} onChangeEn={v => update('feature_name_en', v)} />
<FormField label="Category" value={item.category as string || ''} onChange={v => update('category', v)} />
<div className="grid grid-cols-5 gap-3">
<FormField label="BreakPilot" value={!!item.breakpilot} onChange={v => update('breakpilot', v)} type="checkbox" />
<FormField label="Proliance" value={!!item.proliance} onChange={v => update('proliance', v)} type="checkbox" />
<FormField label="DataGuard" value={!!item.dataguard} onChange={v => update('dataguard', v)} type="checkbox" />
<FormField label="heyData" value={!!item.heydata} onChange={v => update('heydata', v)} type="checkbox" />
<FormField label="Differentiator" value={!!item.is_differentiator} onChange={v => update('is_differentiator', v)} type="checkbox" />
</div>
</div>
)}
/>
</div>
)
case 'milestones':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="title_en"
subtitleKey="milestone_date"
addLabel="Add milestone"
renderCard={(item, update) => (
<div className="space-y-3">
<BilingualField label="Title" valueDe={item.title_de as string || ''} valueEn={item.title_en as string || ''} onChangeDe={v => update('title_de', v)} onChangeEn={v => update('title_en', v)} />
<BilingualField label="Description" valueDe={item.description_de as string || ''} valueEn={item.description_en as string || ''} onChangeDe={v => update('description_de', v)} onChangeEn={v => update('description_en', v)} multiline />
<div className="grid grid-cols-3 gap-4">
<FormField label="Date" value={item.milestone_date as string || ''} onChange={v => update('milestone_date', v)} />
<FormField label="Status" value={item.status as string || ''} onChange={v => update('status', v)} type="select" options={[
{ value: 'completed', label: 'Completed' }, { value: 'in_progress', label: 'In Progress' }, { value: 'planned', label: 'Planned' },
]} />
<FormField label="Category" value={item.category as string || ''} onChange={v => update('category', v)} />
</div>
</div>
)}
/>
</div>
)
case 'metrics':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="metric_name"
subtitleKey="value"
addLabel="Add metric"
renderCard={(item, update) => (
<div className="space-y-3">
<FormField label="Metric Key" value={item.metric_name as string || ''} onChange={v => update('metric_name', v)} />
<BilingualField label="Label" valueDe={item.label_de as string || ''} valueEn={item.label_en as string || ''} onChangeDe={v => update('label_de', v)} onChangeEn={v => update('label_en', v)} />
<div className="grid grid-cols-3 gap-4">
<FormField label="Value" value={item.value as string || ''} onChange={v => update('value', v)} />
<FormField label="Unit" value={item.unit as string || ''} onChange={v => update('unit', v)} />
<FormField label="Is Live" value={!!item.is_live} onChange={v => update('is_live', v)} type="checkbox" />
</div>
</div>
)}
/>
</div>
)
case 'funding':
return (
<div className="space-y-4 p-4">
<FormField label="Round Name" value={single.round_name as string || ''} onChange={v => updateSingle('round_name', v)} />
<div className="grid grid-cols-3 gap-4">
<FormField label="Amount (EUR)" value={single.amount_eur as number || 0} onChange={v => updateSingle('amount_eur', v)} type="number" />
<FormField label="Instrument" value={single.instrument as string || ''} onChange={v => updateSingle('instrument', v)} />
<FormField label="Target Date" value={single.target_date as string || ''} onChange={v => updateSingle('target_date', v)} type="date" />
</div>
<FormField label="Status" value={single.status as string || ''} onChange={v => updateSingle('status', v)} type="select" options={[
{ value: 'planned', label: 'Planned' }, { value: 'in_progress', label: 'In Progress' }, { value: 'completed', label: 'Completed' },
]} />
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Use of Funds</label>
<RowTable
rows={(single.use_of_funds as R[]) || []}
onChange={v => updateSingle('use_of_funds', v)}
columns={[
{ key: 'category', label: 'Category' },
{ key: 'percentage', label: '%', type: 'number' },
{ key: 'label_de', label: 'Label DE' },
{ key: 'label_en', label: 'Label EN' },
]}
/>
</div>
</div>
)
case 'products':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="name"
subtitleKey="hardware"
addLabel="Add product"
renderCard={(item, update) => (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
<FormField label="Hardware" value={item.hardware as string || ''} onChange={v => update('hardware', v)} />
</div>
<div className="grid grid-cols-3 gap-4">
<FormField label="HW Cost (EUR)" value={item.hardware_cost_eur as number || 0} onChange={v => update('hardware_cost_eur', v)} type="number" />
<FormField label="Monthly Price (EUR)" value={item.monthly_price_eur as number || 0} onChange={v => update('monthly_price_eur', v)} type="number" />
<FormField label="Operating Cost (EUR)" value={item.operating_cost_eur as number || 0} onChange={v => update('operating_cost_eur', v)} type="number" />
</div>
<div className="grid grid-cols-2 gap-4">
<FormField label="LLM Model" value={item.llm_model as string || ''} onChange={v => update('llm_model', v)} />
<FormField label="LLM Size" value={item.llm_size as string || ''} onChange={v => update('llm_size', v)} />
</div>
<BilingualField label="LLM Capability" valueDe={item.llm_capability_de as string || ''} valueEn={item.llm_capability_en as string || ''} onChangeDe={v => update('llm_capability_de', v)} onChangeEn={v => update('llm_capability_en', v)} multiline />
<div className="grid grid-cols-2 gap-4">
<ArrayField label="Features (DE)" values={(item.features_de as string[]) || []} onChange={v => update('features_de', v)} />
<ArrayField label="Features (EN)" values={(item.features_en as string[]) || []} onChange={v => update('features_en', v)} />
</div>
<FormField label="Popular" value={!!item.is_popular} onChange={v => update('is_popular', v)} type="checkbox" />
</div>
)}
/>
</div>
)
case 'fm_scenarios':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="name"
subtitleKey="description"
addLabel="Add scenario"
renderCard={(item, update) => (
<div className="space-y-3">
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
<FormField label="Description" value={item.description as string || ''} onChange={v => update('description', v)} />
<div className="grid grid-cols-2 gap-4">
<FormField label="Color" value={item.color as string || '#6366f1'} onChange={v => update('color', v)} type="color" />
<FormField label="Default" value={!!item.is_default} onChange={v => update('is_default', v)} type="checkbox" />
</div>
</div>
)}
/>
</div>
)
case 'fm_assumptions':
// Reuse the inline table approach from the FM editor (already works well for this)
return (
<div className="p-4">
<RowTable
rows={data as R[]}
onChange={updateData}
columns={[
{ key: 'key', label: 'Key' },
{ key: 'label_de', label: 'Label DE' },
{ key: 'label_en', label: 'Label EN' },
{ key: 'category', label: 'Category' },
{ key: 'unit', label: 'Unit' },
]}
addLabel="Add assumption"
/>
<p className="text-[10px] text-white/30 mt-2">Note: values, min/max/step are best edited via "Edit as JSON" mode for complex types.</p>
</div>
)
default:
return <div className="p-4 text-white/40">No editor for this table</div>
}
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -137,37 +442,35 @@ export default function VersionEditorPage() {
{version.description && <p className="text-sm text-white/50">{version.description}</p>} {version.description && <p className="text-sm text-white/50">{version.description}</p>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link
href={`/pitch-preview/${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"
>
<Eye className="w-4 h-4" /> Preview
</Link>
{isDraft && ( {isDraft && (
<button <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">
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
</button> </button>
)} )}
<button <button onClick={forkVersion} 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">
onClick={forkVersion}
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"
>
<GitFork className="w-4 h-4" /> Fork <GitFork className="w-4 h-4" /> Fork
</button> </button>
{version.parent_id && ( {version.parent_id && (
<Link <Link href={`/pitch-admin/versions/${id}/diff/${version.parent_id}`} className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg">
href={`/pitch-admin/versions/${id}/diff/${version.parent_id}`} Diff
className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg"
>
Diff with parent
</Link> </Link>
)} )}
</div> </div>
</div> </div>
{/* Tab navigation */} {/* Tabs */}
<div className="flex gap-1 overflow-x-auto pb-1"> <div className="flex gap-1 overflow-x-auto pb-1">
{TABLE_NAMES.map(t => ( {TABLE_NAMES.map(t => (
<button <button
key={t} key={t}
onClick={() => { if (dirty && !confirm('Discard unsaved changes?')) return; setActiveTab(t) }} onClick={() => { if (dirty && !confirm('Discard unsaved changes?')) return; setActiveTab(t); setDirty(false); setJsonMode(false) }}
className={`px-3 py-1.5 rounded-lg text-xs whitespace-nowrap transition-colors ${ className={`px-3 py-1.5 rounded-lg text-xs whitespace-nowrap transition-colors ${
activeTab === t activeTab === t
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30' ? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
@@ -179,38 +482,37 @@ export default function VersionEditorPage() {
))} ))}
</div> </div>
{/* JSON editor */} {/* Editor */}
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl overflow-hidden"> <div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-white/[0.06]"> <div className="flex items-center justify-between px-4 py-3 border-b border-white/[0.06]">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm font-semibold text-white">{TABLE_LABELS[activeTab]}</span> <span className="text-sm font-semibold text-white">{TABLE_LABELS[activeTab]}</span>
{dirty && <span className="text-[9px] px-2 py-0.5 rounded bg-amber-500/20 text-amber-300">Unsaved</span>} {dirty && <span className="text-[9px] px-2 py-0.5 rounded bg-amber-500/20 text-amber-300">Unsaved</span>}
</div> </div>
{isDraft && ( <div className="flex items-center gap-2">
<button <button
onClick={saveTable} onClick={() => setJsonMode(!jsonMode)}
disabled={saving || !dirty} className={`text-[10px] px-2 py-1 rounded flex items-center gap-1 transition-colors ${
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-1.5 rounded-lg flex items-center gap-2 disabled:opacity-30" jsonMode ? 'bg-indigo-500/20 text-indigo-300' : 'bg-white/[0.04] text-white/40 hover:text-white/60'
}`}
> >
<Save className="w-3.5 h-3.5" /> {saving ? 'Saving…' : 'Save'} <Code className="w-3 h-3" /> {jsonMode ? 'Form' : 'JSON'}
</button> </button>
)} {isDraft && (
<button
onClick={saveTable}
disabled={saving || !dirty}
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-1.5 rounded-lg flex items-center gap-2 disabled:opacity-30"
>
<Save className="w-3.5 h-3.5" /> {saving ? 'Saving…' : 'Save'}
</button>
)}
</div>
</div> </div>
<textarea {renderEditor()}
value={editorValue}
onChange={e => { setEditorValue(e.target.value); setDirty(true) }}
readOnly={!isDraft}
className="w-full bg-transparent text-white/90 font-mono text-xs p-4 focus:outline-none resize-none"
style={{ minHeight: '400px' }}
spellCheck={false}
/>
</div> </div>
{!isDraft && ( {!isDraft && <p className="text-xs text-white/30 text-center">Committed read-only. Fork to edit.</p>}
<p className="text-xs text-white/30 text-center">
This version is committed and read-only. Fork it to make changes.
</p>
)}
{toast && ( {toast && (
<div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm z-50"> <div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm z-50">

View File

@@ -0,0 +1,72 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useParams } from 'next/navigation'
import { Language, PitchData } from '@/lib/types'
import PitchDeck from '@/components/PitchDeck'
export default function PreviewPage() {
const { versionId } = useParams<{ versionId: string }>()
const [data, setData] = useState<PitchData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lang, setLang] = useState<Language>('de')
const toggleLanguage = useCallback(() => {
setLang(prev => prev === 'de' ? 'en' : 'de')
}, [])
useEffect(() => {
if (!versionId) return
setLoading(true)
fetch(`/api/preview-data/${versionId}`)
.then(async r => {
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'Failed to load')
return r.json()
})
.then(setData)
.catch(e => setError(e.message))
.finally(() => setLoading(false))
}, [versionId])
if (loading) {
return (
<div className="h-screen flex items-center justify-center bg-[#0a0a1a]">
<div className="text-center">
<div className="w-12 h-12 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-white/40 text-sm">Loading preview...</p>
</div>
</div>
)
}
if (error || !data) {
return (
<div className="h-screen flex items-center justify-center bg-[#0a0a1a]">
<div className="text-center max-w-md px-6">
<p className="text-rose-400 mb-2">Preview Error</p>
<p className="text-white/40 text-sm">{error || 'No data found for this version'}</p>
<p className="text-white/30 text-xs mt-4">Make sure you are logged in as an admin.</p>
</div>
</div>
)
}
// Render PitchDeck with no investor (no watermark, no audit) — admin preview only
// The banner at the top indicates this is a preview
return (
<div className="relative">
{/* Preview banner */}
<div className="fixed top-0 left-0 right-0 z-[100] bg-amber-500/90 text-black text-center py-1.5 text-xs font-semibold">
PREVIEW MODE This is how investors will see this version
</div>
<PitchDeck
lang={lang}
onToggleLanguage={toggleLanguage}
investor={null}
onLogout={() => {}}
previewData={data}
/>
</div>
)
}

View File

@@ -47,10 +47,14 @@ interface PitchDeckProps {
onToggleLanguage: () => void onToggleLanguage: () => void
investor: Investor | null investor: Investor | null
onLogout: () => void onLogout: () => void
previewData?: PitchData | null
} }
export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout }: PitchDeckProps) { export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout, previewData }: PitchDeckProps) {
const { data, loading, error } = usePitchData() const fetched = usePitchData()
const data = previewData || fetched.data
const loading = previewData ? false : fetched.loading
const error = previewData ? null : fetched.error
const nav = useSlideNavigation() const nav = useSlideNavigation()
const [fabOpen, setFabOpen] = useState(false) const [fabOpen, setFabOpen] = useState(false)

View File

@@ -0,0 +1,56 @@
'use client'
import { useState } from 'react'
import { X, Plus } from 'lucide-react'
interface ArrayFieldProps {
label: string
values: string[]
onChange: (v: string[]) => void
placeholder?: string
}
export default function ArrayField({ label, values, onChange, placeholder }: ArrayFieldProps) {
const [input, setInput] = useState('')
function add() {
const v = input.trim()
if (v && !values.includes(v)) {
onChange([...values, v])
setInput('')
}
}
function remove(idx: number) {
onChange(values.filter((_, i) => i !== idx))
}
return (
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">{label}</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{values.map((v, i) => (
<span key={i} className="inline-flex items-center gap-1 bg-indigo-500/15 text-indigo-300 text-xs px-2 py-1 rounded-lg border border-indigo-500/20">
{v}
<button onClick={() => remove(i)} className="hover:text-rose-300">
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); add() } }}
placeholder={placeholder || 'Type and press Enter'}
className="flex-1 bg-black/30 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 placeholder:text-white/20"
/>
<button onClick={add} className="bg-white/[0.06] hover:bg-white/[0.1] text-white/60 p-1.5 rounded-lg">
<Plus className="w-4 h-4" />
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
interface BilingualFieldProps {
label: string
valueDe: string
valueEn: string
onChangeDe: (v: string) => void
onChangeEn: (v: string) => void
multiline?: boolean
placeholder?: string
}
export default function BilingualField({
label, valueDe, valueEn, onChangeDe, onChangeEn, multiline, placeholder,
}: BilingualFieldProps) {
const inputClass = 'w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 placeholder:text-white/20'
return (
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">{label}</label>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1.5 mb-1">
<span className="text-[10px] text-white/40 font-semibold">DE</span>
</div>
{multiline ? (
<textarea
value={valueDe || ''}
onChange={e => onChangeDe(e.target.value)}
rows={3}
placeholder={placeholder}
className={`${inputClass} resize-none`}
/>
) : (
<input
type="text"
value={valueDe || ''}
onChange={e => onChangeDe(e.target.value)}
placeholder={placeholder}
className={inputClass}
/>
)}
</div>
<div>
<div className="flex items-center gap-1.5 mb-1">
<span className="text-[10px] text-white/40 font-semibold">EN</span>
</div>
{multiline ? (
<textarea
value={valueEn || ''}
onChange={e => onChangeEn(e.target.value)}
rows={3}
placeholder={placeholder}
className={`${inputClass} resize-none`}
/>
) : (
<input
type="text"
value={valueEn || ''}
onChange={e => onChangeEn(e.target.value)}
placeholder={placeholder}
className={inputClass}
/>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,115 @@
'use client'
import { useState } from 'react'
import { ChevronDown, ChevronRight, Plus, Trash2, GripVertical } from 'lucide-react'
interface CardListProps {
items: Record<string, unknown>[]
onChange: (items: Record<string, unknown>[]) => void
titleKey: string
subtitleKey?: string
renderCard: (item: Record<string, unknown>, update: (key: string, value: unknown) => void) => React.ReactNode
newItemTemplate?: Record<string, unknown>
addLabel?: string
}
export default function CardList({
items, onChange, titleKey, subtitleKey, renderCard, newItemTemplate, addLabel,
}: CardListProps) {
const [expandedIdx, setExpandedIdx] = useState<number | null>(null)
function updateItem(idx: number, key: string, value: unknown) {
onChange(items.map((item, i) => i === idx ? { ...item, [key]: value } : item))
}
function addItem() {
const newItem = newItemTemplate || (() => {
const template: Record<string, unknown> = {}
if (items.length > 0) {
Object.keys(items[0]).forEach(k => {
const sample = items[0][k]
template[k] = Array.isArray(sample) ? [] : typeof sample === 'number' ? 0 : typeof sample === 'boolean' ? false : ''
})
}
if ('sort_order' in template) template.sort_order = items.length
return template
})()
onChange([...items, newItem])
setExpandedIdx(items.length)
}
function removeItem(idx: number) {
if (!confirm('Remove this item?')) return
onChange(items.filter((_, i) => i !== idx))
if (expandedIdx === idx) setExpandedIdx(null)
}
function moveUp(idx: number) {
if (idx === 0) return
const copy = [...items]
;[copy[idx - 1], copy[idx]] = [copy[idx], copy[idx - 1]]
onChange(copy)
setExpandedIdx(idx - 1)
}
function moveDown(idx: number) {
if (idx >= items.length - 1) return
const copy = [...items]
;[copy[idx], copy[idx + 1]] = [copy[idx + 1], copy[idx]]
onChange(copy)
setExpandedIdx(idx + 1)
}
return (
<div className="space-y-2">
{items.map((item, idx) => {
const isExpanded = expandedIdx === idx
const title = String(item[titleKey] || `Item ${idx + 1}`)
const subtitle = subtitleKey ? String(item[subtitleKey] || '') : ''
return (
<div key={idx} className="border border-white/[0.06] rounded-xl overflow-hidden">
<button
onClick={() => setExpandedIdx(isExpanded ? null : idx)}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-white/[0.02] text-left"
>
<div className="flex items-center gap-1 text-white/30">
<button
onClick={e => { e.stopPropagation(); moveUp(idx) }}
className="hover:text-white/60 p-0.5"
title="Move up"
>
<GripVertical className="w-3 h-3" />
</button>
</div>
{isExpanded ? <ChevronDown className="w-4 h-4 text-white/40" /> : <ChevronRight className="w-4 h-4 text-white/40" />}
<div className="flex-1 min-w-0">
<span className="text-sm text-white/90 font-medium truncate block">{title}</span>
{subtitle && <span className="text-xs text-white/40 truncate block">{subtitle}</span>}
</div>
<span className="text-[9px] text-white/30 font-mono">#{idx + 1}</span>
<button
onClick={e => { e.stopPropagation(); removeItem(idx) }}
className="text-white/30 hover:text-rose-400 p-1"
title="Remove"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</button>
{isExpanded && (
<div className="px-4 pb-4 pt-1 border-t border-white/[0.04] space-y-4">
{renderCard(item, (key, value) => updateItem(idx, key, value))}
</div>
)}
</div>
)
})}
<button
onClick={addItem}
className="w-full flex items-center justify-center gap-2 py-2.5 text-xs text-white/50 hover:text-white border border-dashed border-white/[0.1] hover:border-white/[0.2] rounded-xl transition-colors"
>
<Plus className="w-3.5 h-3.5" /> {addLabel || 'Add item'}
</button>
</div>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
interface FormFieldProps {
label: string
value: string | number | boolean
onChange: (v: string | number | boolean) => void
type?: 'text' | 'number' | 'date' | 'url' | 'checkbox' | 'select' | 'color'
placeholder?: string
options?: { value: string; label: string }[]
hint?: string
}
export default function FormField({
label, value, onChange, type = 'text', placeholder, options, hint,
}: FormFieldProps) {
const inputClass = 'w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 placeholder:text-white/20'
return (
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">{label}</label>
{type === 'checkbox' ? (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={!!value}
onChange={e => onChange(e.target.checked)}
className="w-4 h-4 rounded border-white/20 bg-black/30 text-indigo-500 focus:ring-indigo-500/40"
/>
<span className="text-sm text-white/70">{placeholder || label}</span>
</label>
) : type === 'select' && options ? (
<select
value={String(value)}
onChange={e => onChange(e.target.value)}
className={inputClass}
>
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
) : type === 'color' ? (
<div className="flex items-center gap-2">
<input
type="color"
value={String(value) || '#6366f1'}
onChange={e => onChange(e.target.value)}
className="w-10 h-10 rounded-lg border border-white/10 cursor-pointer bg-transparent"
/>
<input
type="text"
value={String(value)}
onChange={e => onChange(e.target.value)}
className={`${inputClass} flex-1`}
placeholder="#6366f1"
/>
</div>
) : (
<input
type={type}
value={value as string | number}
onChange={e => onChange(type === 'number' ? Number(e.target.value) || 0 : e.target.value)}
placeholder={placeholder}
className={inputClass}
/>
)}
{hint && <p className="text-[10px] text-white/30 mt-1">{hint}</p>}
</div>
)
}

View File

@@ -0,0 +1,92 @@
'use client'
import { Plus, Trash2 } from 'lucide-react'
interface RowTableProps {
rows: Record<string, unknown>[]
onChange: (rows: Record<string, unknown>[]) => void
columns?: { key: string; label: string; type?: 'text' | 'number' }[]
addLabel?: string
}
export default function RowTable({ rows, onChange, columns, addLabel }: RowTableProps) {
// Auto-detect columns from first row if not provided
const cols = columns || (rows.length > 0
? Object.keys(rows[0]).filter(k => k !== 'id' && k !== 'sort_order').map(k => ({
key: k,
label: k.replace(/_/g, ' '),
type: (typeof rows[0][k] === 'number' ? 'number' : 'text') as 'text' | 'number',
}))
: [])
function updateCell(rowIdx: number, key: string, value: string) {
const col = cols.find(c => c.key === key)
const parsedValue = col?.type === 'number' ? (Number(value) || 0) : value
onChange(rows.map((r, i) => i === rowIdx ? { ...r, [key]: parsedValue } : r))
}
function addRow() {
const newRow: Record<string, unknown> = {}
cols.forEach(c => { newRow[c.key] = c.type === 'number' ? 0 : '' })
// Carry over id-like fields
if (rows.length > 0 && 'id' in rows[0]) {
newRow.id = (rows.length + 1)
}
if (rows.length > 0 && 'sort_order' in rows[0]) {
newRow.sort_order = rows.length
}
onChange([...rows, newRow])
}
function removeRow(idx: number) {
onChange(rows.filter((_, i) => i !== idx))
}
if (cols.length === 0) return <div className="text-white/40 text-sm">No columns detected</div>
return (
<div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.08]">
{cols.map(c => (
<th key={c.key} className="text-left py-2 px-2 text-[10px] text-white/40 font-medium uppercase tracking-wider whitespace-nowrap">
{c.label}
</th>
))}
<th className="w-8" />
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri} className="border-b border-white/[0.04] hover:bg-white/[0.02]">
{cols.map(c => (
<td key={c.key} className="py-1 px-2">
<input
type={c.type || 'text'}
value={(row[c.key] as string | number) ?? ''}
onChange={e => updateCell(ri, c.key, e.target.value)}
className="w-full bg-transparent border-b border-transparent hover:border-white/10 focus:border-indigo-500/50 text-white font-mono text-xs py-1 focus:outline-none min-w-[60px]"
/>
</td>
))}
<td className="py-1 px-1">
<button onClick={() => removeRow(ri)} className="text-white/30 hover:text-rose-400 p-1" title="Remove">
<Trash2 className="w-3 h-3" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={addRow}
className="mt-2 text-xs text-white/50 hover:text-white flex items-center gap-1 px-2 py-1 rounded hover:bg-white/[0.04]"
>
<Plus className="w-3 h-3" /> {addLabel || 'Add row'}
</button>
</div>
)
}

View File

@@ -16,7 +16,7 @@ const PUBLIC_PATHS = [
] ]
// Paths gated on the admin session cookie // Paths gated on the admin session cookie
const ADMIN_GATED_PREFIXES = ['/pitch-admin', '/api/admin'] const ADMIN_GATED_PREFIXES = ['/pitch-admin', '/api/admin', '/pitch-preview', '/api/preview-data']
function isPublicPath(pathname: string): boolean { function isPublicPath(pathname: string): boolean {
return PUBLIC_PATHS.some(p => pathname === p || pathname.startsWith(p + '/')) return PUBLIC_PATHS.some(p => pathname === p || pathname.startsWith(p + '/'))