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:
44
pitch-deck/app/api/preview-data/[versionId]/route.ts
Normal file
44
pitch-deck/app/api/preview-data/[versionId]/route.ts
Normal 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 || [],
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
72
pitch-deck/app/pitch-preview/[versionId]/page.tsx
Normal file
72
pitch-deck/app/pitch-preview/[versionId]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 + '/'))
|
||||||
|
|||||||
Reference in New Issue
Block a user