feat(pitch-deck): bilingual email template + invite page with live preview
Some checks failed
Build pitch-deck / build-push-deploy (push) Failing after 1m6s
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 39s
CI / test-bqas (push) Successful in 40s

Email template (email.ts):
- Bilingual: German body + DE/EN legal footer
- Customizable greeting, message body, and closing
- Magic Link explanation box (hardcoded)
- Confidentiality & Disclaimer footer (hardcoded, bilingual)

Invite page (investors/new):
- Name is now required, Company is optional
- Editable fields: greeting, message, closing (with defaults)
- Live email preview panel (right side)
- Shows full email content before sending
- German UI labels

API (invite/route.ts):
- Passes greeting, message, closing to email function

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-16 08:34:23 +02:00
parent 32851ca9fb
commit aed428312f
3 changed files with 297 additions and 95 deletions

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 } = body
const { email, name, company, greeting, message, closing } = body
if (!email || typeof email !== 'string') {
return NextResponse.json({ error: 'Email required' }, { status: 400 })
@@ -54,7 +54,7 @@ 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)
await sendMagicLinkEmail(normalizedEmail, name || null, magicLinkUrl, greeting, message, closing)
await logAdminAudit(
adminId,

View File

@@ -1,18 +1,25 @@
'use client'
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { ArrowLeft, Eye, Send } from 'lucide-react'
import { DEFAULT_MESSAGE, DEFAULT_CLOSING, getDefaultGreeting } from '@/lib/email'
export default function NewInvestorPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [name, setName] = useState('')
const [company, setCompany] = useState('')
const [greeting, setGreeting] = useState('')
const [message, setMessage] = useState(DEFAULT_MESSAGE)
const [closing, setClosing] = useState(DEFAULT_CLOSING)
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
const effectiveGreeting = greeting || getDefaultGreeting(name || null)
const ttl = process.env.NEXT_PUBLIC_MAGIC_LINK_TTL_HOURS || '72'
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
@@ -21,7 +28,14 @@ export default function NewInvestorPage() {
const res = await fetch('/api/admin/invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, name, company }),
body: JSON.stringify({
email,
name,
company,
greeting: effectiveGreeting,
message,
closing,
}),
})
if (res.ok) {
router.push('/pitch-admin/investors')
@@ -37,8 +51,10 @@ export default function NewInvestorPage() {
}
}
const closingHtml = useMemo(() => closing.replace(/\n/g, '<br>'), [closing])
return (
<div className="max-w-xl">
<div className="max-w-5xl">
<Link
href="/pitch-admin/investors"
className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80 mb-6"
@@ -46,80 +62,203 @@ export default function NewInvestorPage() {
<ArrowLeft className="w-4 h-4" /> Back to investors
</Link>
<h1 className="text-2xl font-semibold text-white mb-2">Invite Investor</h1>
<h1 className="text-2xl font-semibold text-white mb-2">Investor einladen</h1>
<p className="text-sm text-white/50 mb-6">
A magic link will be emailed. Single-use, expires in {process.env.NEXT_PUBLIC_MAGIC_LINK_TTL_HOURS || '72'}h.
Der Investor erhaelt eine Email mit einem persoenlichen Magic Link (einmalig, verfaellt nach {ttl}h).
</p>
<form
onSubmit={handleSubmit}
className="bg-white/[0.04] border border-white/[0.08] rounded-2xl p-6 space-y-4"
>
<div>
<label htmlFor="email" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
Email <span className="text-rose-400">*</span>
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
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="jane@vc.com"
/>
</div>
<div>
<label htmlFor="name" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
Name
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(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="Jane Doe"
/>
</div>
<div>
<label htmlFor="company" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
Company
</label>
<input
id="company"
type="text"
value={company}
onChange={(e) => setCompany(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="Acme Ventures"
/>
</div>
{error && (
<div className="text-sm text-rose-300 bg-rose-500/10 border border-rose-500/20 rounded-lg px-3 py-2">
{error}
<div className="grid lg:grid-cols-2 gap-6">
{/* Left: Form */}
<form
onSubmit={handleSubmit}
className="bg-white/[0.04] border border-white/[0.08] rounded-2xl p-6 space-y-4 self-start"
>
{/* Email */}
<div>
<label htmlFor="email" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
Email <span className="text-rose-400">*</span>
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
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="investor@example.com"
/>
</div>
)}
<div className="flex items-center justify-end gap-3 pt-2">
<Link
href="/pitch-admin/investors"
className="text-sm text-white/60 hover:text-white px-4 py-2"
>
Cancel
</Link>
<button
type="submit"
disabled={submitting}
className="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"
>
{submitting ? 'Sending…' : 'Send invite'}
</button>
{/* Name */}
<div>
<label htmlFor="name" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
Name <span className="text-rose-400">*</span>
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
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="Dr. Max Mustermann"
/>
</div>
{/* Company (optional) */}
<div>
<label htmlFor="company" className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
Unternehmen <span className="text-white/30 text-[10px] normal-case">(optional)</span>
</label>
<input
id="company"
type="text"
value={company}
onChange={(e) => setCompany(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="Muster Ventures GmbH"
/>
</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>
</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>
{/* 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>
{error && (
<div className="text-sm text-rose-300 bg-rose-500/10 border border-rose-500/20 rounded-lg px-3 py-2">
{error}
</div>
)}
<div className="flex items-center justify-end gap-3 pt-2">
<Link
href="/pitch-admin/investors"
className="text-sm text-white/60 hover:text-white px-4 py-2"
>
Abbrechen
</Link>
<button
type="submit"
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'}
</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">
{/* 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>
{/* 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>
{/* 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
</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>
</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>
</form>
</div>
</div>
)
}