Email marketing V2

This commit is contained in:
2026-07-29 10:42:21 +02:00
parent 11fdec610f
commit 6fd0ed8522
9 changed files with 256 additions and 53 deletions

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Code2, Eye, FileText, ImagePlus, Loader2, MailCheck, Send, ShieldCheck, Users } from 'lucide-react';
import { Ban, Code2, Eye, FileText, ImagePlus, MailCheck, Send, ShieldCheck, Users } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
@@ -18,9 +18,12 @@ export default function NewsletterComposer() {
const [format, setFormat] = useState<Format>('text');
const [audience, setAudience] = useState<Audience>('all_users');
const [testEmail, setTestEmail] = useState('knuth.timo@gmail.com');
const [testName, setTestName] = useState('Timo');
const [bounceEmails, setBounceEmails] = useState('');
const [status, setStatus] = useState('');
const [isTesting, setIsTesting] = useState(false);
const [isSending, setIsSending] = useState(false);
const [isSuppressing, setIsSuppressing] = useState(false);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const [uploadedImageUrl, setUploadedImageUrl] = useState('');
const imageInput = useRef<HTMLInputElement>(null);
@@ -40,10 +43,12 @@ export default function NewsletterComposer() {
const audienceCount = details?.audiences[audience] ?? 0;
const preview = useMemo(() => {
if (format === 'html') return content;
const escaped = content.replace(/[&<>]/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[character] || character);
const firstName = testName.trim().split(/\s+/)[0] || 'there';
const previewContent = content.replace(/\{\{\s*first_name\s*\}\}/gi, firstName);
if (format === 'html') return previewContent;
const escaped = previewContent.replace(/[&<>]/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[character] || character);
return `<pre style="margin:0;font:16px/1.65 Arial,sans-serif;white-space:pre-wrap;color:#1b1c19;">${escaped}</pre>`;
}, [content, format]);
}, [content, format, testName]);
async function submit(action: 'test' | 'send') {
if (!subject.trim() || !content.trim()) {
@@ -60,10 +65,12 @@ export default function NewsletterComposer() {
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 }),
body: JSON.stringify({ action, confirm: action === 'send', subject, content, format, audience, testEmail, testName }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || 'Sending failed.');
const payload = response.headers.get('content-type')?.includes('application/json')
? await response.json()
: null;
if (!response.ok) throw new Error(payload?.error || 'The server did not return a sending result. Do not send again until the delivery log has been checked.');
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.');
@@ -73,6 +80,32 @@ export default function NewsletterComposer() {
}
}
async function suppressBounces() {
const emails = bounceEmails.split(/[\s,;]+/).filter(Boolean);
if (emails.length === 0) {
setStatus('Paste one or more bounced email addresses first.');
return;
}
setIsSuppressing(true);
setStatus('');
try {
const response = await fetch('/api/marketing/newsletter/suppress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emails }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || 'Could not suppress these addresses.');
setBounceEmails('');
setStatus(`${payload.suppressed} bounced address${payload.suppressed === 1 ? '' : 'es'} removed from future campaigns.`);
} catch (error) {
setStatus(error instanceof Error ? error.message : 'Could not suppress these addresses.');
} finally {
setIsSuppressing(false);
}
}
async function uploadImage(file: File | undefined) {
if (!file) return;
@@ -126,7 +159,7 @@ export default function NewsletterComposer() {
<CardContent className="grid gap-8 pt-6 xl:grid-cols-[minmax(0,1fr)_minmax(340px,0.85fr)]">
<div className="space-y-5">
<label className="block"><span className="mb-2 block text-sm font-medium text-slate-800">Subject line</span><input value={subject} onChange={(event) => setSubject(event.target.value)} maxLength={150} placeholder="What should people see in their inbox?" className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-950 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-100" /><span className="mt-1 block text-right text-xs tabular-nums text-slate-400">{subject.length}/150</span></label>
<label className="block"><span className="mb-2 flex items-center justify-between text-sm font-medium text-slate-800"><span>{format === 'html' ? 'HTML email' : 'Email text'}</span><span className="font-normal text-slate-500">Use the preview to check spacing and line breaks</span></span><textarea value={content} onChange={(event) => setContent(event.target.value)} spellCheck={format === 'text'} className={`min-h-[390px] w-full resize-y rounded-lg border border-slate-300 bg-white p-4 text-sm leading-6 text-slate-900 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-100 ${format === 'html' ? 'font-mono text-[13px]' : ''}`} placeholder={format === 'html' ? '<h1>Your update</h1>' : 'Write your email...'} /></label>
<label className="block"><span className="mb-2 flex items-center justify-between text-sm font-medium text-slate-800"><span>{format === 'html' ? 'HTML email' : 'Email text'}</span><span className="font-normal text-slate-500">Use the preview to check spacing and line breaks</span></span><textarea value={content} onChange={(event) => setContent(event.target.value)} spellCheck={format === 'text'} className={`min-h-[390px] w-full resize-y rounded-lg border border-slate-300 bg-white p-4 text-sm leading-6 text-slate-900 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-100 ${format === 'html' ? 'font-mono text-[13px]' : ''}`} placeholder={format === 'html' ? '<h1>Your update</h1>' : 'Write your email...'} /><span className="mt-2 block text-xs leading-5 text-slate-500">Use <code className="rounded bg-slate-100 px-1 py-0.5 text-slate-700">{'{{first_name}}'}</code> for a first-name greeting. Accounts without a name receive there.</span></label>
<div className="flex flex-col gap-3 rounded-lg border border-dashed border-slate-300 bg-slate-50/60 p-4 sm:flex-row sm:items-center sm:justify-between">
<div><p className="text-sm font-medium text-slate-800">Add an image</p><p className="mt-1 text-xs leading-5 text-slate-500">JPG, PNG, or WebP up to 5 MB. The image is hosted publicly for email clients.</p></div>
<input ref={imageInput} type="file" accept="image/jpeg,image/png,image/webp" className="sr-only" onChange={(event) => uploadImage(event.target.files?.[0])} />
@@ -138,8 +171,16 @@ export default function NewsletterComposer() {
<div className="space-y-5 xl:border-l xl:border-slate-100 xl:pl-8">
<div className="flex items-center gap-2 text-sm font-medium text-slate-800"><Eye className="h-4 w-4 text-primary-600" /> Live preview</div>
<div className="overflow-hidden rounded-lg border border-slate-200 bg-slate-100 p-3"><div className="mx-auto max-w-[600px] overflow-hidden rounded bg-white shadow-sm"><div className="border-b border-slate-100 px-5 py-3 text-[11px] font-semibold tracking-[0.16em] text-slate-700">QR MASTER</div><div className="border-b border-slate-100 px-5 py-4 text-sm font-semibold text-slate-950">{subject || 'Your subject line will appear here'}</div><iframe title="Email preview" sandbox="" srcDoc={preview} className="h-[280px] w-full border-0 bg-white" /><div className="border-t border-slate-100 px-5 py-4 text-[11px] leading-5 text-slate-500">Your existing QR codes will stay active exactly as they are.<br />Unsubscribe from product updates</div></div></div>
<div className="border-t border-slate-100 pt-5"><label className="block"><span className="mb-2 block text-sm font-medium text-slate-800">Send a test to</span><input type="email" value={testEmail} onChange={(event) => setTestEmail(event.target.value)} className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-950 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-100" /></label><Button type="button" variant="outline" className="mt-3 w-full" loading={isTesting} onClick={() => submit('test')}><MailCheck className="mr-2 h-4 w-4" /> Send test email</Button></div>
<div className="border-t border-slate-100 pt-5"><label className="block"><span className="mb-2 block text-sm font-medium text-slate-800">Send a test to</span><input type="email" value={testEmail} onChange={(event) => setTestEmail(event.target.value)} className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-950 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-100" /></label><label className="mt-3 block"><span className="mb-2 block text-sm font-medium text-slate-800">Preview first name</span><input value={testName} onChange={(event) => setTestName(event.target.value)} maxLength={80} placeholder="Timo" className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-950 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-100" /><span className="mt-1 block text-xs leading-5 text-slate-500">Used for the preview and test email only.</span></label><Button type="button" variant="outline" className="mt-3 w-full" loading={isTesting} onClick={() => submit('test')}><MailCheck className="mr-2 h-4 w-4" /> Send test email</Button></div>
<div className="border-t border-slate-100 pt-5"><label className="block"><span className="mb-2 block text-sm font-medium text-slate-800">Live audience</span><select value={audience} onChange={(event) => setAudience(event.target.value as Audience)} className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-950 outline-none focus:border-primary-500 focus:ring-2 focus:ring-primary-100"><option value="all_users">All account holders {details?.audiences.all_users ?? '…'} eligible</option><option value="newsletter_subscribers">Newsletter subscribers {details?.audiences.newsletter_subscribers ?? '…'} eligible</option></select></label><div className="mt-3 flex items-center gap-2 text-sm text-slate-600"><Users className="h-4 w-4 text-slate-400" /> {audienceCount} recipients will receive this email.</div><Button type="button" className="mt-4 w-full" loading={isSending} disabled={!details || audienceCount === 0} onClick={() => submit('send')}><Send className="mr-2 h-4 w-4" /> Send to {audienceCount || '…'} recipients</Button></div>
<div className="border-t border-slate-100 pt-5">
<label className="block">
<span className="mb-2 block text-sm font-medium text-slate-800">Remove bounced addresses</span>
<textarea value={bounceEmails} onChange={(event) => setBounceEmails(event.target.value)} rows={3} placeholder={'one@email.com\nanother@email.com'} className="w-full resize-y rounded-lg border border-slate-300 bg-white p-3 text-sm leading-6 text-slate-950 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-100" />
<span className="mt-1 block text-xs leading-5 text-slate-500">Paste addresses from SMTP delivery-status messages. One per line, comma, or space.</span>
</label>
<Button type="button" variant="outline" className="mt-3 w-full" loading={isSuppressing} onClick={suppressBounces}><Ban className="mr-2 h-4 w-4" /> Remove from future campaigns</Button>
</div>
{status && <p role="status" className="rounded-lg bg-slate-50 px-3 py-3 text-sm leading-5 text-slate-700">{status}</p>}
</div>
</CardContent>