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
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:
@@ -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)
|
||||
|
||||
|
||||
56
pitch-deck/components/pitch-admin/editors/ArrayField.tsx
Normal file
56
pitch-deck/components/pitch-admin/editors/ArrayField.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
pitch-deck/components/pitch-admin/editors/BilingualField.tsx
Normal file
69
pitch-deck/components/pitch-admin/editors/BilingualField.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
pitch-deck/components/pitch-admin/editors/CardList.tsx
Normal file
115
pitch-deck/components/pitch-admin/editors/CardList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
pitch-deck/components/pitch-admin/editors/FormField.tsx
Normal file
69
pitch-deck/components/pitch-admin/editors/FormField.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
pitch-deck/components/pitch-admin/editors/RowTable.tsx
Normal file
92
pitch-deck/components/pitch-admin/editors/RowTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user