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

@@ -47,10 +47,14 @@ interface PitchDeckProps {
onToggleLanguage: () => void
investor: Investor | null
onLogout: () => void
previewData?: PitchData | null
}
export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout }: PitchDeckProps) {
const { data, loading, error } = usePitchData()
export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout, previewData }: PitchDeckProps) {
const fetched = usePitchData()
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)

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>
)
}