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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user