Merge branch 'main' of ssh://gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-core
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 48s
CI / test-python-voice (push) Successful in 43s
CI / test-bqas (push) Successful in 36s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 48s
CI / test-python-voice (push) Successful in 43s
CI / test-bqas (push) Successful in 36s
This commit is contained in:
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { generateToken } from '@/lib/auth'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
import { createShortLink } from '@/lib/short-links'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
@@ -47,6 +48,7 @@ export async function POST(request: NextRequest, ctx: RouteContext) {
|
||||
|
||||
const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai'
|
||||
const url = `${baseUrl}/auth/verify?token=${token}`
|
||||
const shortUrl = await createShortLink(token)
|
||||
|
||||
await logAdminAudit(
|
||||
adminId,
|
||||
@@ -56,5 +58,5 @@ export async function POST(request: NextRequest, ctx: RouteContext) {
|
||||
investor.id,
|
||||
)
|
||||
|
||||
return NextResponse.json({ url, expires_at: expiresAt.toISOString() })
|
||||
return NextResponse.json({ url, short_url: shortUrl, expires_at: expiresAt.toISOString() })
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { generateToken } from '@/lib/auth'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
import { sendMagicLinkEmail } from '@/lib/email'
|
||||
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
|
||||
import { createShortLink } from '@/lib/short-links'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
@@ -44,9 +45,8 @@ export async function POST(request: NextRequest, ctx: RouteContext) {
|
||||
[investor.id, token, expiresAt],
|
||||
)
|
||||
|
||||
const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai'
|
||||
const magicLinkUrl = `${baseUrl}/auth/verify?token=${token}`
|
||||
await sendMagicLinkEmail(investor.email, investor.name, magicLinkUrl)
|
||||
const shortUrl = await createShortLink(token)
|
||||
await sendMagicLinkEmail(investor.email, investor.name, shortUrl)
|
||||
|
||||
await logAdminAudit(
|
||||
adminId,
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function GET(request: NextRequest, ctx: RouteContext) {
|
||||
pool.query(
|
||||
`SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count,
|
||||
i.created_at, i.updated_at, i.first_activity_at, i.data_masked_at,
|
||||
i.assigned_version_id, i.is_showcase,
|
||||
i.assigned_version_id, i.is_showcase, i.preferred_lang,
|
||||
v.name AS version_name, v.status AS version_status
|
||||
FROM pitch_investors i
|
||||
LEFT JOIN pitch_versions v ON v.id = i.assigned_version_id
|
||||
@@ -68,14 +68,14 @@ export async function PATCH(request: NextRequest, ctx: RouteContext) {
|
||||
|
||||
const { id } = await ctx.params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { name, company, assigned_version_id, is_showcase } = body
|
||||
const { name, company, assigned_version_id, is_showcase, preferred_lang } = body
|
||||
|
||||
if (name === undefined && company === undefined && assigned_version_id === undefined && is_showcase === undefined) {
|
||||
return NextResponse.json({ error: 'name, company, assigned_version_id, or is_showcase required' }, { status: 400 })
|
||||
if (name === undefined && company === undefined && assigned_version_id === undefined && is_showcase === undefined && preferred_lang === undefined) {
|
||||
return NextResponse.json({ error: 'name, company, assigned_version_id, is_showcase, or preferred_lang required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const before = await pool.query(
|
||||
`SELECT name, company, assigned_version_id, is_showcase FROM pitch_investors WHERE id = $1`,
|
||||
`SELECT name, company, assigned_version_id, is_showcase, preferred_lang FROM pitch_investors WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
if (before.rows.length === 0) {
|
||||
@@ -98,8 +98,8 @@ export async function PATCH(request: NextRequest, ctx: RouteContext) {
|
||||
|
||||
// Use null to clear version assignment, undefined to leave unchanged
|
||||
const versionValue = assigned_version_id === undefined ? before.rows[0].assigned_version_id : (assigned_version_id || null)
|
||||
|
||||
const showcaseValue = is_showcase !== undefined ? Boolean(is_showcase) : before.rows[0].is_showcase
|
||||
const langValue = preferred_lang === 'en' || preferred_lang === 'de' ? preferred_lang : before.rows[0].preferred_lang
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE pitch_investors SET
|
||||
@@ -107,10 +107,11 @@ export async function PATCH(request: NextRequest, ctx: RouteContext) {
|
||||
company = COALESCE($2, company),
|
||||
assigned_version_id = $4,
|
||||
is_showcase = $5,
|
||||
preferred_lang = $6,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING id, email, name, company, status, assigned_version_id, is_showcase`,
|
||||
[name ?? null, company ?? null, id, versionValue, showcaseValue],
|
||||
RETURNING id, email, name, company, status, assigned_version_id, is_showcase, preferred_lang`,
|
||||
[name ?? null, company ?? null, id, versionValue, showcaseValue, langValue],
|
||||
)
|
||||
|
||||
const action = assigned_version_id !== undefined && assigned_version_id !== before.rows[0].assigned_version_id
|
||||
|
||||
@@ -3,6 +3,7 @@ import pool from '@/lib/db'
|
||||
import { generateToken } from '@/lib/auth'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
import { sendMagicLinkEmail } from '@/lib/email'
|
||||
import { createShortLink } from '@/lib/short-links'
|
||||
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -11,7 +12,7 @@ export async function POST(request: NextRequest) {
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { email, name, company, greeting, message, closing } = body
|
||||
const { email, name, company, greeting, message, closing, lang = 'de', send_email = true, version_id } = body
|
||||
|
||||
if (!email || typeof email !== 'string') {
|
||||
return NextResponse.json({ error: 'Email required' }, { status: 400 })
|
||||
@@ -25,17 +26,36 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const normalizedEmail = email.toLowerCase().trim()
|
||||
|
||||
const normalizedLang = lang === 'en' ? 'en' : 'de'
|
||||
|
||||
// Validate version if provided
|
||||
const normalizedVersionId = version_id || null
|
||||
if (normalizedVersionId) {
|
||||
const ver = await pool.query(
|
||||
`SELECT id, status FROM pitch_versions WHERE id = $1`,
|
||||
[normalizedVersionId],
|
||||
)
|
||||
if (ver.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Version not found' }, { status: 404 })
|
||||
}
|
||||
if (ver.rows[0].status !== 'committed') {
|
||||
return NextResponse.json({ error: 'Can only assign committed versions' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert investor
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO pitch_investors (email, name, company)
|
||||
VALUES ($1, $2, $3)
|
||||
`INSERT INTO pitch_investors (email, name, company, preferred_lang, assigned_version_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
name = COALESCE(EXCLUDED.name, pitch_investors.name),
|
||||
company = COALESCE(EXCLUDED.company, pitch_investors.company),
|
||||
preferred_lang = EXCLUDED.preferred_lang,
|
||||
assigned_version_id = COALESCE(EXCLUDED.assigned_version_id, pitch_investors.assigned_version_id),
|
||||
status = CASE WHEN pitch_investors.status = 'revoked' THEN 'invited' ELSE pitch_investors.status END,
|
||||
updated_at = NOW()
|
||||
RETURNING id, status`,
|
||||
[normalizedEmail, name || null, company || null],
|
||||
[normalizedEmail, name || null, company || null, normalizedLang, normalizedVersionId],
|
||||
)
|
||||
|
||||
const investor = rows[0]
|
||||
@@ -53,13 +73,24 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai'
|
||||
const magicLinkUrl = `${baseUrl}/auth/verify?token=${token}`
|
||||
const shortUrl = await createShortLink(token)
|
||||
|
||||
await sendMagicLinkEmail(normalizedEmail, name || null, magicLinkUrl, greeting, message, closing)
|
||||
if (send_email) {
|
||||
await sendMagicLinkEmail(normalizedEmail, name || null, shortUrl, greeting, message, closing, normalizedLang)
|
||||
}
|
||||
|
||||
await logAdminAudit(
|
||||
adminId,
|
||||
'investor_invited',
|
||||
{ email: normalizedEmail, name: name || null, company: company || null, expires_at: expiresAt.toISOString() },
|
||||
{
|
||||
email: normalizedEmail,
|
||||
name: name || null,
|
||||
company: company || null,
|
||||
expires_at: expiresAt.toISOString(),
|
||||
lang: normalizedLang,
|
||||
send_email: !!send_email,
|
||||
version_id: normalizedVersionId,
|
||||
},
|
||||
request,
|
||||
investor.id,
|
||||
)
|
||||
@@ -69,5 +100,7 @@ export async function POST(request: NextRequest) {
|
||||
investor_id: investor.id,
|
||||
email: normalizedEmail,
|
||||
expires_at: expiresAt.toISOString(),
|
||||
magic_link_url: magicLinkUrl,
|
||||
short_url: shortUrl,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -160,6 +160,16 @@ export async function POST(request: NextRequest) {
|
||||
`ALTER TABLE dataroom_documents ADD COLUMN IF NOT EXISTS description_en TEXT`,
|
||||
`ALTER TABLE dataroom_investor_uploads ADD COLUMN IF NOT EXISTS description_de TEXT`,
|
||||
`ALTER TABLE dataroom_investor_uploads ADD COLUMN IF NOT EXISTS description_en TEXT`,
|
||||
// 007 — investor preferred language
|
||||
`ALTER TABLE pitch_investors ADD COLUMN IF NOT EXISTS preferred_lang VARCHAR(5) NOT NULL DEFAULT 'de'`,
|
||||
// 008 — short links for magic URLs
|
||||
`CREATE TABLE IF NOT EXISTS pitch_short_links (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
short_code VARCHAR(10) UNIQUE NOT NULL,
|
||||
token VARCHAR(128) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_pitch_short_links_code ON pitch_short_links(short_code)`,
|
||||
]
|
||||
|
||||
for (const sql of statements) {
|
||||
|
||||
@@ -14,7 +14,7 @@ export async function GET() {
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, email, name, company, status, last_login_at, login_count, created_at, is_showcase
|
||||
`SELECT id, email, name, company, status, last_login_at, login_count, created_at, is_showcase, preferred_lang
|
||||
FROM pitch_investors WHERE id = $1`,
|
||||
[session.sub]
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { Suspense, useEffect, useState, useCallback } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
@@ -9,7 +9,7 @@ function VerifyContent() {
|
||||
const router = useRouter()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [status, setStatus] = useState<'verifying' | 'success' | 'error'>('verifying')
|
||||
const [status, setStatus] = useState<'ready' | 'verifying' | 'success' | 'error'>('ready')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -19,38 +19,37 @@ function VerifyContent() {
|
||||
return
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
try {
|
||||
// If the investor already has a valid session, skip token verification
|
||||
const sessionCheck = await fetch('/api/auth/me')
|
||||
if (sessionCheck.ok) {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
const res = await fetch('/api/auth/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setStatus('success')
|
||||
setTimeout(() => router.push('/'), 1000)
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setStatus('error')
|
||||
setErrorMsg(data.error || 'Verification failed.')
|
||||
}
|
||||
} catch {
|
||||
setStatus('error')
|
||||
setErrorMsg('Network error. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
verify()
|
||||
// If the investor already has a valid session, skip the button entirely
|
||||
fetch('/api/auth/me').then(res => {
|
||||
if (res.ok) router.push('/')
|
||||
})
|
||||
}, [token, router])
|
||||
|
||||
const handleAccess = useCallback(async () => {
|
||||
if (!token || status === 'verifying') return
|
||||
setStatus('verifying')
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setStatus('success')
|
||||
setTimeout(() => router.push('/'), 1000)
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setStatus('error')
|
||||
setErrorMsg(data.error || 'Verification failed.')
|
||||
}
|
||||
} catch {
|
||||
setStatus('error')
|
||||
setErrorMsg('Network error. Please try again.')
|
||||
}
|
||||
}, [token, status, router])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
@@ -59,10 +58,28 @@ function VerifyContent() {
|
||||
className="relative z-10 text-center max-w-md mx-auto px-6"
|
||||
>
|
||||
<div className="bg-white/[0.03] border border-white/[0.06] rounded-2xl p-8 backdrop-blur-sm">
|
||||
{status === 'ready' && (
|
||||
<>
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-indigo-500/10 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.069A1 1 0 0121 8.87v6.26a1 1 0 01-1.447.894L15 14M3 8a2 2 0 012-2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-white/80 font-medium mb-1">Your pitch deck is ready</p>
|
||||
<p className="text-white/40 text-sm mb-6">Click below to access it.</p>
|
||||
<button
|
||||
onClick={handleAccess}
|
||||
className="w-full bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white font-medium py-3 px-6 rounded-xl transition-all shadow-lg shadow-indigo-500/20"
|
||||
>
|
||||
Access Pitch Deck
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'verifying' && (
|
||||
<>
|
||||
<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/60">Verifying your access link...</p>
|
||||
<p className="text-white/60">Verifying your access...</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -115,11 +132,10 @@ export default function VerifyPage() {
|
||||
<VerifyContent />
|
||||
</Suspense>
|
||||
|
||||
{/* Privacy Notice Footer */}
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10 px-8 py-4 border-t border-white/5">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<p className="text-[10px] text-white/20 leading-relaxed text-center">
|
||||
<strong className="text-white/25">Datenschutzhinweis (Art. 13 DSGVO):</strong> Beim Zugriff werden technische Zugriffsdaten (IP-Adresse, Zeitpunkt, Browser) sowie – soweit eingeladen – personenbezogene Kontaktdaten (E-Mail, Name, Unternehmen) verarbeitet. Zweck: Zugangsverwaltung und Missbrauchsprävention. Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse). Speicherdauer: max. 30 Tage nach letztem Zugriff; nicht aktivierte Zugänge nach 90 Tagen. Danach automatische Anonymisierung. Ihre Rechte gem. Art. 15–21 DSGVO (Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit, Widerspruch): Anfragen an pitch@breakpilot.ai. Beschwerderecht bei der Aufsichtsbehörde: LfDI Baden-Württemberg (www.baden-wuerttemberg.datenschutz.de).</p>
|
||||
<strong className="text-white/25">Datenschutzhinweis (Art. 13 DSGVO):</strong> Beim Zugriff werden technische Zugriffsdaten (IP-Adresse, Zeitpunkt, Browser) sowie – soweit eingeladen – personenbezogene Kontaktdaten (E-Mail, Name, Unternehmen) verarbeitet. Zweck: Zugangsverwaltung und Missbrauchsprävention. Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse). Speicherdauer: max. 30 Tage nach letztem Zugriff; nicht aktivierte Zugänge nach 90 Tagen. Danach automatische Anonymisierung. Ihre Rechte gem. Art. 15–21 DSGVO (Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit, Widerspruch): Anfragen an pitch@breakpilot.ai. Beschwerderecht bei der Aufsichtsbehörde: LfDI Baden-Württemberg (www.baden-wuerttemberg.datenschutz.de).</p>
|
||||
<p className="text-[10px] text-white/15 text-center mt-1">
|
||||
Verantwortlich: Benjamin Bönisch & Sharang Parnerkar · Kontakt: info@breakpilot.com
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
|
||||
interface Ctx { params: Promise<{ code: string }> }
|
||||
|
||||
const BASE_URL = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.com'
|
||||
|
||||
export async function GET(request: NextRequest, ctx: Ctx) {
|
||||
const { code } = await ctx.params
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT sl.token FROM pitch_short_links sl
|
||||
JOIN pitch_magic_links ml ON ml.token = sl.token
|
||||
WHERE sl.short_code = $1 AND ml.expires_at > NOW()`,
|
||||
[code.toLowerCase()],
|
||||
)
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.redirect(`${BASE_URL}/auth?error=invalid`)
|
||||
}
|
||||
|
||||
return NextResponse.redirect(`${BASE_URL}/auth/verify?token=${rows[0].token}`)
|
||||
}
|
||||
+13
-3
@@ -1,19 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { Language } from '@/lib/types'
|
||||
import { useAuth } from '@/lib/hooks/useAuth'
|
||||
import PitchDeck from '@/components/PitchDeck'
|
||||
|
||||
export default function Home() {
|
||||
const [lang, setLang] = useState<Language>('de')
|
||||
const { investor, loading, logout } = useAuth()
|
||||
const [lang, setLang] = useState<Language>('de')
|
||||
const [langReady, setLangReady] = useState(false)
|
||||
const synced = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !synced.current) {
|
||||
synced.current = true
|
||||
if (investor?.preferred_lang === 'en') setLang('en')
|
||||
setLangReady(true)
|
||||
}
|
||||
}, [loading, investor])
|
||||
|
||||
const toggleLanguage = useCallback(() => {
|
||||
setLang(prev => prev === 'de' ? 'en' : 'de')
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
if (loading || !langReady) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center">
|
||||
<div className="w-12 h-12 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
||||
|
||||
@@ -22,6 +22,7 @@ interface InvestorDetail {
|
||||
version_name: string | null
|
||||
version_status: string | null
|
||||
is_showcase: boolean
|
||||
preferred_lang: 'de' | 'en'
|
||||
}
|
||||
sessions: Array<{
|
||||
id: string
|
||||
@@ -115,11 +116,12 @@ export default function InvestorDetailPage() {
|
||||
setBusy(false)
|
||||
if (res.ok) {
|
||||
const d = await res.json()
|
||||
const link = d.short_url || d.url
|
||||
try {
|
||||
await navigator.clipboard.writeText(d.url)
|
||||
flashToast('Magic link copied to clipboard')
|
||||
await navigator.clipboard.writeText(link)
|
||||
flashToast('Short link copied to clipboard')
|
||||
} catch {
|
||||
flashToast(`Link (copy manually): ${d.url}`)
|
||||
flashToast(`Link (copy manually): ${link}`)
|
||||
}
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
@@ -324,6 +326,39 @@ export default function InvestorDetailPage() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Default language */}
|
||||
<div className="mt-4 pt-4 border-t border-white/[0.06]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm text-white font-medium">Default pitch language</div>
|
||||
<div className="text-xs text-white/40 mt-0.5">Language the deck opens in when the investor clicks the link</div>
|
||||
</div>
|
||||
<div className="flex rounded-lg border border-white/10 overflow-hidden">
|
||||
{(['de', 'en'] as const).map(l => (
|
||||
<button
|
||||
key={l}
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
if (inv.preferred_lang === l) return
|
||||
setBusy(true)
|
||||
const res = await fetch(`/api/admin/investors/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ preferred_lang: l }),
|
||||
})
|
||||
setBusy(false)
|
||||
if (res.ok) { flashToast(`Language set to ${l.toUpperCase()}`); load() }
|
||||
else { flashToast('Update failed') }
|
||||
}}
|
||||
className={`px-4 py-1.5 text-sm font-medium transition-colors disabled:opacity-50 ${inv.preferred_lang === l ? 'bg-indigo-600 text-white' : 'bg-black/20 text-white/50 hover:text-white/80'}`}
|
||||
>
|
||||
{l === 'de' ? '🇩🇪 DE' : '🇬🇧 EN'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Showcase toggle */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-white/[0.06]">
|
||||
<div>
|
||||
|
||||
@@ -1,24 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState, useMemo, useEffect, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Eye, Send } from 'lucide-react'
|
||||
import { DEFAULT_MESSAGE, DEFAULT_CLOSING, getDefaultGreeting } from '@/lib/email-templates'
|
||||
import { ArrowLeft, Eye, Send, Link2, Copy, Check, Mail, MailX, Layers } from 'lucide-react'
|
||||
import {
|
||||
DEFAULT_MESSAGE, DEFAULT_CLOSING,
|
||||
DEFAULT_MESSAGE_EN, DEFAULT_CLOSING_EN,
|
||||
getDefaultGreeting,
|
||||
} from '@/lib/email-templates'
|
||||
|
||||
type Lang = 'de' | 'en'
|
||||
|
||||
interface Version {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export default function NewInvestorPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [company, setCompany] = useState('')
|
||||
const [lang, setLang] = useState<Lang>('de')
|
||||
const [sendEmail, setSendEmail] = useState(true)
|
||||
const [versionId, setVersionId] = useState('')
|
||||
const [versions, setVersions] = useState<Version[]>([])
|
||||
const [greeting, setGreeting] = useState('')
|
||||
const [message, setMessage] = useState(DEFAULT_MESSAGE)
|
||||
const [closing, setClosing] = useState(DEFAULT_CLOSING)
|
||||
const [messageEdited, setMessageEdited] = useState(false)
|
||||
const [closingEdited, setClosingEdited] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [createdLink, setCreatedLink] = useState<{ short: string; full: string } | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const prevLang = useRef<Lang>('de')
|
||||
|
||||
const effectiveGreeting = greeting || getDefaultGreeting(name || null)
|
||||
const ttl = process.env.NEXT_PUBLIC_MAGIC_LINK_TTL_HOURS || '72'
|
||||
const effectiveGreeting = greeting || getDefaultGreeting(name || null, lang)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/versions')
|
||||
.then(r => r.json())
|
||||
.then(d => setVersions((d.versions ?? []).filter((v: Version) => v.status === 'committed')))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// When language changes, swap defaults unless the user has manually edited
|
||||
useEffect(() => {
|
||||
if (lang === prevLang.current) return
|
||||
prevLang.current = lang
|
||||
const defaults = lang === 'en'
|
||||
? { message: DEFAULT_MESSAGE_EN, closing: DEFAULT_CLOSING_EN }
|
||||
: { message: DEFAULT_MESSAGE, closing: DEFAULT_CLOSING }
|
||||
if (!messageEdited) setMessage(defaults.message)
|
||||
if (!closingEdited) setClosing(defaults.closing)
|
||||
}, [lang, messageEdited, closingEdited])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
@@ -32,14 +72,24 @@ export default function NewInvestorPage() {
|
||||
email,
|
||||
name,
|
||||
company,
|
||||
greeting: effectiveGreeting,
|
||||
message,
|
||||
closing,
|
||||
lang,
|
||||
send_email: sendEmail,
|
||||
version_id: versionId || null,
|
||||
...(sendEmail && {
|
||||
greeting: effectiveGreeting,
|
||||
message,
|
||||
closing,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
router.push('/pitch-admin/investors')
|
||||
router.refresh()
|
||||
const data = await res.json()
|
||||
if (!sendEmail) {
|
||||
setCreatedLink({ short: data.short_url, full: data.magic_link_url })
|
||||
} else {
|
||||
router.push('/pitch-admin/investors')
|
||||
router.refresh()
|
||||
}
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setError(data.error || 'Invite failed')
|
||||
@@ -51,8 +101,66 @@ export default function NewInvestorPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
if (!createdLink) return
|
||||
await navigator.clipboard.writeText(createdLink.short)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const closingHtml = useMemo(() => closing.replace(/\n/g, '<br>'), [closing])
|
||||
|
||||
// Success state: link was generated without email
|
||||
if (createdLink) {
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<Link
|
||||
href="/pitch-admin/investors"
|
||||
className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80 mb-6"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to investors
|
||||
</Link>
|
||||
|
||||
<div className="bg-white/[0.04] border border-white/[0.08] rounded-2xl p-8 text-center space-y-6">
|
||||
<div className="w-14 h-14 rounded-full bg-green-500/15 border border-green-500/30 flex items-center justify-center mx-auto">
|
||||
<Link2 className="w-7 h-7 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white mb-1">Investor created</h2>
|
||||
<p className="text-sm text-white/50">No email was sent. Copy the magic link below to use in your outreach.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-black/40 border border-white/10 rounded-xl p-4 text-left space-y-3">
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-white/40 uppercase tracking-wider mb-1">Short Link <span className="text-green-400/70 normal-case font-normal">(use this in emails)</span></p>
|
||||
<p className="text-sm text-indigo-300 font-mono">{createdLink?.short}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold text-white/25 uppercase tracking-wider mb-1">Full Link</p>
|
||||
<p className="text-[10px] text-white/30 break-all leading-relaxed font-mono">{createdLink?.full}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className="inline-flex items-center gap-2 bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-5 py-2.5 rounded-lg shadow-lg shadow-indigo-500/20"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
{copied ? 'Copied!' : 'Copy link'}
|
||||
</button>
|
||||
<Link
|
||||
href="/pitch-admin/investors"
|
||||
className="text-sm text-white/60 hover:text-white px-4 py-2"
|
||||
>
|
||||
Back to investors
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl">
|
||||
<Link
|
||||
@@ -64,7 +172,9 @@ export default function NewInvestorPage() {
|
||||
|
||||
<h1 className="text-2xl font-semibold text-white mb-2">Investor einladen</h1>
|
||||
<p className="text-sm text-white/50 mb-6">
|
||||
Der Investor erhaelt eine Email mit einem persoenlichen Magic Link (einmalig, verfaellt nach {ttl}h).
|
||||
{sendEmail
|
||||
? `Der Investor erhält eine Email mit einem persönlichen Magic Link (einmalig, verfällt nach ${ttl}h).`
|
||||
: `Erstellt den Investor-Datensatz und generiert einen Magic Link — keine Email wird gesendet.`}
|
||||
</p>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
@@ -120,51 +230,137 @@ export default function NewInvestorPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pitch Version */}
|
||||
<div>
|
||||
<label htmlFor="version" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
<span className="inline-flex items-center gap-1.5"><Layers className="w-3.5 h-3.5" /> Pitch-Version <span className="text-white/30 text-[10px] normal-case">(optional)</span></span>
|
||||
</label>
|
||||
<select
|
||||
id="version"
|
||||
value={versionId}
|
||||
onChange={(e) => setVersionId(e.target.value)}
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 appearance-none"
|
||||
>
|
||||
<option value="">— Standard (keine spezifische Version) —</option>
|
||||
{versions.map(v => (
|
||||
<option key={v.id} value={v.id}>{v.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{versions.length === 0 && (
|
||||
<p className="text-[10px] text-white/25 mt-1">Keine committed Versionen vorhanden</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr className="border-white/[0.06]" />
|
||||
|
||||
{/* Greeting */}
|
||||
<div>
|
||||
<label htmlFor="greeting" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Anrede
|
||||
</label>
|
||||
<input
|
||||
id="greeting"
|
||||
type="text"
|
||||
value={greeting}
|
||||
onChange={(e) => setGreeting(e.target.value)}
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
placeholder={getDefaultGreeting(name || null)}
|
||||
/>
|
||||
<p className="text-[10px] text-white/25 mt-1">Leer lassen fuer automatische Anrede basierend auf dem Namen</p>
|
||||
{/* Language toggle + Send email toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Pitch language */}
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Pitch-Sprache</p>
|
||||
<div className="flex rounded-lg border border-white/10 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLang('de')}
|
||||
className={`flex-1 py-2 text-sm font-medium transition-colors ${lang === 'de' ? 'bg-indigo-600 text-white' : 'bg-black/20 text-white/50 hover:text-white/80'}`}
|
||||
>
|
||||
🇩🇪 DE
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLang('en')}
|
||||
className={`flex-1 py-2 text-sm font-medium transition-colors ${lang === 'en' ? 'bg-indigo-600 text-white' : 'bg-black/20 text-white/50 hover:text-white/80'}`}
|
||||
>
|
||||
🇬🇧 EN
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-white/25 mt-1">Standardsprache beim Öffnen des Decks</p>
|
||||
</div>
|
||||
|
||||
{/* Send email toggle */}
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Email senden</p>
|
||||
<div className="flex rounded-lg border border-white/10 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSendEmail(true)}
|
||||
className={`flex-1 py-2 text-xs font-medium transition-colors flex items-center justify-center gap-1.5 ${sendEmail ? 'bg-indigo-600 text-white' : 'bg-black/20 text-white/50 hover:text-white/80'}`}
|
||||
>
|
||||
<Mail className="w-3.5 h-3.5" /> Ja
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSendEmail(false)}
|
||||
className={`flex-1 py-2 text-xs font-medium transition-colors flex items-center justify-center gap-1.5 ${!sendEmail ? 'bg-amber-600 text-white' : 'bg-black/20 text-white/50 hover:text-white/80'}`}
|
||||
>
|
||||
<MailX className="w-3.5 h-3.5" /> Nein
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-white/25 mt-1">Nein = nur Link generieren</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Nachricht
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/40 resize-y"
|
||||
/>
|
||||
</div>
|
||||
{/* Email customization — only shown when sendEmail=true */}
|
||||
{sendEmail && (
|
||||
<>
|
||||
<hr className="border-white/[0.06]" />
|
||||
|
||||
{/* Closing */}
|
||||
<div>
|
||||
<label htmlFor="closing" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Grussformel
|
||||
</label>
|
||||
<textarea
|
||||
id="closing"
|
||||
value={closing}
|
||||
onChange={(e) => setClosing(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/40 resize-y"
|
||||
/>
|
||||
</div>
|
||||
{/* Greeting */}
|
||||
<div>
|
||||
<label htmlFor="greeting" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Anrede / Greeting
|
||||
</label>
|
||||
<input
|
||||
id="greeting"
|
||||
type="text"
|
||||
value={greeting}
|
||||
onChange={(e) => setGreeting(e.target.value)}
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
|
||||
placeholder={getDefaultGreeting(name || null, lang)}
|
||||
/>
|
||||
<p className="text-[10px] text-white/25 mt-1">
|
||||
{lang === 'de' ? 'Leer lassen für automatische Anrede basierend auf dem Namen' : 'Leave empty for automatic greeting based on name'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Nachricht / Message
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
value={message}
|
||||
onChange={(e) => { setMessage(e.target.value); setMessageEdited(true) }}
|
||||
rows={4}
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/40 resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Closing */}
|
||||
<div>
|
||||
<label htmlFor="closing" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
|
||||
Grussformel / Closing
|
||||
</label>
|
||||
<textarea
|
||||
id="closing"
|
||||
value={closing}
|
||||
onChange={(e) => { setClosing(e.target.value); setClosingEdited(true) }}
|
||||
rows={3}
|
||||
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/40 resize-y"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!sendEmail && (
|
||||
<div className="flex items-start gap-3 bg-amber-500/[0.08] border border-amber-500/20 rounded-xl p-4">
|
||||
<MailX className="w-4 h-4 text-amber-400 mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-amber-300/80 leading-relaxed">
|
||||
Kein Email wird gesendet. Nach dem Erstellen erhältst du den Magic Link zum manuellen Einfügen in deine Outreach-Email.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-rose-300 bg-rose-500/10 border border-rose-500/20 rounded-lg px-3 py-2">
|
||||
@@ -184,80 +380,108 @@ export default function NewInvestorPage() {
|
||||
disabled={submitting}
|
||||
className="inline-flex items-center gap-2 bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-5 py-2.5 rounded-lg disabled:opacity-50 shadow-lg shadow-indigo-500/20"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
{submitting ? 'Wird gesendet...' : 'Einladung senden'}
|
||||
{sendEmail ? <Send className="w-4 h-4" /> : <Link2 className="w-4 h-4" />}
|
||||
{submitting
|
||||
? (sendEmail ? 'Wird gesendet...' : 'Wird erstellt...')
|
||||
: (sendEmail ? 'Einladung senden' : 'Erstellen & Link generieren')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Right: Email Preview */}
|
||||
<div className="self-start">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Eye className="w-4 h-4 text-white/40" />
|
||||
<h3 className="text-sm font-semibold text-white/60 uppercase tracking-wider">Email-Vorschau</h3>
|
||||
</div>
|
||||
<div className="bg-[#0a0a1a] border border-white/[0.08] rounded-2xl p-4 overflow-y-auto max-h-[80vh]">
|
||||
{/* Email Card */}
|
||||
<div className="bg-[#111127] border border-indigo-500/20 rounded-xl overflow-hidden">
|
||||
{/* Right: Email Preview (only when sendEmail=true) */}
|
||||
{sendEmail ? (
|
||||
<div className="self-start">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Eye className="w-4 h-4 text-white/40" />
|
||||
<h3 className="text-sm font-semibold text-white/60 uppercase tracking-wider">Email-Vorschau</h3>
|
||||
</div>
|
||||
<div className="bg-[#0a0a1a] border border-white/[0.08] rounded-2xl p-4 overflow-y-auto max-h-[80vh]">
|
||||
<div className="bg-[#111127] border border-indigo-500/20 rounded-xl overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-6 pb-3">
|
||||
<p className="text-lg font-semibold text-[#e0e0ff]">BreakPilot ComplAI</p>
|
||||
<p className="text-xs text-white/40 mt-1">Investor Pitch Deck</p>
|
||||
</div>
|
||||
<div className="px-6 pt-6 pb-3">
|
||||
<p className="text-lg font-semibold text-[#e0e0ff]">BreakPilot ComplAI</p>
|
||||
<p className="text-xs text-white/40 mt-1">Investor Pitch Deck</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-3">
|
||||
<p className="text-sm text-white/80 mb-3">{effectiveGreeting},</p>
|
||||
<p className="text-sm text-white/70 leading-relaxed mb-4">{message}</p>
|
||||
<div className="px-6 py-3">
|
||||
<p className="text-sm text-white/80 mb-3">{effectiveGreeting},</p>
|
||||
<p className="text-sm text-white/70 leading-relaxed mb-4" dangerouslySetInnerHTML={{ __html: message }} />
|
||||
|
||||
{/* Magic Link Box */}
|
||||
<div className="bg-indigo-500/[0.08] border border-indigo-500/[0.15] rounded-lg p-3 mb-4">
|
||||
<p className="text-[10px] font-semibold text-indigo-400/80 uppercase tracking-wider mb-1">
|
||||
Ihr persoenlicher Zugangslink
|
||||
<div className="bg-indigo-500/[0.08] border border-indigo-500/[0.15] rounded-lg p-3 mb-4">
|
||||
<p className="text-[10px] font-semibold text-indigo-400/80 uppercase tracking-wider mb-1">
|
||||
{lang === 'en' ? 'Your Personal Access Link' : 'Ihr persönlicher Zugangslink'}
|
||||
</p>
|
||||
<p className="text-xs text-white/50 leading-relaxed">
|
||||
{lang === 'en'
|
||||
? `The link below is single-use and expires after ${ttl} hours. It grants you exclusive access to our interactive pitch deck — including an AI assistant for your questions.`
|
||||
: `Der untenstehende Link ist einmalig und verfällt nach ${ttl} Stunden. Er gewährt Ihnen exklusiven Zugang zu unserem interaktiven Pitch Deck — inklusive KI-Assistent für Ihre Fragen.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-3">
|
||||
<span className="inline-block bg-gradient-to-r from-indigo-500 to-purple-600 text-white text-sm font-semibold px-8 py-2.5 rounded-lg">
|
||||
{lang === 'en' ? 'Open Pitch Deck' : 'Pitch Deck öffnen'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-white/25 mb-4 break-all">
|
||||
{lang === 'en' ? "If the button doesn't work: " : 'Falls der Button nicht funktioniert: '}
|
||||
https://pitch.breakpilot.ai/auth/verify?token=...
|
||||
</p>
|
||||
<p className="text-xs text-white/50 leading-relaxed">
|
||||
Der untenstehende Link ist einmalig und verfaellt nach {ttl} Stunden. Er gewaehrt Ihnen exklusiven Zugang zu unserem interaktiven Pitch Deck — inklusive KI-Assistent fuer Ihre Fragen.
|
||||
|
||||
<p className="text-sm text-white/70 leading-relaxed" dangerouslySetInnerHTML={{ __html: closingHtml }} />
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-white/[0.05]">
|
||||
<p className="text-[9px] font-semibold text-white/30 uppercase tracking-wider mb-1">
|
||||
Vertraulichkeit & Haftungsausschluss
|
||||
</p>
|
||||
<p className="text-[9px] text-white/[0.18] leading-relaxed mb-2">
|
||||
Dieses Pitch Deck ist vertraulich und wurde ausschließlich für den namentlich eingeladenen Empfänger erstellt. Keine Weitergabe. 3-Jahres-Pflicht. Kein Angebot. Planzahlen ohne Garantie. Totalverlustrisiko. Deutsches Recht, Konstanz.
|
||||
</p>
|
||||
<p className="text-[9px] font-semibold text-white/20 uppercase tracking-wider mb-1">
|
||||
Confidentiality & Disclaimer
|
||||
</p>
|
||||
<p className="text-[9px] text-white/[0.13] leading-relaxed">
|
||||
Confidential. Purpose-limited. 3-year obligation. Not an offer. Projections only. Risk of total loss. German law, Konstanz jurisdiction.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Button Preview */}
|
||||
<div className="text-center mb-3">
|
||||
<span className="inline-block bg-gradient-to-r from-indigo-500 to-purple-600 text-white text-sm font-semibold px-8 py-2.5 rounded-lg">
|
||||
Pitch Deck oeffnen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-white/25 mb-4 break-all">
|
||||
Falls der Button nicht funktioniert: https://pitch.breakpilot.ai/auth/verify?token=...
|
||||
</p>
|
||||
|
||||
{/* Closing */}
|
||||
<p className="text-sm text-white/70 leading-relaxed" dangerouslySetInnerHTML={{ __html: closingHtml }} />
|
||||
</div>
|
||||
|
||||
{/* Legal Footer */}
|
||||
<div className="px-6 py-4 border-t border-white/[0.05]">
|
||||
<p className="text-[9px] font-semibold text-white/30 uppercase tracking-wider mb-1">
|
||||
Vertraulichkeit & Haftungsausschluss
|
||||
</p>
|
||||
<p className="text-[9px] text-white/[0.18] leading-relaxed mb-2">
|
||||
Dieses Pitch Deck ist vertraulich und wurde ausschliesslich fuer den namentlich eingeladenen Empfaenger erstellt. Durch das Oeffnen des Links erklaert sich der Empfaenger einverstanden: (a) Vertrauliche Behandlung, keine Weitergabe an Dritte. (b) Nutzung ausschliesslich zur Bewertung einer Beteiligung. (c) Vertraulichkeitspflicht fuer 3 Jahre.
|
||||
</p>
|
||||
<p className="text-[9px] text-white/[0.18] leading-relaxed mb-3">
|
||||
Kein Angebot, kein Prospekt. Planzahlen ohne Garantie. Totalverlustrisiko. Deutsches Recht, Gerichtsstand Konstanz.
|
||||
</p>
|
||||
<p className="text-[9px] font-semibold text-white/20 uppercase tracking-wider mb-1">
|
||||
Confidentiality & Disclaimer
|
||||
</p>
|
||||
<p className="text-[9px] text-white/[0.13] leading-relaxed">
|
||||
Confidential. Purpose-limited. 3-year obligation. Not an offer. Projections only. Risk of total loss. German law, Konstanz jurisdiction.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="self-start">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Link2 className="w-4 h-4 text-white/40" />
|
||||
<h3 className="text-sm font-semibold text-white/60 uppercase tracking-wider">Link-Modus</h3>
|
||||
</div>
|
||||
<div className="bg-white/[0.03] border border-white/[0.06] rounded-2xl p-6 space-y-4">
|
||||
<p className="text-sm text-white/60 leading-relaxed">
|
||||
Im Link-Modus wird kein Email gesendet. Stattdessen:
|
||||
</p>
|
||||
<ol className="space-y-3 text-sm text-white/50">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-5 h-5 rounded-full bg-indigo-500/20 text-indigo-400 text-xs flex items-center justify-center shrink-0 mt-0.5">1</span>
|
||||
Investor-Datensatz wird erstellt (Email, Name, Unternehmen)
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-5 h-5 rounded-full bg-indigo-500/20 text-indigo-400 text-xs flex items-center justify-center shrink-0 mt-0.5">2</span>
|
||||
Persönlicher Magic Link wird generiert (einmalig, {ttl}h gültig)
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="w-5 h-5 rounded-full bg-indigo-500/20 text-indigo-400 text-xs flex items-center justify-center shrink-0 mt-0.5">3</span>
|
||||
Link erscheint hier zum Kopieren — füge ihn in deine Cold-Email ein
|
||||
</li>
|
||||
</ol>
|
||||
<div className="mt-4 bg-amber-500/[0.06] border border-amber-500/15 rounded-lg p-3">
|
||||
<p className="text-xs text-amber-300/70">
|
||||
Pitch-Sprache: <strong>{lang === 'en' ? 'Englisch' : 'Deutsch'}</strong> — das Deck öffnet sich beim ersten Klick in dieser Sprache.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -59,11 +59,12 @@ export default function InvestorsPage() {
|
||||
setBusy(null)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const link = data.short_url || data.url
|
||||
try {
|
||||
await navigator.clipboard.writeText(data.url)
|
||||
flashToast('Magic link copied to clipboard')
|
||||
await navigator.clipboard.writeText(link)
|
||||
flashToast('Short link copied to clipboard')
|
||||
} catch {
|
||||
flashToast(`Link (copy manually): ${data.url}`)
|
||||
flashToast(`Link (copy manually): ${link}`)
|
||||
}
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
SheetRow, MONTH_LABELS, FORMULA_TOOLTIPS,
|
||||
SheetRow, MONTH_LABELS, MONTH_LABELS_EN, FORMULA_TOOLTIPS, FORMULA_TOOLTIPS_EN, ROW_LABEL_MAP,
|
||||
getLabel, getValues, formatCell,
|
||||
} from './FinanzplanSlide.helpers'
|
||||
|
||||
/* ── Tooltip for formula-linked labels ── */
|
||||
|
||||
function LabelWithTooltip({ label }: { label: string }) {
|
||||
const tooltip = FORMULA_TOOLTIPS[label]
|
||||
if (!tooltip) return <span>{label}</span>
|
||||
function LabelWithTooltip({ label, de }: { label: string; de: boolean }) {
|
||||
const displayLabel = de ? label : (ROW_LABEL_MAP[label] ?? label)
|
||||
const tooltip = de ? FORMULA_TOOLTIPS[label] : (FORMULA_TOOLTIPS_EN[label] ?? FORMULA_TOOLTIPS[label])
|
||||
if (!tooltip) return <span>{displayLabel}</span>
|
||||
return (
|
||||
<span className="group relative cursor-help">
|
||||
{label}
|
||||
{displayLabel}
|
||||
<span className="invisible group-hover:visible absolute left-0 top-full mt-1 z-50 bg-slate-800 border border-white/10 text-[9px] text-white/70 px-2 py-1 rounded shadow-lg whitespace-nowrap">
|
||||
{tooltip}
|
||||
</span>
|
||||
@@ -51,7 +52,7 @@ export function GuvTable({ rows, de }: GuvTableProps) {
|
||||
return (
|
||||
<tr key={row.id} className={`${isMajorSum ? 'border-t-2 border-t-white/20 border-b border-b-white/[0.05] bg-white/[0.05]' : isMinorSum ? 'border-t border-t-white/10 border-b border-b-white/[0.03] bg-white/[0.03]' : 'border-b border-white/[0.03]'} hover:bg-white/[0.02]`}>
|
||||
<td className={`py-1.5 px-2 sticky left-0 bg-slate-900/90 backdrop-blur ${isMajorSum ? 'font-bold text-white text-xs' : isMinorSum ? 'font-semibold text-white/80' : 'text-white/60'}`}>
|
||||
<LabelWithTooltip label={label} />
|
||||
<LabelWithTooltip label={label} de={de} />
|
||||
</td>
|
||||
{[2026, 2027, 2028, 2029, 2030].map(y => {
|
||||
const v = values[`y${y}`] || 0
|
||||
@@ -195,7 +196,7 @@ export function MonthlyGrid({ rows, activeSheet, de, yearOffset, openCats, toggl
|
||||
<th className="text-right py-1.5 px-2 text-white/60 font-medium min-w-[70px]">
|
||||
{currentYear}
|
||||
</th>
|
||||
{MONTH_LABELS.map((label, idx) => (
|
||||
{(de ? MONTH_LABELS : MONTH_LABELS_EN).map((label, idx) => (
|
||||
<th key={idx} className="text-right py-1.5 px-1.5 text-white/50 font-normal min-w-[55px]">
|
||||
{label}
|
||||
</th>
|
||||
@@ -241,7 +242,7 @@ export function MonthlyGrid({ rows, activeSheet, de, yearOffset, openCats, toggl
|
||||
<div className="flex items-center gap-1">
|
||||
{isCatHeader && <span className="text-[10px] text-indigo-400 w-3 shrink-0">{isCatOpen ? '▾' : '▸'}</span>}
|
||||
{isEditable && <span className="w-1 h-1 rounded-full bg-indigo-400 flex-shrink-0" />}
|
||||
<span className="truncate"><LabelWithTooltip label={label} /></span>
|
||||
<span className="truncate"><LabelWithTooltip label={label} de={de} /></span>
|
||||
{row.position && <span className="text-white/50 ml-1">({row.position})</span>}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -37,10 +37,86 @@ export const MONTH_LABELS = [
|
||||
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez',
|
||||
]
|
||||
|
||||
export const MONTH_LABELS_EN = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||
]
|
||||
|
||||
export function getLabel(row: SheetRow): string {
|
||||
return row.row_label || row.person_name || row.item_name || '—'
|
||||
}
|
||||
|
||||
// German → English row label translations
|
||||
export const ROW_LABEL_MAP: Record<string, string> = {
|
||||
// GuV / P&L
|
||||
'Umsatzerlöse': 'Revenue',
|
||||
'Materialaufwand': 'Cost of Materials',
|
||||
'Material-/Wareneinsatz': 'Materials / COGS',
|
||||
'Fremdleistungen': 'External Services',
|
||||
'Rohertrag': 'Gross Profit',
|
||||
'Rohergebnis': 'Gross Result',
|
||||
'Gesamtleistung': 'Total Output',
|
||||
'Personalkosten': 'Personnel Costs',
|
||||
'Abschreibungen': 'Depreciation',
|
||||
'Betriebliche Aufwendungen': 'Operating Expenses',
|
||||
'Sonstige betriebliche Aufwendungen': 'Other Operating Expenses',
|
||||
'EBIT': 'EBIT',
|
||||
'Betriebsergebnis': 'Operating Result',
|
||||
'Zinsergebnis': 'Net Interest',
|
||||
'Zinsaufwand': 'Interest Expense',
|
||||
'Zinsertrag': 'Interest Income',
|
||||
'Ergebnis vor Steuern': 'Earnings Before Tax',
|
||||
'EBT': 'EBT',
|
||||
'Gewerbesteuer': 'Trade Tax',
|
||||
'Körperschaftsteuer': 'Corporate Tax',
|
||||
'Steuern gesamt': 'Total Taxes',
|
||||
'Steuern': 'Taxes',
|
||||
'Jahresüberschuss': 'Net Income',
|
||||
'Jahresfehlbetrag': 'Net Loss',
|
||||
'Ergebnis nach Steuern': 'Net Result',
|
||||
// Liquidität
|
||||
'Einzahlungen': 'Cash Inflows',
|
||||
'Summe Einzahlungen': 'Total Inflows',
|
||||
'Auszahlungen': 'Cash Outflows',
|
||||
'Summe Auszahlungen': 'Total Outflows',
|
||||
'Überschuss/Fehlbetrag': 'Surplus / Deficit',
|
||||
'ÜBERSCHUSS': 'SURPLUS',
|
||||
'FEHLBETRAG': 'DEFICIT',
|
||||
'ÜBERSCHUSS/FEHLBETRAG': 'SURPLUS / DEFICIT',
|
||||
'Kontostand': 'Account Balance',
|
||||
'Kontostand (Anfang)': 'Opening Balance',
|
||||
'Kontostand (Ende)': 'Closing Balance',
|
||||
'LIQUIDITÄT': 'LIQUIDITY',
|
||||
'LIQUIDITAET': 'LIQUIDITY',
|
||||
'Kredittilgung': 'Loan Repayment',
|
||||
'Zins- und Tilgungszahlung': 'Interest & Principal',
|
||||
'Investitionen': 'Investments',
|
||||
'Sonstige Erträge': 'Other Income',
|
||||
// Kunden
|
||||
'Neukunden': 'New Customers',
|
||||
'Bestandskunden': 'Existing Customers',
|
||||
'Bestandskunden gesamt': 'Total Existing Customers',
|
||||
'Anzahl Kunden': 'Customer Count',
|
||||
'GESAMT': 'TOTAL',
|
||||
'GESAMTUMSATZ': 'TOTAL REVENUE',
|
||||
// Betriebliche Aufwendungen
|
||||
'Fort-/Weiterbildungskosten (F)': 'Training & Development (F)',
|
||||
'Fahrzeugkosten (F)': 'Vehicle Costs (F)',
|
||||
'KFZ-Steuern (F)': 'Vehicle Tax (F)',
|
||||
'KFZ-Versicherung (F)': 'Vehicle Insurance (F)',
|
||||
'Reisekosten (F)': 'Travel Expenses (F)',
|
||||
'Bewirtungskosten (F)': 'Entertainment Costs (F)',
|
||||
'Internet/Mobilfunk (F)': 'Internet / Mobile (F)',
|
||||
'Cloud-Hosting (SysEleven/Hetzner)': 'Cloud Hosting (SysEleven/Hetzner)',
|
||||
'Berufsgenossenschaft (F)': 'Employers\' Liability Insurance (F)',
|
||||
'Allgemeine Marketingkosten (F)': 'General Marketing Costs (F)',
|
||||
'Gewerbesteuer (F)': 'Trade Tax (F)',
|
||||
'Summe sonstige Aufwendungen': 'Total Other Expenses',
|
||||
'SUMME Betriebliche Aufwendungen': 'TOTAL Operating Expenses',
|
||||
'SUMME': 'TOTAL',
|
||||
'Summe': 'Total',
|
||||
}
|
||||
|
||||
export const FORMULA_TOOLTIPS: Record<string, string> = {
|
||||
'Fort-/Weiterbildungskosten (F)': 'Mitarbeiter (ohne Gründer) × 300 EUR/Mon',
|
||||
'Fahrzeugkosten (F)': 'Mitarbeiter (ohne Gründer) × 200 EUR/Mon',
|
||||
@@ -57,6 +133,22 @@ export const FORMULA_TOOLTIPS: Record<string, string> = {
|
||||
'Abschreibungen': 'Summe AfA aus Tab Investitionen',
|
||||
}
|
||||
|
||||
export const FORMULA_TOOLTIPS_EN: Record<string, string> = {
|
||||
'Fort-/Weiterbildungskosten (F)': 'Employees (excl. founders) × €300/month',
|
||||
'Fahrzeugkosten (F)': 'Employees (excl. founders) × €200/month',
|
||||
'KFZ-Steuern (F)': 'Employees (excl. founders) × €25/month',
|
||||
'KFZ-Versicherung (F)': 'Employees (excl. founders) × €150/month',
|
||||
'Reisekosten (F)': 'Total headcount × €75/month',
|
||||
'Bewirtungskosten (F)': 'Existing customers × €50/month',
|
||||
'Internet/Mobilfunk (F)': 'Total headcount × €50/month',
|
||||
'Cloud-Hosting (SysEleven/Hetzner)': '€1,500 base + (customers − 10) × €100 (first 10 included)',
|
||||
'Berufsgenossenschaft (F)': '0.5% of gross payroll (VBG IT/Office)',
|
||||
'Allgemeine Marketingkosten (F)': '8% of revenue (2026–2028), 10% from 2029',
|
||||
'Gewerbesteuer (F)': '12.25% of profit (rate 3.5% × multiplier 350%, only when profitable)',
|
||||
'Personalkosten': 'Sum from Personnel Costs tab',
|
||||
'Abschreibungen': 'Sum of depreciation from Investments tab',
|
||||
}
|
||||
|
||||
export function getValues(row: SheetRow): Record<string, number> {
|
||||
return row.values || row.values_total || row.values_brutto || (row as Record<string, unknown>).values_invest as Record<string, number> || {}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,19 @@ export const DEFAULT_MESSAGE =
|
||||
export const DEFAULT_CLOSING =
|
||||
'Gerne stehen wir Ihnen für einen persönlichen Austausch oder eine vertiefende Diskussion jederzeit zur Verfügung.\n\nMit freundlichen Grüßen,\nBenjamin Bönisch & Sharang Parnerkar\nGründer — BreakPilot'
|
||||
|
||||
export function getDefaultGreeting(name: string | null): string {
|
||||
export const DEFAULT_GREETING_EN = 'Dear Sir or Madam'
|
||||
export const DEFAULT_MESSAGE_EN =
|
||||
'we are delighted to grant you exclusive access to the interactive investor pitch deck of our planned venture <strong>BreakPilot</strong>.<br><br>BreakPilot addresses a core challenge facing modern software development: maintaining continuous regulatory compliance while sustaining high development velocity.<br><br>Our planned solution combines code security, automated compliance, and regulatory intelligence in an end-to-end platform. The goal is not only to surface deviations, but to actively guide organizations on how to operate their systems in maximum regulatory conformance and optimal performance — particularly in the context of AI systems and the EU AI Act.<br><br>The pitch deck provides a structured overview of the problem space, solution architecture, and our planned financing strategy.'
|
||||
export const DEFAULT_CLOSING_EN =
|
||||
'We would be delighted to connect for a personal exchange or a deeper discussion at any time.\n\nBest regards,\nBenjamin Bönisch & Sharang Parnerkar\nFounders — BreakPilot'
|
||||
|
||||
export function getDefaultGreeting(name: string | null, lang: 'de' | 'en' = 'de'): string {
|
||||
if (lang === 'en') return name ? `Dear ${name}` : DEFAULT_GREETING_EN
|
||||
return name ? `Sehr geehrter Herr ${name}` : DEFAULT_GREETING
|
||||
}
|
||||
|
||||
export function getDefaults(lang: 'de' | 'en') {
|
||||
return lang === 'en'
|
||||
? { message: DEFAULT_MESSAGE_EN, closing: DEFAULT_CLOSING_EN }
|
||||
: { message: DEFAULT_MESSAGE, closing: DEFAULT_CLOSING }
|
||||
}
|
||||
|
||||
+29
-9
@@ -3,10 +3,16 @@ import nodemailer from 'nodemailer'
|
||||
import {
|
||||
DEFAULT_MESSAGE,
|
||||
DEFAULT_CLOSING,
|
||||
DEFAULT_MESSAGE_EN,
|
||||
DEFAULT_CLOSING_EN,
|
||||
getDefaultGreeting,
|
||||
} from '@/lib/email-templates'
|
||||
|
||||
export { DEFAULT_GREETING, DEFAULT_MESSAGE, DEFAULT_CLOSING, getDefaultGreeting } from '@/lib/email-templates'
|
||||
export {
|
||||
DEFAULT_GREETING, DEFAULT_MESSAGE, DEFAULT_CLOSING,
|
||||
DEFAULT_GREETING_EN, DEFAULT_MESSAGE_EN, DEFAULT_CLOSING_EN,
|
||||
getDefaultGreeting, getDefaults,
|
||||
} from '@/lib/email-templates'
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
@@ -28,17 +34,31 @@ export async function sendMagicLinkEmail(
|
||||
greeting?: string,
|
||||
message?: string,
|
||||
closing?: string,
|
||||
lang: 'de' | 'en' = 'de',
|
||||
): Promise<void> {
|
||||
const effectiveGreeting = greeting || getDefaultGreeting(investorName)
|
||||
const effectiveMessage = message || DEFAULT_MESSAGE
|
||||
const effectiveClosing = closing || DEFAULT_CLOSING
|
||||
const effectiveGreeting = greeting || getDefaultGreeting(investorName, lang)
|
||||
const effectiveMessage = message || (lang === 'en' ? DEFAULT_MESSAGE_EN : DEFAULT_MESSAGE)
|
||||
const effectiveClosing = closing || (lang === 'en' ? DEFAULT_CLOSING_EN : DEFAULT_CLOSING)
|
||||
const closingHtml = effectiveClosing.replace(/\n/g, '<br>')
|
||||
const ttl = process.env.MAGIC_LINK_TTL_HOURS || '72'
|
||||
|
||||
const subject = lang === 'en'
|
||||
? 'BreakPilot ComplAI — Your Personal Pitch Deck Access'
|
||||
: 'BreakPilot ComplAI — Ihr persönlicher Pitch-Deck-Zugang'
|
||||
|
||||
const accessLabel = lang === 'en' ? 'Your Personal Access Link' : 'Ihr persönlicher Zugang'
|
||||
const accessBody = lang === 'en'
|
||||
? `The following link has been generated exclusively for you, is single-use, and valid for ${ttl} hours for security reasons. It grants you access to our interactive pitch deck — including an integrated AI assistant for further questions.`
|
||||
: `Der folgende Link ist individuell für Sie generiert, einmalig nutzbar und aus Sicherheitsgründen für ${ttl} Stunden gültig. Er gewährt Ihnen Zugang zu unserem interaktiven Pitch Deck — inklusive integriertem KI-Assistenten für weiterführende Fragen.`
|
||||
const buttonLabel = lang === 'en' ? 'Open Pitch Deck' : 'Pitch Deck öffnen'
|
||||
const fallbackLabel = lang === 'en'
|
||||
? `If the button doesn't work: ${magicLinkUrl}`
|
||||
: `Falls der Button nicht funktioniert: ${magicLinkUrl}`
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${fromName}" <${fromAddr}>`,
|
||||
to,
|
||||
subject: 'BreakPilot ComplAI — Ihr persönlicher Pitch-Deck-Zugang',
|
||||
subject,
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -81,10 +101,10 @@ export async function sendMagicLinkEmail(
|
||||
<td style="padding:0 40px 20px;">
|
||||
<div style="background:rgba(99,102,241,0.08);border:1px solid rgba(99,102,241,0.15);border-radius:8px;padding:16px;">
|
||||
<p style="margin:0 0 4px;font-size:11px;font-weight:600;color:rgba(99,102,241,0.8);text-transform:uppercase;letter-spacing:0.5px;">
|
||||
Ihr persönlicher Zugang
|
||||
${accessLabel}
|
||||
</p>
|
||||
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);line-height:1.5;">
|
||||
Der folgende Link ist individuell für Sie generiert, einmalig nutzbar und aus Sicherheitsgründen für ${ttl} Stunden gültig. Er gewährt Ihnen Zugang zu unserem interaktiven Pitch Deck — inklusive integriertem KI-Assistenten für weiterführende Fragen.
|
||||
${accessBody}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
@@ -97,7 +117,7 @@ export async function sendMagicLinkEmail(
|
||||
<tr>
|
||||
<td style="background:linear-gradient(135deg,#6366f1,#8b5cf6);border-radius:8px;padding:14px 40px;">
|
||||
<a href="${magicLinkUrl}" style="color:#ffffff;font-size:16px;font-weight:600;text-decoration:none;display:inline-block;">
|
||||
Pitch Deck öffnen
|
||||
${buttonLabel}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -109,7 +129,7 @@ export async function sendMagicLinkEmail(
|
||||
<tr>
|
||||
<td style="padding:0 40px 24px;">
|
||||
<p style="margin:0;font-size:11px;color:rgba(255,255,255,0.25);line-height:1.5;word-break:break-all;">
|
||||
Falls der Button nicht funktioniert: ${magicLinkUrl}
|
||||
${fallbackLabel}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Investor {
|
||||
login_count: number
|
||||
created_at: string
|
||||
is_showcase: boolean
|
||||
preferred_lang: 'de' | 'en'
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'server-only'
|
||||
import { randomBytes } from 'crypto'
|
||||
import pool from '@/lib/db'
|
||||
|
||||
// Alphanumeric, confusable chars removed (0, 1, i, l, o)
|
||||
const CHARS = 'abcdefghjkmnpqrstuvwxyz23456789'
|
||||
|
||||
function generateCode(): string {
|
||||
const bytes = randomBytes(6)
|
||||
return Array.from(bytes, b => CHARS[b % CHARS.length]).join('')
|
||||
}
|
||||
|
||||
export async function createShortLink(token: string): Promise<string> {
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
const code = generateCode()
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO pitch_short_links (short_code, token) VALUES ($1, $2)`,
|
||||
[code, token],
|
||||
)
|
||||
const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai'
|
||||
return `${baseUrl}/p/${code}`
|
||||
} catch {
|
||||
// Unique constraint collision — retry with a new code
|
||||
}
|
||||
}
|
||||
throw new Error('Failed to generate unique short code after 10 attempts')
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
export {};
|
||||
Vendored
+185
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env node
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
const API_URL = process.env.PITCH_API_URL || "https://pitch.breakpilot.com";
|
||||
const API_SECRET = process.env.PITCH_ADMIN_SECRET || "";
|
||||
if (!API_SECRET) {
|
||||
console.error("PITCH_ADMIN_SECRET is required");
|
||||
process.exit(1);
|
||||
}
|
||||
// --- HTTP client ---
|
||||
async function api(method, path, body) {
|
||||
const url = `${API_URL}${path}`;
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${API_SECRET}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
}
|
||||
catch {
|
||||
data = text;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg = typeof data === "object" && data && "error" in data
|
||||
? data.error
|
||||
: `HTTP ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
const TABLE_NAMES = [
|
||||
"company",
|
||||
"team",
|
||||
"financials",
|
||||
"market",
|
||||
"competitors",
|
||||
"features",
|
||||
"milestones",
|
||||
"metrics",
|
||||
"funding",
|
||||
"products",
|
||||
"fm_scenarios",
|
||||
"fm_assumptions",
|
||||
];
|
||||
// --- MCP Server ---
|
||||
const server = new McpServer({
|
||||
name: "breakpilot-pitch",
|
||||
version: "1.0.0",
|
||||
});
|
||||
// 1. list_versions
|
||||
server.tool("list_versions", "List all pitch versions with status, parent chain, and investor assignment counts", {}, async () => {
|
||||
const data = await api("GET", "/api/admin/versions");
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
});
|
||||
// 2. create_version
|
||||
server.tool("create_version", "Create a new draft version. Optionally fork from a parent version ID, otherwise snapshots current base tables.", {
|
||||
name: z.string().describe("Version name, e.g. 'Conservative Q4'"),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional description"),
|
||||
parent_id: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe("UUID of parent version to fork from. Omit to snapshot base tables."),
|
||||
}, async ({ name, description, parent_id }) => {
|
||||
const data = await api("POST", "/api/admin/versions", {
|
||||
name,
|
||||
description,
|
||||
parent_id,
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
});
|
||||
// 3. get_version
|
||||
server.tool("get_version", "Get full version detail including all 12 data table snapshots", {
|
||||
version_id: z.string().uuid().describe("Version UUID"),
|
||||
}, async ({ version_id }) => {
|
||||
const data = await api("GET", `/api/admin/versions/${version_id}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
});
|
||||
// 4. get_table_data
|
||||
server.tool("get_table_data", "Get a specific table's data for a version. Tables: company, team, financials, market, competitors, features, milestones, metrics, funding, products, fm_scenarios, fm_assumptions", {
|
||||
version_id: z.string().uuid().describe("Version UUID"),
|
||||
table_name: z
|
||||
.enum(TABLE_NAMES)
|
||||
.describe("Which data table to retrieve"),
|
||||
}, async ({ version_id, table_name }) => {
|
||||
const data = await api("GET", `/api/admin/versions/${version_id}/data/${table_name}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
});
|
||||
// 5. update_table_data
|
||||
server.tool("update_table_data", "Replace a table's data in a DRAFT version. Pass the full array of row objects. Single-record tables (company, funding) should still be wrapped in an array.", {
|
||||
version_id: z.string().uuid().describe("Version UUID (must be a draft)"),
|
||||
table_name: z.enum(TABLE_NAMES).describe("Which data table to update"),
|
||||
data: z
|
||||
.string()
|
||||
.describe("JSON string of the new data — an array of row objects. Example for company: [{\"name\":\"BreakPilot\",\"tagline_en\":\"...\"}]"),
|
||||
}, async ({ version_id, table_name, data: dataStr }) => {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(dataStr);
|
||||
}
|
||||
catch {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: invalid JSON in data parameter" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const result = await api("PUT", `/api/admin/versions/${version_id}/data/${table_name}`, { data: parsed });
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
||||
};
|
||||
});
|
||||
// 6. commit_version
|
||||
server.tool("commit_version", "Commit a draft version, making it immutable and available for investor assignment", {
|
||||
version_id: z.string().uuid().describe("Draft version UUID to commit"),
|
||||
}, async ({ version_id }) => {
|
||||
const data = await api("POST", `/api/admin/versions/${version_id}/commit`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
});
|
||||
// 7. fork_version
|
||||
server.tool("fork_version", "Create a new draft by forking an existing version (copies all data)", {
|
||||
version_id: z.string().uuid().describe("Version UUID to fork from"),
|
||||
name: z.string().describe("Name for the new forked draft"),
|
||||
}, async ({ version_id, name }) => {
|
||||
const data = await api("POST", `/api/admin/versions/${version_id}/fork`, { name });
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
});
|
||||
// 8. diff_versions
|
||||
server.tool("diff_versions", "Compare two versions and see per-table diffs (added/removed/changed rows and fields)", {
|
||||
version_a: z.string().uuid().describe("First version UUID"),
|
||||
version_b: z.string().uuid().describe("Second version UUID"),
|
||||
}, async ({ version_a, version_b }) => {
|
||||
const data = await api("GET", `/api/admin/versions/${version_a}/diff/${version_b}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
});
|
||||
// 9. list_investors
|
||||
server.tool("list_investors", "List all investors with their login stats, assigned version, and activity", {}, async () => {
|
||||
const data = await api("GET", "/api/admin/investors");
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
});
|
||||
// 10. assign_version
|
||||
server.tool("assign_version", "Assign a committed version to an investor (determines what pitch data they see). Pass null to reset to default base tables.", {
|
||||
investor_id: z.string().uuid().describe("Investor UUID"),
|
||||
version_id: z
|
||||
.string()
|
||||
.uuid()
|
||||
.nullable()
|
||||
.describe("Committed version UUID to assign, or null for default"),
|
||||
}, async ({ investor_id, version_id }) => {
|
||||
const data = await api("PATCH", `/api/admin/investors/${investor_id}`, {
|
||||
assigned_version_id: version_id,
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
});
|
||||
// 11. invite_investor
|
||||
server.tool("invite_investor", "Invite a new investor by email — sends a magic link for passwordless access to the pitch deck", {
|
||||
email: z.string().email().describe("Investor email address"),
|
||||
name: z.string().optional().describe("Investor name"),
|
||||
company: z.string().optional().describe("Investor company"),
|
||||
}, async ({ email, name, company }) => {
|
||||
const data = await api("POST", "/api/admin/invite", {
|
||||
email,
|
||||
name,
|
||||
company,
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
});
|
||||
// --- Start ---
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
}
|
||||
main().catch((err) => {
|
||||
console.error("MCP server error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ const PUBLIC_PATHS = [
|
||||
'/api/health',
|
||||
'/api/admin-auth', // admin login API
|
||||
'/pitch-admin/login', // admin login page
|
||||
'/p', // short link redirects — must be public so uninvited users can follow magic links
|
||||
'/_next',
|
||||
'/manifest.json',
|
||||
'/sw.js',
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE pitch_investors
|
||||
ADD COLUMN IF NOT EXISTS preferred_lang VARCHAR(5) NOT NULL DEFAULT 'de';
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user