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>
)
}

View File

@@ -13,17 +13,34 @@ const transporter = nodemailer.createTransport({
const fromName = process.env.SMTP_FROM_NAME || 'BreakPilot'
const fromAddr = process.env.SMTP_FROM_ADDR || 'noreply@breakpilot.ai'
export const DEFAULT_GREETING = 'Sehr geehrte Damen und Herren'
export const DEFAULT_MESSAGE =
'wir freuen uns, Ihnen einen exklusiven Zugang zu unserem interaktiven Investor Pitch Deck zu gewähren. Die Präsentation enthält alle relevanten Informationen zu unserem Unternehmen, unserem Produkt und unserer Finanzierungsstrategie.'
export const DEFAULT_CLOSING =
'Mit freundlichen Grüßen,\nBenjamin Boenisch & Sharang Parnerkar\nGründer — BreakPilot ComplAI'
export function getDefaultGreeting(name: string | null): string {
return name ? `Sehr geehrte(r) ${name}` : DEFAULT_GREETING
}
export async function sendMagicLinkEmail(
to: string,
investorName: string | null,
magicLinkUrl: string
magicLinkUrl: string,
greeting?: string,
message?: string,
closing?: string,
): Promise<void> {
const greeting = investorName ? `Hello ${investorName}` : 'Hello'
const effectiveGreeting = greeting || getDefaultGreeting(investorName)
const effectiveMessage = message || DEFAULT_MESSAGE
const effectiveClosing = closing || DEFAULT_CLOSING
const closingHtml = effectiveClosing.replace(/\n/g, '<br>')
const ttl = process.env.MAGIC_LINK_TTL_HOURS || '72'
await transporter.sendMail({
from: `"${fromName}" <${fromAddr}>`,
to,
subject: 'Your BreakPilot Pitch Deck Access',
subject: 'BreakPilot ComplAI — Ihr persönlicher Pitch-Deck-Zugang',
html: `
<!DOCTYPE html>
<html>
@@ -36,6 +53,8 @@ export async function sendMagicLinkEmail(
<tr>
<td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#111127;border-radius:12px;border:1px solid rgba(99,102,241,0.2);">
<!-- Header -->
<tr>
<td style="padding:40px 40px 20px;">
<h1 style="margin:0;font-size:24px;color:#e0e0ff;font-weight:600;">
@@ -46,52 +65,96 @@ export async function sendMagicLinkEmail(
</p>
</td>
</tr>
<!-- Greeting + Message -->
<tr>
<td style="padding:20px 40px;">
<td style="padding:20px 40px 0;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.8);line-height:1.6;">
${greeting},
${effectiveGreeting},
</p>
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.8);line-height:1.6;">
You have been invited to view the BreakPilot ComplAI investor pitch deck.
Click the button below to access the interactive presentation.
<p style="margin:0 0 24px;font-size:15px;color:rgba(255,255,255,0.7);line-height:1.7;">
${effectiveMessage}
</p>
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
</td>
</tr>
<!-- Magic Link Explanation -->
<tr>
<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 persoenlicher Zugangslink
</p>
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);line-height:1.5;">
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>
</td>
</tr>
<!-- Button -->
<tr>
<td style="padding:0 40px 16px;" align="center">
<table cellpadding="0" cellspacing="0">
<tr>
<td style="background:linear-gradient(135deg,#6366f1,#8b5cf6);border-radius:8px;padding:14px 32px;">
<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;">
View Pitch Deck
Pitch Deck oeffnen
</a>
</td>
</tr>
</table>
<p style="margin:0 0 8px;font-size:13px;color:rgba(255,255,255,0.4);line-height:1.5;">
This link expires in ${process.env.MAGIC_LINK_TTL_HOURS || '72'} hours and can only be used once.
</p>
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.3);line-height:1.5;word-break:break-all;">
${magicLinkUrl}
</td>
</tr>
<!-- Raw link -->
<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}
</p>
</td>
</tr>
<!-- Closing -->
<tr>
<td style="padding:0 40px 24px;">
<p style="margin:0;font-size:15px;color:rgba(255,255,255,0.7);line-height:1.7;">
${closingHtml}
</p>
</td>
</tr>
<!-- Legal Footer DE -->
<tr>
<td style="padding:20px 40px 12px;border-top:1px solid rgba(255,255,255,0.05);">
<p style="margin:0;font-size:12px;color:rgba(255,255,255,0.25);line-height:1.5;">
If you did not expect this email, you can safely ignore it.
<p style="margin:0 0 8px;font-size:10px;font-weight:600;color:rgba(255,255,255,0.3);text-transform:uppercase;letter-spacing:0.5px;">
Vertraulichkeit &amp; Haftungsausschluss
</p>
<p style="margin:0 0 6px;font-size:10px;color:rgba(255,255,255,0.18);line-height:1.5;">
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) Der Inhalt ist vertraulich zu behandeln und darf nicht an Dritte weitergegeben, kopiert oder zugaenglich gemacht werden. Ausgenommen sind Berater (Rechtsanwaelte, Steuerberater), die berufsrechtlich zur Verschwiegenheit verpflichtet sind. (b) Die Informationen duerfen ausschliesslich zur Bewertung einer moeglichen Beteiligung verwendet werden. (c) Diese Vertraulichkeitsverpflichtung gilt fuer drei (3) Jahre ab Uebermittlung, unabhaengig davon, ob eine Beteiligung zustande kommt.
</p>
<p style="margin:0 0 12px;font-size:10px;color:rgba(255,255,255,0.18);line-height:1.5;">
Dieses Dokument stellt weder ein Angebot zum Verkauf noch eine Aufforderung zur Abgabe eines Angebots zum Erwerb von Wertpapieren dar. Es handelt sich nicht um einen Wertpapierprospekt. Alle Finanzangaben sind Planzahlen und stellen keine Garantie fuer kuenftige Ergebnisse dar. Eine Beteiligung an einem jungen Unternehmen ist mit erheblichen Risiken verbunden, einschliesslich des Risikos eines Totalverlusts. Es gilt deutsches Recht. Gerichtsstand ist Konstanz, Deutschland.
</p>
</td>
</tr>
<!-- Legal Footer EN -->
<tr>
<td style="padding:0 40px 40px;">
<p style="margin:0 0 8px;font-size:10px;font-weight:600;color:rgba(255,255,255,0.3);text-transform:uppercase;letter-spacing:0.5px;">
<p style="margin:0 0 8px;font-size:10px;font-weight:600;color:rgba(255,255,255,0.2);text-transform:uppercase;letter-spacing:0.5px;">
Confidentiality &amp; Disclaimer
</p>
<p style="margin:0 0 6px;font-size:10px;color:rgba(255,255,255,0.18);line-height:1.5;">
<p style="margin:0 0 6px;font-size:10px;color:rgba(255,255,255,0.13);line-height:1.5;">
This pitch deck is confidential and has been prepared exclusively for the personally invited recipient. By opening this link, the recipient agrees: (a) The content must be treated confidentially and may not be disclosed, copied or made accessible to third parties. Excluded are advisors (lawyers, tax advisors) professionally bound to secrecy. (b) The information may only be used for evaluating a possible participation. (c) This confidentiality obligation applies for three (3) years from transmission, regardless of whether a participation materializes.
</p>
<p style="margin:0;font-size:10px;color:rgba(255,255,255,0.18);line-height:1.5;">
<p style="margin:0;font-size:10px;color:rgba(255,255,255,0.13);line-height:1.5;">
This document constitutes neither an offer to sell nor a solicitation of an offer to acquire securities. It is not a securities prospectus. All financial figures are projections and do not constitute a guarantee of future results. An investment in a young company involves significant risks, including the risk of total loss. German law applies. Place of jurisdiction is Konstanz, Germany.
</p>
</td>
</tr>
</table>
</td>
</tr>