Email marketing V2
This commit is contained in:
@@ -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) => ({ '&': '&', '<': '<', '>': '>' })[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) => ({ '&': '&', '<': '<', '>': '>' })[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>
|
||||
|
||||
Reference in New Issue
Block a user