diff --git a/pledge-now-pay-later/src/app/api/automations/ai/route.ts b/pledge-now-pay-later/src/app/api/automations/ai/route.ts new file mode 100644 index 0000000..ea05ada --- /dev/null +++ b/pledge-now-pay-later/src/app/api/automations/ai/route.ts @@ -0,0 +1,320 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { getUser } from "@/lib/session" + +const OPENAI_KEY = process.env.OPENAI_API_KEY +const MODEL = "gpt-4o-mini" + +async function chat(messages: Array<{ role: string; content: string }>, maxTokens = 600): Promise { + if (!OPENAI_KEY) return "" + try { + const res = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${OPENAI_KEY}` }, + body: JSON.stringify({ model: MODEL, messages, max_tokens: maxTokens, temperature: 0.8 }), + }) + const data = await res.json() + return data.choices?.[0]?.message?.content || "" + } catch { return "" } +} + +/** + * POST /api/automations/ai + * + * Actions: + * - generate_variant: AI creates a challenger variant B + * - rewrite: AI rewrites a template with a specific instruction + * - check_winners: Evaluate all A/B tests and auto-promote winners + */ +export async function POST(request: NextRequest) { + const user = await getUser() + if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + if (!prisma) return NextResponse.json({ error: "No DB" }, { status: 503 }) + + const body = await request.json() + const { action } = body + const orgId = user.orgId + + // ─── GENERATE VARIANT ─────────────────────────── + if (action === "generate_variant") { + const { step, channel } = body + + const existing = await prisma.messageTemplate.findFirst({ + where: { organizationId: orgId, step, channel, variant: "A" }, + }) + if (!existing) return NextResponse.json({ error: "No variant A found" }, { status: 404 }) + + // Get org context + const org = await prisma.organization.findUnique({ + where: { id: orgId }, + select: { name: true, orgType: true }, + }) + + const stepLabels: Record = { + 0: "pledge receipt (instant confirmation with bank details)", + 1: "gentle reminder (day 2, donor hasn't paid yet)", + 2: "impact nudge (day 7, building urgency with purpose)", + 3: "final reminder (day 14, last message before marking overdue)", + } + + const channelRules: Record = { + whatsapp: "WhatsApp message. Use WhatsApp formatting: *bold*, _italic_, `code` for references. Emojis are good. Keep conversational. Can use ━━━ dividers. Reply keywords: PAID, HELP, CANCEL.", + email: "Email body (plain text, will be formatted). Can be slightly longer. Include {{pledge_url}} and {{cancel_url}} links.", + sms: "SMS message. MUST be under 160 characters. No formatting. Be extremely concise. Every character counts.", + } + + const result = await chat([ + { + role: "system", + content: `You are an A/B testing expert for UK charity fundraising messages. You generate CHALLENGER variants that take a fundamentally different psychological approach. + +Your job: given an existing message (variant A), create a variant B that tests a DIFFERENT strategy. Don't just rephrase — change the APPROACH. + +Strategies to try (pick ONE that's different from variant A): +- SOCIAL PROOF: "47 others have already paid their pledge" +- URGENCY: deadline framing, countdown language +- IMPACT STORYTELLING: specific impact ("£50 = 3 weeks of food for a family") +- PERSONAL CONNECTION: use donor's name heavily, feel like a 1-to-1 conversation +- BREVITY: strip to absolute minimum — sometimes less is more +- GRATITUDE-FIRST: lead with thanks, make the ask secondary +- LOSS FRAMING: "Your pledge is at risk of being marked unfulfilled" +- COMMUNITY: "Join 23 others who've completed their pledge this week" + +Rules: +- Use the SAME template variables: {{name}}, {{amount}}, {{event}}, {{reference}}, {{bank_name}}, {{sort_code}}, {{account_no}}, {{org_name}}, {{days}}, {{cancel_url}}, {{pledge_url}} +- ${channelRules[channel] || "Keep appropriate for the channel."} +- UK English. Warm but professional. Charity context: ${org?.orgType || "charity"}. +- Org name: ${org?.name || "the charity"} + +Return ONLY valid JSON: +{ + "body": "the new message template with {{variables}}", + "subject": "email subject line with {{variables}} (null if not email)", + "name": "short name for this variant (e.g. 'Social proof nudge')", + "strategy": "which strategy you used (e.g. 'social_proof')", + "reasoning": "1-2 sentences explaining WHY this approach might outperform variant A" +}` + }, + { + role: "user", + content: `Step: ${stepLabels[step] || `step ${step}`} +Channel: ${channel} +Current variant A: +--- +${existing.body} +--- + +Generate a challenger variant B using a DIFFERENT psychological approach.` + } + ], 800) + + if (!result) { + return NextResponse.json({ error: "AI unavailable — no API key configured" }, { status: 503 }) + } + + try { + const parsed = JSON.parse(result) + + // Save as variant B + await prisma.messageTemplate.upsert({ + where: { + organizationId_step_channel_variant: { + organizationId: orgId, step, channel, variant: "B", + }, + }, + update: { + body: parsed.body, + subject: parsed.subject || null, + name: parsed.name || "AI Challenger", + isActive: true, + splitPercent: 50, + }, + create: { + organizationId: orgId, step, channel, variant: "B", + body: parsed.body, + subject: parsed.subject || null, + name: parsed.name || "AI Challenger", + isActive: true, + splitPercent: 50, + }, + }) + + // Set variant A to 50% + await prisma.messageTemplate.updateMany({ + where: { organizationId: orgId, step, channel, variant: "A" }, + data: { splitPercent: 50 }, + }) + + return NextResponse.json({ + ok: true, + variant: { + body: parsed.body, + subject: parsed.subject, + name: parsed.name, + strategy: parsed.strategy, + reasoning: parsed.reasoning, + }, + }) + } catch { + return NextResponse.json({ error: "AI returned invalid response" }, { status: 500 }) + } + } + + // ─── REWRITE ──────────────────────────────────── + if (action === "rewrite") { + const { instruction, currentBody } = body + const channel = body.channel + + const channelHints: Record = { + whatsapp: "WhatsApp formatting (*bold*, _italic_, `code`). Emojis OK. Conversational.", + email: "Email body (plain text). Can include links like {{pledge_url}}.", + sms: "SMS — MUST be under 160 characters. No formatting.", + } + + const result = await chat([ + { + role: "system", + content: `You rewrite UK charity fundraising messages. Follow the instruction exactly. Keep ALL {{variable}} placeholders. ${channelHints[channel] || ""} +Return ONLY the rewritten message text — nothing else. No JSON, no explanation.` + }, + { + role: "user", + content: `Instruction: ${instruction} + +Current message: +--- +${currentBody} +--- + +Rewrite it following the instruction.` + } + ], 600) + + if (!result) { + return NextResponse.json({ error: "AI unavailable" }, { status: 503 }) + } + + return NextResponse.json({ ok: true, body: result.trim() }) + } + + // ─── CHECK WINNERS ────────────────────────────── + if (action === "check_winners") { + const MIN_SAMPLE = body.minSample || 20 // minimum sends per variant before judging + + // Find all A/B pairs for this org + const templates = await prisma.messageTemplate.findMany({ + where: { organizationId: orgId }, + orderBy: [{ step: "asc" }, { channel: "asc" }, { variant: "asc" }], + }) + + // Group by step+channel + const groups = new Map() + for (const t of templates) { + const key = `${t.step}:${t.channel}` + if (!groups.has(key)) groups.set(key, []) + groups.get(key)!.push(t) + } + + const results: Array<{ + step: number; channel: string + winner: string; loser: string + winnerRate: number; loserRate: number + action: "promoted" | "too_early" | "no_ab" + newChallenger?: boolean + }> = [] + + for (const group of Array.from(groups.values())) { + if (group.length < 2) { + results.push({ step: group[0].step, channel: group[0].channel, winner: "A", loser: "-", winnerRate: 0, loserRate: 0, action: "no_ab" }) + continue + } + + const a = group.find(t => t.variant === "A") + const b = group.find(t => t.variant === "B") + if (!a || !b) continue + + // Not enough data yet + if (a.sentCount < MIN_SAMPLE || b.sentCount < MIN_SAMPLE) { + results.push({ + step: a.step, channel: a.channel, + winner: "-", loser: "-", + winnerRate: a.sentCount > 0 ? Math.round((a.convertedCount / a.sentCount) * 100) : 0, + loserRate: b.sentCount > 0 ? Math.round((b.convertedCount / b.sentCount) * 100) : 0, + action: "too_early", + }) + continue + } + + const rateA = a.convertedCount / a.sentCount + const rateB = b.convertedCount / b.sentCount + + // Statistical significance: simplified z-test + const pooledRate = (a.convertedCount + b.convertedCount) / (a.sentCount + b.sentCount) + const se = Math.sqrt(pooledRate * (1 - pooledRate) * (1 / a.sentCount + 1 / b.sentCount)) + const z = se > 0 ? Math.abs(rateA - rateB) / se : 0 + + // z > 1.65 ≈ 90% confidence + if (z < 1.65) { + results.push({ + step: a.step, channel: a.channel, + winner: "-", loser: "-", + winnerRate: Math.round(rateA * 100), + loserRate: Math.round(rateB * 100), + action: "too_early", + }) + continue + } + + // We have a winner! + const winnerIs = rateB > rateA ? "B" : "A" + const winner = winnerIs === "B" ? b : a + const loser = winnerIs === "B" ? a : b + + // Promote winner: winner becomes A, loser gets deleted + await prisma.messageTemplate.update({ + where: { id: a.id }, + data: { + body: winner.body, + subject: winner.subject, + name: winner.name, + splitPercent: 100, + sentCount: 0, + convertedCount: 0, + }, + }) + + // Delete B + await prisma.messageTemplate.delete({ where: { id: b.id } }) + + // Generate new challenger + let newChallenger = false + if (OPENAI_KEY) { + try { + // Recursively call generate_variant + const genRes = await fetch(new URL("/api/automations/ai", request.url), { + method: "POST", + headers: { + "Content-Type": "application/json", + cookie: request.headers.get("cookie") || "", + }, + body: JSON.stringify({ action: "generate_variant", step: winner.step, channel: winner.channel }), + }) + if (genRes.ok) newChallenger = true + } catch { /* */ } + } + + results.push({ + step: winner.step, channel: winner.channel, + winner: winnerIs, loser: winnerIs === "B" ? "A" : "B", + winnerRate: Math.round((winner.convertedCount / winner.sentCount) * 100), + loserRate: Math.round((loser.convertedCount / loser.sentCount) * 100), + action: "promoted", + newChallenger, + }) + } + + return NextResponse.json({ ok: true, results }) + } + + return NextResponse.json({ error: "Unknown action" }, { status: 400 }) +} diff --git a/pledge-now-pay-later/src/app/dashboard/automations/page.tsx b/pledge-now-pay-later/src/app/dashboard/automations/page.tsx index 1af287d..fc5c6a1 100644 --- a/pledge-now-pay-later/src/app/dashboard/automations/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/automations/page.tsx @@ -3,35 +3,13 @@ import { useState, useEffect, useCallback, useRef } from "react" import { Loader2, MessageCircle, Mail, Smartphone, Check, X, Clock, - ChevronDown, Send, Zap, AlertTriangle, Plus, Trash2, - Pencil, RotateCcw, CheckCheck + ChevronDown, Send, Zap, AlertTriangle, Trash2, + Pencil, RotateCcw, CheckCheck, Sparkles, Trophy, FlaskConical, + TrendingUp } from "lucide-react" import Link from "next/link" import { resolvePreview, TEMPLATE_VARIABLES, STEP_META, STRATEGY_PRESETS } from "@/lib/templates" -/** - * /dashboard/automations — THE MESSAGE DESIGN STUDIO - * - * This is not a monitoring dashboard. This is where Aaisha designs - * the messages her donors receive. She sees exactly what Ahmed - * sees on his phone — a WhatsApp-native mockup with green bubbles, - * blue ticks, and her charity's name in the header. - * - * Layout: - * ┌─────────────────────────────────────────────────────────────┐ - * │ STEP TIMELINE (4 tabs across the top) │ - * ├─────────────────────┬───────────────────────────────────────┤ - * │ PHONE MOCKUP │ EDITOR │ - * │ (WhatsApp-native) │ Channel tabs + textarea + variables │ - * │ │ A/B variant toggle │ - * │ │ Save / Test / Reset │ - * ├─────────────────────┴───────────────────────────────────────┤ - * │ CHANNEL STRATEGY (matrix + presets) │ - * ├─────────────────────────────────────────────────────────────┤ - * │ STATS + FEED (condensed at bottom) │ - * └─────────────────────────────────────────────────────────────┘ - */ - // ─── Types ─────────────────────────────────────────────────── interface Template { @@ -59,6 +37,17 @@ const CH_META: Record([]) const [pending, setPending] = useState([]) - // Current editor state const [activeStep, setActiveStep] = useState(0) const [activeChannel, setActiveChannel] = useState("whatsapp") const [activeVariant, setActiveVariant] = useState("A") @@ -83,6 +71,17 @@ export default function AutomationsPage() { const [showStrategy, setShowStrategy] = useState(false) const [showFeed, setShowFeed] = useState(false) + // AI state + const [aiGenerating, setAiGenerating] = useState(false) + const [aiReasoning, setAiReasoning] = useState(null) + const [aiStrategy, setAiStrategy] = useState(null) + const [rewriting, setRewriting] = useState(null) + const [checkingWinners, setCheckingWinners] = useState(false) + const [winnerResults, setWinnerResults] = useState | null>(null) + const editorRef = useRef(null) const load = useCallback(async () => { @@ -105,21 +104,19 @@ export default function AutomationsPage() { useEffect(() => { const tpl = templates.find(t => t.step === activeStep && t.channel === activeChannel && t.variant === activeVariant) if (tpl) { - setEditBody(tpl.body) - setEditSubject(tpl.subject || "") - setEditName(tpl.name) + setEditBody(tpl.body); setEditSubject(tpl.subject || ""); setEditName(tpl.name) setDirty(false) } else { - setEditBody("") - setEditSubject("") - setEditName(STEP_META[activeStep]?.label || "") + setEditBody(""); setEditSubject(""); setEditName(STEP_META[activeStep]?.label || "") setDirty(false) } + setAiReasoning(null); setAiStrategy(null) }, [activeStep, activeChannel, activeVariant, templates]) - const currentTemplate = templates.find(t => t.step === activeStep && t.channel === activeChannel && t.variant === activeVariant) - const variantB = templates.find(t => t.step === activeStep && t.channel === activeChannel && t.variant === "B") - const hasVariantB = !!variantB + const tplA = templates.find(t => t.step === activeStep && t.channel === activeChannel && t.variant === "A") + const tplB = templates.find(t => t.step === activeStep && t.channel === activeChannel && t.variant === "B") + const hasAB = !!tplB + const currentTpl = templates.find(t => t.step === activeStep && t.channel === activeChannel && t.variant === activeVariant) const activeChannels = [ channels?.whatsapp ? "whatsapp" : null, @@ -133,58 +130,70 @@ export default function AutomationsPage() { setSaving(true) try { await fetch("/api/automations", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - templates: [{ - step: activeStep, - channel: activeChannel, - variant: activeVariant, - name: editName, - subject: activeChannel === "email" ? editSubject : null, - body: editBody, - splitPercent: hasVariantB ? 50 : 100, - }], - }), + method: "PATCH", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ templates: [{ step: activeStep, channel: activeChannel, variant: activeVariant, name: editName, subject: activeChannel === "email" ? editSubject : null, body: editBody, splitPercent: hasAB ? 50 : 100 }] }), }) - setSaved(true) - setDirty(false) + setSaved(true); setDirty(false) setTimeout(() => setSaved(false), 2000) await load() } catch { /* */ } setSaving(false) } - const createVariantB = async () => { - const tplA = templates.find(t => t.step === activeStep && t.channel === activeChannel && t.variant === "A") - if (!tplA) return - setSaving(true) + const aiGenerateVariant = async () => { + setAiGenerating(true); setAiReasoning(null); setAiStrategy(null) try { - await fetch("/api/automations", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - templates: [ - { step: activeStep, channel: activeChannel, variant: "A", name: tplA.name, subject: tplA.subject, body: tplA.body, splitPercent: 50 }, - { step: activeStep, channel: activeChannel, variant: "B", name: tplA.name + " (B)", subject: tplA.subject, body: tplA.body, splitPercent: 50 }, - ], - }), + const res = await fetch("/api/automations/ai", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "generate_variant", step: activeStep, channel: activeChannel }), }) - setActiveVariant("B") - await load() + const data = await res.json() + if (data.ok && data.variant) { + setAiReasoning(data.variant.reasoning) + setAiStrategy(data.variant.strategy) + setActiveVariant("B") + await load() + } } catch { /* */ } - setSaving(false) + setAiGenerating(false) + } + + const aiRewrite = async (instruction: string) => { + setRewriting(instruction) + try { + const res = await fetch("/api/automations/ai", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "rewrite", step: activeStep, channel: activeChannel, variant: activeVariant, instruction, currentBody: editBody }), + }) + const data = await res.json() + if (data.ok && data.body) { + setEditBody(data.body); setDirty(true) + } + } catch { /* */ } + setRewriting(null) + } + + const checkWinners = async () => { + setCheckingWinners(true); setWinnerResults(null) + try { + const res = await fetch("/api/automations/ai", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "check_winners" }), + }) + const data = await res.json() + if (data.results) { setWinnerResults(data.results); await load() } + } catch { /* */ } + setCheckingWinners(false) } const deleteVariantB = async () => { setSaving(true) try { await fetch("/api/automations", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, + method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ step: activeStep, channel: activeChannel, variant: "B" }), }) - setActiveVariant("A") + setActiveVariant("A"); setAiReasoning(null); setAiStrategy(null) await load() } catch { /* */ } setSaving(false) @@ -193,8 +202,7 @@ export default function AutomationsPage() { const saveStrategy = async (strategy: string, matrix: Record) => { try { await fetch("/api/automations", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, + method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ config: { strategy, channelMatrix: matrix } }), }) await load() @@ -204,8 +212,7 @@ export default function AutomationsPage() { const saveTiming = async (key: string, value: number) => { try { await fetch("/api/automations", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, + method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ config: { [key]: value } }), }) await load() @@ -215,23 +222,19 @@ export default function AutomationsPage() { const insertVariable = (key: string) => { const el = editorRef.current if (!el) return - const start = el.selectionStart - const end = el.selectionEnd + const start = el.selectionStart; const end = el.selectionEnd const insert = `{{${key}}}` - const newBody = editBody.slice(0, start) + insert + editBody.slice(end) - setEditBody(newBody) + setEditBody(editBody.slice(0, start) + insert + editBody.slice(end)) setDirty(true) - setTimeout(() => { - el.focus() - el.setSelectionRange(start + insert.length, start + insert.length) - }, 0) + setTimeout(() => { el.focus(); el.setSelectionRange(start + insert.length, start + insert.length) }, 0) } // ─── Render ─────────────────────────────── if (loading) return
- const noChannels = activeChannels.length === 0 + // Count A/B tests across all steps + const abCount = new Set(templates.filter(t => t.variant === "B").map(t => `${t.step}:${t.channel}`)).size return (
@@ -242,21 +245,55 @@ export default function AutomationsPage() {

Message design studio

Automations

- {stats && stats.total > 0 && ( -
-

{stats.total}

-

messages this week · {stats.deliveryRate}% delivered

-
- )} +
+ {abCount > 0 && ( + + )} + {stats && stats.total > 0 && ( +
+

{stats.total}

+

this week · {stats.deliveryRate}%

+
+ )} +
+ {/* ── Winner Results Banner ── */} + {winnerResults && winnerResults.some(r => r.action === "promoted") && ( +
+

Winners promoted

+ {winnerResults.filter(r => r.action === "promoted").map((r, i) => ( +
+ {STEP_META[r.step]?.label} · {CH_META[r.channel as Channel]?.label} + → Variant {r.winner} wins ({r.winnerRate}% vs {r.loserRate}%) + {r.newChallenger && New AI challenger created} +
+ ))} + +
+ )} + {winnerResults && !winnerResults.some(r => r.action === "promoted") && ( +
+

Not enough data yet

+

Each variant needs at least 20 sends before we can pick a winner. Keep collecting pledges!

+ +
+ )} + {/* ── No channels warning ── */} - {noChannels && ( + {activeChannels.length === 0 && (

No channels connected yet

-

Connect WhatsApp in Settings to start sending messages. You can design your messages now and they'll start sending once connected.

+

Design your messages now — they'll start sending once you connect WhatsApp in Settings.

)} @@ -266,7 +303,7 @@ export default function AutomationsPage() { {STEP_META.map((s, i) => { const isActive = activeStep === i const delay = i === 0 ? "Instant" : i === 1 ? `Day ${config?.step1Delay || 2}` : i === 2 ? `Day ${config?.step2Delay || 7}` : `Day ${config?.step3Delay || 14}` - const tplCount = templates.filter(t => t.step === i).length + const hasABForStep = templates.some(t => t.step === i && t.variant === "B") return ( ) })} @@ -298,31 +327,25 @@ export default function AutomationsPage() { {/* ── LEFT: Phone Mockup ── */}
- - - {/* Step timing (editable) */} + {activeStep > 0 && (
- - Send + Send - after pledge (if not paid) + after pledge (if not paid)
)} + + {/* A/B stats comparison (below phone) */} + {hasAB && tplA && tplB && (tplA.sentCount > 0 || tplB.sentCount > 0) && ( + + )}
{/* ── RIGHT: Editor ── */} @@ -331,108 +354,92 @@ export default function AutomationsPage() { {/* Channel tabs */}
{CHANNELS.map(ch => { - const m = CH_META[ch] - const isActive = activeChannel === ch - const isLive = activeChannels.includes(ch) - const Icon = m.icon + const m = CH_META[ch]; const isAct = activeChannel === ch; const isLive = activeChannels.includes(ch); const Icon = m.icon return ( - ) })}
- {/* A/B variant toggle */} -
- - {hasVariantB ? ( + + {hasAB ? ( <> - - - {/* A/B stats */} - {(currentTemplate?.sentCount || 0) + (variantB?.sentCount || 0) > 0 && ( -
- - A: {currentTemplate?.sentCount || 0} sent → {currentTemplate?.convertedCount || 0} paid - ({currentTemplate?.sentCount ? Math.round(((currentTemplate?.convertedCount || 0) / currentTemplate.sentCount) * 100) : 0}%) - - - B: {variantB.sentCount} sent → {variantB.convertedCount} paid - ({variantB.sentCount ? Math.round((variantB.convertedCount / variantB.sentCount) * 100) : 0}%) - -
- )} + ) : ( - )}
+ {/* AI reasoning banner */} + {aiReasoning && ( +
+
+ + + {aiStrategy?.replace(/_/g, " ") || "AI Strategy"} + +
+

{aiReasoning}

+
+ )} + {/* Template name */} -
- { setEditName(e.target.value); setDirty(true) }} - className="text-lg font-black text-[#111827] bg-transparent border-b-2 border-transparent hover:border-gray-200 focus:border-[#1E40AF] outline-none w-full pb-1 transition-colors" - placeholder="Template name..." - /> -
+ { setEditName(e.target.value); setDirty(true) }} + className="text-lg font-black text-[#111827] bg-transparent border-b-2 border-transparent hover:border-gray-200 focus:border-[#1E40AF] outline-none w-full pb-1 transition-colors" + placeholder="Template name..." /> {/* Email subject */} {activeChannel === "email" && (
- { setEditSubject(e.target.value); setDirty(true) }} - className="w-full h-10 px-3 border-2 border-gray-200 text-sm font-medium placeholder:text-gray-300 focus:border-[#1E40AF] outline-none" - placeholder="e.g. Your £{{amount}} pledge — payment details" - /> + { setEditSubject(e.target.value); setDirty(true) }} + className="w-full h-10 px-3 border-2 border-gray-200 text-sm font-medium placeholder:text-gray-300 focus:border-[#1E40AF] outline-none" placeholder="e.g. Your £{{amount}} pledge — payment details" />

Preview: {resolvePreview(editSubject || "")}

)} + {/* AI rewrite toolbar */} +
+

AI rewrite

+
+ {REWRITE_ACTIONS.map(a => ( + + ))} +
+
+ {/* Message body editor */}
{editBody.length} chars{activeChannel === "sms" ? ` · ${Math.ceil(editBody.length / 160)} SMS` : ""}
-