From 11fdec610fe1a25b3335938efea59267a4a7ebe6 Mon Sep 17 00:00:00 2001 From: knuthtimo-lab Date: Wed, 29 Jul 2026 00:02:50 +0200 Subject: [PATCH] Email marketing --- .../newsletter/NewsletterClient.tsx | 19 ++- .../newsletter/NewsletterComposer.tsx | 149 ++++++++++++++++++ .../api/marketing/newsletter/images/route.ts | 38 +++++ .../(main)/api/marketing/newsletter/route.ts | 126 +++++++++++++++ .../api/newsletter/admin-login/route.ts | 21 +-- src/lib/email.ts | 45 +++++- 6 files changed, 379 insertions(+), 19 deletions(-) create mode 100644 src/app/(main)/(marketing)/newsletter/NewsletterComposer.tsx create mode 100644 src/app/(main)/api/marketing/newsletter/images/route.ts create mode 100644 src/app/(main)/api/marketing/newsletter/route.ts diff --git a/src/app/(main)/(marketing)/newsletter/NewsletterClient.tsx b/src/app/(main)/(marketing)/newsletter/NewsletterClient.tsx index 97b53e1..ec472c1 100644 --- a/src/app/(main)/(marketing)/newsletter/NewsletterClient.tsx +++ b/src/app/(main)/(marketing)/newsletter/NewsletterClient.tsx @@ -28,13 +28,14 @@ import { TrendingUp, Users, } from 'lucide-react'; -import { - getGoalLabel, +import { + getGoalLabel, getRoleLabel, getSourceLabel, getTeamSizeLabel, - getUseCaseLabel, -} from '@/lib/revops'; + getUseCaseLabel, +} from '@/lib/revops'; +import NewsletterComposer from './NewsletterComposer'; type SegmentRow = { id: string; @@ -453,7 +454,7 @@ export default function NewsletterClient() { return (
-
+

Ops Cockpit

@@ -465,9 +466,11 @@ export default function NewsletterClient() { Logout -
- -
+
+ + + +
diff --git a/src/app/(main)/(marketing)/newsletter/NewsletterComposer.tsx b/src/app/(main)/(marketing)/newsletter/NewsletterComposer.tsx new file mode 100644 index 0000000..6efcc65 --- /dev/null +++ b/src/app/(main)/(marketing)/newsletter/NewsletterComposer.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Code2, Eye, FileText, ImagePlus, Loader2, MailCheck, Send, ShieldCheck, Users } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'; + +type Format = 'text' | 'html'; +type Audience = 'all_users' | 'newsletter_subscribers'; +type ComposerData = { audiences: Record; defaultTestEmail: string }; + +const defaultText = `Hi {{first_name}},\n\nWrite your update here.\n\nBest,\nTimo`; + +export default function NewsletterComposer() { + const [details, setDetails] = useState(null); + const [subject, setSubject] = useState(''); + const [content, setContent] = useState(defaultText); + const [format, setFormat] = useState('text'); + const [audience, setAudience] = useState('all_users'); + const [testEmail, setTestEmail] = useState('knuth.timo@gmail.com'); + const [status, setStatus] = useState(''); + const [isTesting, setIsTesting] = useState(false); + const [isSending, setIsSending] = useState(false); + const [isUploadingImage, setIsUploadingImage] = useState(false); + const [uploadedImageUrl, setUploadedImageUrl] = useState(''); + const imageInput = useRef(null); + + useEffect(() => { + fetch('/api/marketing/newsletter') + .then(async (response) => { + if (!response.ok) throw new Error('Unable to load recipients.'); + return response.json(); + }) + .then((payload: ComposerData) => { + setDetails(payload); + if (payload.defaultTestEmail) setTestEmail(payload.defaultTestEmail); + }) + .catch(() => setStatus('Recipient counts could not be loaded. Refresh the page and try again.')); + }, []); + + const audienceCount = details?.audiences[audience] ?? 0; + const preview = useMemo(() => { + if (format === 'html') return content; + const escaped = content.replace(/[&<>]/g, (character) => ({ '&': '&', '<': '<', '>': '>' })[character] || character); + return `
${escaped}
`; + }, [content, format]); + + async function submit(action: 'test' | 'send') { + if (!subject.trim() || !content.trim()) { + setStatus('Add a subject and email content first.'); + return; + } + + if (action === 'send' && !window.confirm(`Send this email to ${audienceCount} eligible recipients? This cannot be undone.`)) return; + + action === 'test' ? setIsTesting(true) : setIsSending(true); + setStatus(''); + + try { + const response = await fetch('/api/marketing/newsletter', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, confirm: action === 'send', subject, content, format, audience, testEmail }), + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || 'Sending failed.'); + setStatus(action === 'test' ? `Test email sent to ${testEmail}.` : `Delivery complete: ${payload.sent} sent, ${payload.failed} failed.`); + } catch (error) { + setStatus(error instanceof Error ? error.message : 'Sending failed.'); + } finally { + setIsTesting(false); + setIsSending(false); + } + } + + async function uploadImage(file: File | undefined) { + if (!file) return; + + setIsUploadingImage(true); + setStatus(''); + try { + const formData = new FormData(); + formData.append('image', file); + const response = await fetch('/api/marketing/newsletter/images', { method: 'POST', body: formData }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || 'Image upload failed.'); + + const imageHtml = `${file.name.replace(/[&<>`; + setContent((current) => { + if (format === 'html') return `${current}\n${imageHtml}`; + const escapedText = current.replace(/[&<>]/g, (character) => ({ '&': '&', '<': '<', '>': '>' })[character] || character).replace(/\n/g, '
'); + return `

${escapedText}

\n${imageHtml}`; + }); + setFormat('html'); + setUploadedImageUrl(payload.url); + setStatus('Image uploaded and added to your HTML email.'); + } catch (error) { + setStatus(error instanceof Error ? error.message : 'Image upload failed.'); + } finally { + setIsUploadingImage(false); + if (imageInput.current) imageInput.current.value = ''; + } + } + + return ( +
+
+
+

Email studio

+

Create a campaign

+

Compose a plain-text update or paste finished HTML. Test it first, then send only when the preview is right.

+
+
Every live email includes a personal unsubscribe link.
+
+ + + +
+
Message setup

Choose the audience, then write or paste the message.

+
+ + +
+
+
+ +
+ +