Add Tier 2 email features: AI draft, Calendar→Email announce, delivery status, engagement badges; v1.1.24
- EmailPage: useLocation to accept pre-filled compose from Calendar; delivery status pill on sent bubbles; AI Draft button calling ANTHROPIC_API_KEY endpoint - CalendarPage: useNavigate + announceEpisode(); ✉ Announce button in episode edit popover - ContactsPage: emailStatus types; unreplied badge; best delivery status pill; engagement score stars - server/routes/contact.js: POST /api/admin-contact-submissions/:id/draft-reply → Anthropic claude-haiku-4-5 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+39
@@ -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;
|
||||
|
||||
+14
-1
@@ -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<PodcastChecklistData | null>(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() {
|
||||
</div>
|
||||
<div className="cal-popover-actions">
|
||||
<button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={saveEdit}>Save</button>
|
||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => announceEpisode(editEp)} title="Open email compose pre-filled to announce this episode">✉ Announce</button>
|
||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => unschedule(editEp)}>Unschedule</button>
|
||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setEditEp(null)}>Cancel</button>
|
||||
</div>
|
||||
|
||||
@@ -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<string, number> = { 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 && <a className="ct-card-email" href={`mailto:${c.email}`}>{c.email}</a>}
|
||||
{sourceBadge(c.source)}
|
||||
{c.subscribe && <span className="ct-badge ct-badge--sub">subscriber</span>}
|
||||
{c.unreplied && <span className="ct-badge ct-badge--unreplied" title="No reply sent yet">needs reply</span>}
|
||||
{c.bestDeliveryStatus && (
|
||||
<span className={`ct-delivery-pill ct-delivery-pill--${c.bestDeliveryStatus}`}>
|
||||
{c.bestDeliveryStatus === 'clicked' ? '🔗 clicked' : c.bestDeliveryStatus === 'opened' ? '👁 opened' : c.bestDeliveryStatus === 'delivered' ? '✓ delivered' : '→ sent'}
|
||||
</span>
|
||||
)}
|
||||
{c.engagementScore >= 3 && <span className="ct-engage-badge" title={`Engagement score: ${c.engagementScore}`}>{'★'.repeat(Math.min(3, Math.floor(c.engagementScore / 3)))}</span>}
|
||||
{c.submissionCount > 1 && <span className="ct-count-badge">{c.submissionCount}</span>}
|
||||
</div>
|
||||
|
||||
|
||||
+65
-5
@@ -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<ContactSubmission[]>([])
|
||||
const [loadStatus, setLoadStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [mailbox, setMailbox] = useState<Mailbox>('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<HTMLTextAreaElement>(null)
|
||||
const listRef = useRef<HTMLElement>(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 }) {
|
||||
<div className="em-conversation">
|
||||
{getConversationItems(selectedFullThread).map((item, i) => {
|
||||
if (item.type === 'sent') {
|
||||
const deliveryStatus = submissions.find(s => s.id === item.item.submissionId)?.emailStatus?.adminReply?.status
|
||||
return (
|
||||
<div key={item.item.id} className={`em-conv-bubble em-conv-bubble--sent${i === 0 ? ' em-conv-bubble--first' : ''}`}>
|
||||
<div className="em-conv-meta">
|
||||
<span className="em-conv-author">You → {item.item.toEmail}</span>
|
||||
<span className="em-conv-date">
|
||||
{item.item.scheduledAt ? `Scheduled: ${formatDate(item.item.scheduledAt)}` : formatDate(item.item.sentAt)}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
{deliveryStatus && deliveryStatus !== 'idle' && (
|
||||
<span className={`em-delivery-pill em-delivery-pill--${deliveryStatus}`}>
|
||||
{deliveryStatus === 'clicked' ? '🔗 clicked' : deliveryStatus === 'opened' ? '👁 opened' : deliveryStatus === 'delivered' ? '✓ delivered' : deliveryStatus === 'bounced' ? '⚠ bounced' : deliveryStatus === 'sent' ? '→ sent' : deliveryStatus}
|
||||
</span>
|
||||
)}
|
||||
<span className="em-conv-date">
|
||||
{item.item.scheduledAt ? `Scheduled: ${formatDate(item.item.scheduledAt)}` : formatDate(item.item.sentAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="em-conv-subject">{item.item.subject}</div>
|
||||
<pre className="em-detail-plain em-conv-body">{item.item.preview}</pre>
|
||||
@@ -1079,7 +1134,12 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="em-btn em-btn--ghost" onClick={() => { setReplyDraft(null); setShowSchedule(false) }}>Cancel</button>
|
||||
{replyMsg && <span className="em-status-msg em-status-msg--inline">{replyMsg}</span>}
|
||||
{replyDraft.submissionId && (
|
||||
<button type="button" className="em-btn em-btn--ghost" onClick={() => handleAIDraft(replyDraft.submissionId!)} disabled={draftBusy}>
|
||||
{draftBusy ? 'Drafting…' : '✦ AI Draft'}
|
||||
</button>
|
||||
)}
|
||||
{(replyMsg || draftMsg) && <span className="em-status-msg em-status-msg--inline">{replyMsg || draftMsg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user