diff --git a/package.json b/package.json index ecfd395..9294847 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.1.23", + "version": "1.1.24", "type": "module", "scripts": { "dev": "vite", diff --git a/server/routes/contact.js b/server/routes/contact.js index 11a76b0..998cf12 100644 --- a/server/routes/contact.js +++ b/server/routes/contact.js @@ -812,4 +812,48 @@ export function register(app) { res.setHeader('Content-Disposition', `attachment; filename="subscribers-${new Date().toISOString().slice(0, 10)}.csv"`) res.send(csv) }) + + app.post('/api/admin-contact-submissions/:id/draft-reply', requireAdminAuth, async (req, res) => { + const apiKey = process.env.ANTHROPIC_API_KEY + if (!apiKey) { res.status(503).json({ message: 'ANTHROPIC_API_KEY is not configured on the server.' }); return } + + const { id } = req.params + if (!id || typeof id !== 'string') { res.status(400).json({ message: 'Invalid submission id.' }); return } + + const submission = state.contactSubmissions.find(s => s.id === id.trim()) + if (!submission) { res.status(404).json({ message: 'Submission not found.' }); return } + + const senderName = submission.name?.trim() || 'this listener' + const messageText = submission.message?.trim() || '(no message body)' + + try { + const apiRes = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-haiku-4-5-20251001', + max_tokens: 400, + messages: [{ + role: 'user', + content: `Draft a warm, personal reply to this message from a podcast listener. Write only the reply body — no greeting line (handled separately), no sign-off (handled by a signature). Keep it under 150 words. Be genuine and specific to their message.\n\nFrom: ${senderName}\nMessage:\n${messageText.slice(0, 1500)}`, + }], + }), + }) + + if (!apiRes.ok) { + const err = await apiRes.json().catch(() => ({})) + res.status(502).json({ message: err?.error?.message || 'AI draft request failed.' }); return + } + + const data = await apiRes.json() + const draft = String(data?.content?.[0]?.text ?? '').trim() + res.json({ ok: true, draft }) + } catch { + res.status(502).json({ message: 'AI draft request failed.' }) + } + }) } diff --git a/src/App.css b/src/App.css index 1040149..c82626d 100644 --- a/src/App.css +++ b/src/App.css @@ -10706,6 +10706,45 @@ width: 100%; } +/* Delivery status pills (contacts page) */ +.ct-delivery-pill { + border-radius: 10px; + font-size: 0.7rem; + font-weight: 500; + padding: 0.15rem 0.5rem; +} + +.ct-delivery-pill--clicked { background: #1a2e1a; color: #4ade80; } +.ct-delivery-pill--opened { background: #1a2535; color: #60a5fa; } +.ct-delivery-pill--delivered { background: #1e1e1e; color: #a0a0a0; } +.ct-delivery-pill--sent { background: #1a1a1a; color: #6b6560; } + +.ct-badge--unreplied { + background: #3d1a0a; + color: #fb923c; +} + +.ct-engage-badge { + color: #f59e0b; + font-size: 0.75rem; + letter-spacing: -1px; +} + +/* Delivery status pills (email page) */ +.em-delivery-pill { + border-radius: 10px; + font-size: 0.7rem; + font-weight: 500; + padding: 0.15rem 0.5rem; + white-space: nowrap; +} + +.em-delivery-pill--clicked { background: #1a2e1a; color: #4ade80; } +.em-delivery-pill--opened { background: #1a2535; color: #60a5fa; } +.em-delivery-pill--delivered { background: #1e1e1e; color: #a0a0a0; } +.em-delivery-pill--sent { background: #1a1a1a; color: #6b6560; } +.em-delivery-pill--bounced { background: #2e1a1a; color: #f87171; } + /* Flash message */ .ct-flash { background: #1a2a1a; diff --git a/src/CalendarPage.tsx b/src/CalendarPage.tsx index efde7b5..7b1770b 100644 --- a/src/CalendarPage.tsx +++ b/src/CalendarPage.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Link } from 'react-router-dom' +import { Link, useNavigate } from 'react-router-dom' // ── Types ──────────────────────────────────────────────────────────────────── @@ -248,6 +248,7 @@ export default function CalendarShell() { // ── Calendar Client ─────────────────────────────────────────────────────────── function CalendarClient() { + const navigate = useNavigate() const today = useMemo(() => new Date(), []) const [checklist, setChecklist] = useState(null) const [loading, setLoading] = useState(true) @@ -433,6 +434,17 @@ function CalendarClient() { setSelectedEpisodeId(null) } + function announceEpisode(ep: PodcastChecklistEpisode) { + const parts = [ep.series, ep.episodeNumber ? `Episode ${ep.episodeNumber}` : null, ep.title].filter(Boolean) + const subject = `New Episode: ${parts.join(' – ')}` + const dateStr = ep.datePublished + ? new Date(ep.datePublished + 'T12:00:00').toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }) + : '' + const body = [parts.join(' – '), dateStr ? `Published ${dateStr}` : ''].filter(Boolean).join('\n\n') + setEditEp(null) + navigate('/email', { state: { compose: true, subject, body } }) + } + function openEdit(ep: PodcastChecklistEpisode) { setEditEp(ep) setEditForm({ @@ -920,6 +932,7 @@ function CalendarClient() {
+
diff --git a/src/ContactsPage.tsx b/src/ContactsPage.tsx index cd32f4d..5edbdad 100644 --- a/src/ContactsPage.tsx +++ b/src/ContactsPage.tsx @@ -3,6 +3,11 @@ import { Link } from 'react-router-dom' // ── Types ──────────────────────────────────────────────────────────────────── +interface EmailDeliveryState { + status: string + lastEventAt: string | null +} + interface ContactSubmission { id: string submittedAt: string @@ -17,6 +22,11 @@ interface ContactSubmission { inboundTo?: string notes?: string tags?: string[] + emailStatus?: { + welcome: EmailDeliveryState + adminNotification: EmailDeliveryState + adminReply: EmailDeliveryState + } } interface ReplyHistoryItem { @@ -48,6 +58,9 @@ interface Contact { mainId: string allIds: string[] allSubmissions: ContactSubmission[] + bestDeliveryStatus: string | null // opened/clicked/delivered/sent/null + unreplied: boolean // has inbound messages, no reply sent + engagementScore: number } type ConversationItem = @@ -298,6 +311,27 @@ function ContactsClient() { const emailKey = latest.email?.trim().toLowerCase() || latest.id // Tags: prefer entry that has tags, falling back to mainId submission const withTags = sorted.find(s => s.tags && s.tags.length > 0) + // Best delivery status: clicked > opened > delivered > sent + const STATUS_RANK: Record = { clicked: 4, opened: 3, delivered: 2, sent: 1 } + let bestDeliveryStatus: string | null = null + let bestRank = -1 + for (const s of sorted) { + const st = s.emailStatus?.adminReply?.status + if (st && STATUS_RANK[st] !== undefined && STATUS_RANK[st] > bestRank) { + bestRank = STATUS_RANK[st] + bestDeliveryStatus = st + } + } + + const repliesForContact = replyHistory.filter(r => r.toEmail?.trim().toLowerCase() === emailKey) + const unreplied = sorted.some(s => !s.archived) && repliesForContact.length === 0 + + const engagementScore = + repliesForContact.length * 3 + + sorted.filter(s => s.emailStatus?.adminReply?.status === 'clicked').length * 2 + + sorted.filter(s => s.emailStatus?.adminReply?.status === 'opened').length * 1 + + (sorted.some(s => s.source === 'download') ? 2 : 0) + return { key: emailKey, email: latest.email ?? '', @@ -315,6 +349,9 @@ function ContactsClient() { mainId: latest.id, allIds: sorted.map(s => s.id), allSubmissions: sorted, + bestDeliveryStatus, + unreplied, + engagementScore, } }) .sort((a, b) => new Date(b.latestAt).getTime() - new Date(a.latestAt).getTime()) @@ -709,6 +746,13 @@ function ContactsClient() { {c.email && {c.email}} {sourceBadge(c.source)} {c.subscribe && subscriber} + {c.unreplied && needs reply} + {c.bestDeliveryStatus && ( + + {c.bestDeliveryStatus === 'clicked' ? '🔗 clicked' : c.bestDeliveryStatus === 'opened' ? '👁 opened' : c.bestDeliveryStatus === 'delivered' ? '✓ delivered' : '→ sent'} + + )} + {c.engagementScore >= 3 && {'★'.repeat(Math.min(3, Math.floor(c.engagementScore / 3)))}} {c.submissionCount > 1 && {c.submissionCount}} diff --git a/src/EmailPage.tsx b/src/EmailPage.tsx index efec9a8..6b274de 100644 --- a/src/EmailPage.tsx +++ b/src/EmailPage.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Link } from 'react-router-dom' +import { Link, useLocation } from 'react-router-dom' // ── Constants ──────────────────────────────────────────────────────────────── @@ -17,6 +17,14 @@ interface Attachment { size: number } +interface EmailDeliveryState { + status: string + lastEventAt: string | null + lastEventType: string | null + resendEmailId: string | null + error: string | null +} + interface ContactSubmission { id: string threadId: string @@ -34,6 +42,11 @@ interface ContactSubmission { htmlBody?: string | null messageId?: string attachments?: Attachment[] + emailStatus?: { + welcome: EmailDeliveryState + adminNotification: EmailDeliveryState + adminReply: EmailDeliveryState + } } interface Thread { @@ -207,6 +220,7 @@ function buildThreads(submissions: ContactSubmission[], history: ReplyHistoryIte type Mailbox = 'inbox' | 'starred' | 'snoozed' | 'archived' function EmailClient({ onLogout }: { onLogout: () => void }) { + const location = useLocation() const [submissions, setSubmissions] = useState([]) const [loadStatus, setLoadStatus] = useState<'loading' | 'ready' | 'error'>('loading') const [mailbox, setMailbox] = useState('inbox') @@ -239,6 +253,9 @@ function EmailClient({ onLogout }: { onLogout: () => void }) { // Schedule send const [showSchedule, setShowSchedule] = useState(false) const [scheduleCustom, setScheduleCustom] = useState('') + // AI draft + const [draftBusy, setDraftBusy] = useState(false) + const [draftMsg, setDraftMsg] = useState('') const composeRef = useRef(null) const listRef = useRef(null) @@ -279,6 +296,23 @@ function EmailClient({ onLogout }: { onLogout: () => void }) { useEffect(() => { loadAll() }, [loadAll]) + // Pre-filled compose from Calendar "Announce" button + useEffect(() => { + const s = location.state as { compose?: boolean; subject?: string; body?: string } | null + if (!s?.compose) return + setReplyDraft({ + submissionId: null, + threadId: null, + recipientName: '', + recipientEmail: '', + subject: s.subject ?? '', + message: s.body ?? '', + fromAddress: REPLY_FROM_OPTIONS[0].value, + scheduledAt: '', + }) + window.history.replaceState({}, '', window.location.pathname) + }, [location.state]) + useEffect(() => { const id = setInterval(async () => { try { @@ -486,6 +520,19 @@ function EmailClient({ onLogout }: { onLogout: () => void }) { setTimeout(() => setActionMsg(''), 3000) } + async function handleAIDraft(submissionId: string) { + setDraftBusy(true); setDraftMsg('') + try { + const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(submissionId)}/draft-reply`, { + method: 'POST', credentials: 'include', + }) + const d = await res.json() as { ok?: boolean; draft?: string; message?: string } + if (!res.ok) { setDraftMsg(d.message ?? 'Draft failed.'); setDraftBusy(false); return } + if (d.draft) setReplyDraft(prev => prev ? { ...prev, message: d.draft! } : prev) + } catch { setDraftMsg('Network error.') } + setDraftBusy(false) + } + function openReply(submission: ContactSubmission) { const firstName = submission.name?.trim().split(/\s+/)[0] || 'there' const subject = extractSubject(submission.message) @@ -902,13 +949,21 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
{getConversationItems(selectedFullThread).map((item, i) => { if (item.type === 'sent') { + const deliveryStatus = submissions.find(s => s.id === item.item.submissionId)?.emailStatus?.adminReply?.status return (
You → {item.item.toEmail} - - {item.item.scheduledAt ? `Scheduled: ${formatDate(item.item.scheduledAt)}` : formatDate(item.item.sentAt)} - +
+ {deliveryStatus && deliveryStatus !== 'idle' && ( + + {deliveryStatus === 'clicked' ? '🔗 clicked' : deliveryStatus === 'opened' ? '👁 opened' : deliveryStatus === 'delivered' ? '✓ delivered' : deliveryStatus === 'bounced' ? '⚠ bounced' : deliveryStatus === 'sent' ? '→ sent' : deliveryStatus} + + )} + + {item.item.scheduledAt ? `Scheduled: ${formatDate(item.item.scheduledAt)}` : formatDate(item.item.sentAt)} + +
{item.item.subject}
{item.item.preview}
@@ -1079,7 +1134,12 @@ function EmailClient({ onLogout }: { onLogout: () => void }) { )}
- {replyMsg && {replyMsg}} + {replyDraft.submissionId && ( + + )} + {(replyMsg || draftMsg) && {replyMsg || draftMsg}}