feat(pitch-deck): English email templates, investor language preference, link-only invite mode
Build pitch-deck / build-push-deploy (push) Successful in 1m55s
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 36s
CI / test-python-voice (push) Successful in 35s
CI / test-bqas (push) Successful in 35s

- Add English email template variants (greeting, message, closing, subject, CTA copy)
- Add `preferred_lang` column to `pitch_investors` — stored per investor, deck opens in that language by default
- Invite form: DE/EN language toggle that switches email defaults and pitch language setting
- Invite form: "Send email" toggle — when off, creates investor + returns magic link without sending email (for cold outreach attachment)
- `app/page.tsx`: initializes pitch language from investor's `preferred_lang` before first render (no flash)
- Migration 007 added to `/api/admin/migrate` route for production rollout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-05-06 23:18:33 +02:00
parent e013702a02
commit 17b9006b88
12 changed files with 559 additions and 130 deletions
+19 -6
View File
@@ -11,7 +11,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 } = body
if (!email || typeof email !== 'string') {
return NextResponse.json({ error: 'Email required' }, { status: 400 })
@@ -25,17 +25,20 @@ export async function POST(request: NextRequest) {
const normalizedEmail = email.toLowerCase().trim()
const normalizedLang = lang === 'en' ? 'en' : 'de'
// 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)
VALUES ($1, $2, $3, $4)
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,
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],
)
const investor = rows[0]
@@ -54,12 +57,21 @@ export async function POST(request: NextRequest) {
const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai'
const magicLinkUrl = `${baseUrl}/auth/verify?token=${token}`
await sendMagicLinkEmail(normalizedEmail, name || null, magicLinkUrl, greeting, message, closing)
if (send_email) {
await sendMagicLinkEmail(normalizedEmail, name || null, magicLinkUrl, 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,
},
request,
investor.id,
)
@@ -69,5 +81,6 @@ export async function POST(request: NextRequest) {
investor_id: investor.id,
email: normalizedEmail,
expires_at: expiresAt.toISOString(),
magic_link_url: magicLinkUrl,
})
}
@@ -160,6 +160,8 @@ 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'`,
]
for (const sql of statements) {
+1 -1
View File
@@ -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]
)
+13 -3
View File
@@ -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" />
@@ -1,24 +1,48 @@
'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 } from 'lucide-react'
import {
DEFAULT_MESSAGE, DEFAULT_CLOSING,
DEFAULT_MESSAGE_EN, DEFAULT_CLOSING_EN,
getDefaultGreeting,
} from '@/lib/email-templates'
type Lang = 'de' | 'en'
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 [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<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)
// 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 +56,23 @@ export default function NewInvestorPage() {
email,
name,
company,
greeting: effectiveGreeting,
message,
closing,
lang,
send_email: sendEmail,
...(sendEmail && {
greeting: effectiveGreeting,
message,
closing,
}),
}),
})
if (res.ok) {
router.push('/pitch-admin/investors')
router.refresh()
const data = await res.json()
if (!sendEmail) {
setCreatedLink(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 +84,60 @@ export default function NewInvestorPage() {
}
}
async function copyLink() {
if (!createdLink) return
await navigator.clipboard.writeText(createdLink)
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">
<p className="text-[10px] font-semibold text-white/40 uppercase tracking-wider mb-2">Magic Link</p>
<p className="text-xs text-indigo-300 break-all leading-relaxed">{createdLink}</p>
</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 +149,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">
@@ -122,49 +209,114 @@ export default function NewInvestorPage() {
<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 +336,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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>
)
+14 -1
View File
@@ -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
View File
@@ -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>
+1
View File
@@ -12,6 +12,7 @@ export interface Investor {
login_count: number
created_at: string
is_showcase: boolean
preferred_lang: 'de' | 'en'
}
export function useAuth() {
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env node
export {};
+185
View File
@@ -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);
});
@@ -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