From 17b9006b88be381a1a7a96b9554813c784013016 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Wed, 6 May 2026 23:18:33 +0200 Subject: [PATCH] feat(pitch-deck): English email templates, investor language preference, link-only invite mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- pitch-deck/app/api/admin/invite/route.ts | 25 +- pitch-deck/app/api/admin/migrate/route.ts | 2 + pitch-deck/app/api/auth/me/route.ts | 2 +- pitch-deck/app/page.tsx | 16 +- .../(authed)/investors/new/page.tsx | 400 +++++++++++++----- pitch-deck/lib/email-templates.ts | 15 +- pitch-deck/lib/email.ts | 38 +- pitch-deck/lib/hooks/useAuth.ts | 1 + pitch-deck/mcp-server/dist/index.d.ts | 2 + pitch-deck/mcp-server/dist/index.js | 185 ++++++++ pitch-deck/migrations/005_investor_lang.sql | 2 + pitch-deck/tsconfig.tsbuildinfo | 1 + 12 files changed, 559 insertions(+), 130 deletions(-) create mode 100644 pitch-deck/mcp-server/dist/index.d.ts create mode 100644 pitch-deck/mcp-server/dist/index.js create mode 100644 pitch-deck/migrations/005_investor_lang.sql create mode 100644 pitch-deck/tsconfig.tsbuildinfo diff --git a/pitch-deck/app/api/admin/invite/route.ts b/pitch-deck/app/api/admin/invite/route.ts index c5aefe1..332d52a 100644 --- a/pitch-deck/app/api/admin/invite/route.ts +++ b/pitch-deck/app/api/admin/invite/route.ts @@ -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, }) } diff --git a/pitch-deck/app/api/admin/migrate/route.ts b/pitch-deck/app/api/admin/migrate/route.ts index 8299282..b70a9b8 100644 --- a/pitch-deck/app/api/admin/migrate/route.ts +++ b/pitch-deck/app/api/admin/migrate/route.ts @@ -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) { diff --git a/pitch-deck/app/api/auth/me/route.ts b/pitch-deck/app/api/auth/me/route.ts index 55dd2ec..40b6fdd 100644 --- a/pitch-deck/app/api/auth/me/route.ts +++ b/pitch-deck/app/api/auth/me/route.ts @@ -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] ) diff --git a/pitch-deck/app/page.tsx b/pitch-deck/app/page.tsx index 0c5d812..929cfdb 100644 --- a/pitch-deck/app/page.tsx +++ b/pitch-deck/app/page.tsx @@ -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('de') const { investor, loading, logout } = useAuth() + const [lang, setLang] = useState('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 (
diff --git a/pitch-deck/app/pitch-admin/(authed)/investors/new/page.tsx b/pitch-deck/app/pitch-admin/(authed)/investors/new/page.tsx index c67dc2d..71e146a 100644 --- a/pitch-deck/app/pitch-admin/(authed)/investors/new/page.tsx +++ b/pitch-deck/app/pitch-admin/(authed)/investors/new/page.tsx @@ -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('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(null) + const [copied, setCopied] = useState(false) + const prevLang = useRef('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, '
'), [closing]) + // Success state: link was generated without email + if (createdLink) { + return ( +
+ + Back to investors + + +
+
+ +
+
+

Investor created

+

No email was sent. Copy the magic link below to use in your outreach.

+
+ +
+

Magic Link

+

{createdLink}

+
+ +
+ + + Back to investors + +
+
+
+ ) + } + return (
Investor einladen

- 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.`}

@@ -122,49 +209,114 @@ export default function NewInvestorPage() {
- {/* Greeting */} -
- - 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)} - /> -

Leer lassen fuer automatische Anrede basierend auf dem Namen

+ {/* Language toggle + Send email toggle */} +
+ {/* Pitch language */} +
+

Pitch-Sprache

+
+ + +
+

Standardsprache beim Öffnen des Decks

+
+ + {/* Send email toggle */} +
+

Email senden

+
+ + +
+

Nein = nur Link generieren

+
- {/* Message */} -
- -