/**
* Upstream Command Center — Sales & System Pages
* Loaded BEFORE app.jsx so components are available for NAV_SECTIONS.
*
* Pages: OverviewPage, ActivityLogPage, AnalyticsPage
* Placeholder pages: ProspectFeedPage, SequencesPage, DealBoardPage,
* ReplyInboxPage, ContactsPage, SegmentsPage
*/
// ---------------------------------------------------------------------------
// Helpers (apiFetch defined in app.jsx but this loads first — use window.fetch)
// ---------------------------------------------------------------------------
function salesApiFetch(path, options = {}) {
const base = window.__UPSTREAM_API_BASE || "";
return fetch(base + path, {
...options,
credentials: "include",
headers: { ...options.headers, "Content-Type": "application/json" },
});
}
function relativeTime(isoStr) {
if (!isoStr) return "";
const diff = Date.now() - new Date(isoStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return mins + "m ago";
const hrs = Math.floor(mins / 60);
if (hrs < 24) return hrs + "h ago";
const days = Math.floor(hrs / 24);
return days + "d ago";
}
// ---------------------------------------------------------------------------
// OverviewPage — unified metrics from both engines
// ---------------------------------------------------------------------------
function OverviewPage() {
const [dashboard, setDashboard] = React.useState(null);
const [analytics, setAnalytics] = React.useState(null);
const [activity, setActivity] = React.useState(null);
const [deals, setDeals] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
React.useEffect(() => {
Promise.all([
salesApiFetch("/api/dashboard").then(r => r.ok ? r.json() : null).catch(() => null),
salesApiFetch("/api/analytics").then(r => r.ok ? r.json() : null).catch(() => null),
salesApiFetch("/api/activity-log").then(r => r.ok ? r.json() : null).catch(() => null),
salesApiFetch("/api/sales/deals").then(r => r.ok ? r.json() : null).catch(() => null),
]).then(([dash, anal, act, dl]) => {
setDashboard(dash);
setAnalytics(anal);
setActivity(act);
setDeals(dl);
setLoading(false);
}).catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
if (loading) return
Loading overview...
;
if (error) return {error}
;
const subscribers = analytics?.funnel?.find(s => s.stage === "subscribers")?.count || 0;
const prospects = analytics?.funnel?.find(s => s.stage === "prospects")?.count || 0;
const dealCount = deals?.summary ? Object.values(deals.summary).reduce((sum, n) => sum + n, 0) : 0;
const repliesToday = activity?.summary?.today?.reply_received || 0;
const tracks = dashboard?.tracks || [];
const salesPaused = deals?.paused || false;
const sequencesActive = deals?.summary?.sequencing || 0;
const todaySummary = activity?.summary?.today || {};
return (
Overview
Unified command center — content + sales engines
{subscribers}
Subscribers
{prospects}
Active Prospects
{dealCount}
Deals in Pipeline
{repliesToday}
Replies Today
Content Engine
{tracks.length > 0 ? tracks.map(t => (
{t.name}
{t.status}
)) : (
No track data available
)}
Sales Engine
Status
{salesPaused ? "paused" : "active"}
Active Sequences
{sequencesActive}
{analytics?.costs && (
Monthly Cost
${(analytics.costs.total_this_month || 0).toFixed(2)}
)}
{Object.keys(todaySummary).length > 0 && (
Today
{Object.entries(todaySummary).map(([type, count], i) => (
{i > 0 ? ", " : ""}
{count} {type.replace(/_/g, " ")}
))}
)}
);
}
// ---------------------------------------------------------------------------
// ActivityLogPage — reverse-chronological system actions
// ---------------------------------------------------------------------------
const ACTIVITY_TYPE_COLORS = {
email_sent: "#2D7DD2",
reply_received: "#4CAF7D",
prospect_discovered: "#9B59B6",
stage_change: "#E67E22",
note: "#6B7A8D",
meeting_booked: "#C9A84C",
};
const ACTIVITY_FILTERS = [
{ key: "all", label: "All" },
{ key: "email_sent", label: "Emails" },
{ key: "reply_received", label: "Replies" },
{ key: "prospect_discovered", label: "Discoveries" },
{ key: "stage_change", label: "Stage Changes" },
];
function ActivityLogPage() {
const [entries, setEntries] = React.useState([]);
const [summary, setSummary] = React.useState({});
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [filter, setFilter] = React.useState("all");
const [expandedIdx, setExpandedIdx] = React.useState(null);
React.useEffect(() => {
salesApiFetch("/api/activity-log")
.then(r => {
if (!r.ok) throw new Error("Failed to load activity log");
return r.json();
})
.then(data => {
setEntries(data.entries || []);
setSummary(data.summary?.today || {});
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
if (loading) return Loading activity log...
;
if (error) return {error}
;
const filtered = filter === "all" ? entries : entries.filter(e => e.type === filter);
const summaryParts = Object.entries(summary);
return (
Activity Log
System actions from the last 7 days
{summaryParts.length > 0 && (
Today:{" "}
{summaryParts.map(([type, count], i) => (
{i > 0 ? ", " : ""}
{count} {type.replace(/_/g, " ")}
))}
)}
{ACTIVITY_FILTERS.map(f => (
))}
{filtered.length === 0 ? (
No activity entries found
) : (
{filtered.map((entry, idx) => {
const dotColor = ACTIVITY_TYPE_COLORS[entry.type] || "#6B7A8D";
const isExpanded = expandedIdx === idx;
return (
setExpandedIdx(isExpanded ? null : idx)}
>
{relativeTime(entry.at)}
{entry.contact_name && (
{entry.contact_name}
)}
{entry.contact_name && " — "}
{entry.type.replace(/_/g, " ")}
{isExpanded && entry.detail && (
{entry.detail}
)}
);
})}
)}
);
}
// ---------------------------------------------------------------------------
// AnalyticsPage — funnel, attribution, costs
// ---------------------------------------------------------------------------
function AnalyticsPage() {
const [data, setData] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [sortCol, setSortCol] = React.useState("prospects");
const [sortAsc, setSortAsc] = React.useState(false);
React.useEffect(() => {
salesApiFetch("/api/analytics")
.then(r => {
if (!r.ok) throw new Error("Failed to load analytics");
return r.json();
})
.then(d => {
setData(d);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
if (loading) return Loading analytics...
;
if (error) return {error}
;
const funnel = data?.funnel || [];
const maxCount = Math.max(...funnel.map(s => s.count), 1);
const FUNNEL_COLORS = [
"#2D7DD2", "#3A8FD6", "#47A1DA", "#54B3DE",
"#5FC4D6", "#4CAF7D", "#3FA06A",
];
const attribution = data?.attribution || [];
const sortedAttribution = [...attribution].sort((a, b) => {
const va = a[sortCol] ?? 0;
const vb = b[sortCol] ?? 0;
return sortAsc ? va - vb : vb - va;
});
function handleSort(col) {
if (col === sortCol) {
setSortAsc(!sortAsc);
} else {
setSortCol(col);
setSortAsc(false);
}
}
const sortIndicator = (col) => {
if (col !== sortCol) return "";
return sortAsc ? " \u25B2" : " \u25BC";
};
const costs = data?.costs || {};
const services = costs.services || [];
const budgetPct = costs.budget_cap > 0
? Math.min((costs.total_this_month / costs.budget_cap) * 100, 100)
: 0;
const budgetColor = budgetPct > 90 ? "var(--danger)" : budgetPct > 70 ? "var(--cta-gold)" : "var(--data-positive)";
return (
Analytics
Funnel performance, content attribution, and cost tracking
Conversion Funnel
{funnel.map((stage, idx) => (
{stage.stage.replace(/_/g, " ")}
{stage.count}
{idx < funnel.length - 1 && funnel[idx].count > 0 && (
{funnel[idx].count} → {funnel[idx + 1].count}
{" "}({(funnel[idx + 1].count / funnel[idx].count * 100).toFixed(1)}%)
)}
))}
Content Attribution
{sortedAttribution.length === 0 ? (
No attribution data yet
) : (
| Article |
handleSort("prospects")}>
Prospects{sortIndicator("prospects")}
|
handleSort("replies")}>
Replies{sortIndicator("replies")}
|
handleSort("meetings")}>
Meetings{sortIndicator("meetings")}
|
handleSort("revenue")}>
Revenue{sortIndicator("revenue")}
|
{sortedAttribution.map(row => (
| {row.slug} |
{row.prospects} |
{row.replies} |
{row.meetings} |
${(row.revenue || 0).toFixed(0)} |
))}
)}
Cost Breakdown
| Service |
This Month |
Last Month |
Delta |
{services.map(svc => (
| {svc.service} |
${(svc.this_month || 0).toFixed(2)} |
${(svc.last_month || 0).toFixed(2)} |
0 ? "text-negative" : svc.delta < 0 ? "text-positive" : ""}>
{svc.delta > 0 ? "+" : ""}{(svc.delta || 0).toFixed(2)}
|
))}
| Total |
${(costs.total_this_month || 0).toFixed(2)} |
${(costs.total_last_month || 0).toFixed(2)} |
0 ? "text-negative" : "text-positive"}>
{(costs.total_this_month - costs.total_last_month) > 0 ? "+" : ""}
{((costs.total_this_month || 0) - (costs.total_last_month || 0)).toFixed(2)}
|
{costs.budget_cap > 0 && (
Budget: ${(costs.total_this_month || 0).toFixed(2)} / ${(costs.budget_cap || 0).toFixed(2)}
)}
{costs.cac != null && (
Customer Acquisition Cost: ${(costs.cac || 0).toFixed(2)}
)}
{/* Flywheel Metrics */}
{data?.flywheel && (
Flywheel Metrics
{data.flywheel.content_sourced_prospects || 0}
Content-Sourced Prospects
{data.flywheel.outbound_subscriber_rate || 0}%
Outbound-to-Subscriber Rate
{data.flywheel.hot_leads_with_content || 0}
Hot Leads with Content Data
{data.flywheel.contacts_with_content_data || 0}
Contacts with Content Interests
{/* Pillar demand from reply signals */}
{data.flywheel.pillar_demand && Object.keys(data.flywheel.pillar_demand).length > 0 && (
Reply Topic Demand
{Object.entries(data.flywheel.pillar_demand)
.sort((a, b) => b[1] - a[1])
.map(([pillar, count]) => (
{pillar.replace(/_/g, ' ')}: {count}
))}
)}
)}
);
}
// ---------------------------------------------------------------------------
// Placeholder pages — replaced by plans 09-03 and 09-04
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// ProspectCard — compact row with tier/layer badges, score, actions
// ---------------------------------------------------------------------------
function ProspectCard({ prospect, activeTab, expanded, onToggle, onAction }) {
const tierColors = { S: "tier-S", A: "tier-A", B: "tier-B", C: "tier-C" };
const layerColors = {
relationship: "layer-relationship",
operator: "layer-operator",
bdr: "layer-bdr",
newsletter: "layer-newsletter",
};
const [acting, setActing] = React.useState(false);
function handleAction(e, action) {
e.stopPropagation();
if (acting) return;
setActing(true);
onAction(prospect.id, action).finally(() => setActing(false));
}
const interactions = prospect.interactions || [];
const tags = prospect.tags || [];
return (
{prospect.tier || "?"}
{ e.stopPropagation(); window.location.hash = "#contact/" + prospect.id; }}
>{prospect.name || "Unknown"}
{prospect.title || ""}{prospect.title && prospect.company ? " @ " : ""}{prospect.company || ""}
{prospect.layer || ""}
{prospect.total_score || 0}/100
{prospect.pain_summary || ""}
{activeTab === "pending" && (
<>
>
)}
{activeTab === "approved" && Approved}
{activeTab === "skipped" && Skipped}
{expanded && (
Research Dossier
{prospect.pain_summary || "No pain signals recorded."}
Scores
Fit: {prospect.fit_score || 0}/40
Pain: {prospect.pain_score || 0}/40
Access: {prospect.access_score || 0}/20
Total: {prospect.total_score || 0}/100
{tags.length > 0 && (
Tags
{tags.map(t => {t})}
)}
Recent Interactions
{interactions.length === 0 ? (
No interactions yet
) : (
{interactions.slice(0, 5).map((ix, i) => (
{ix.type ? ix.type.replace(/_/g, " ") : "action"} {ix.at ? " — " + relativeTime(ix.at) : ""}
{ix.detail || ""}
))}
)}
)}
);
}
// ---------------------------------------------------------------------------
// ProspectFeedPage — filterable prospect action queue
// ---------------------------------------------------------------------------
function ProspectFeedPage() {
const [prospects, setProspects] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [activeTab, setActiveTab] = React.useState("pending");
const [expandedId, setExpandedId] = React.useState(null);
const [page, setPage] = React.useState(1);
const [totalPages, setTotalPages] = React.useState(1);
function fetchProspects(tab, pg) {
setLoading(true);
setError(null);
const status = tab === "all" ? "" : "&status=" + tab;
salesApiFetch("/api/sales/prospects?page=" + pg + "&per_page=20" + status)
.then(r => {
if (!r.ok) throw new Error("Failed to load prospects");
return r.json();
})
.then(data => {
setProspects(data.prospects || []);
setTotalPages(data.pages || 1);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}
React.useEffect(() => {
fetchProspects(activeTab, page);
}, [activeTab, page]);
function handleTabChange(tab) {
setActiveTab(tab);
setPage(1);
setExpandedId(null);
}
function handleAction(prospectId, action) {
const body = action === "approve"
? { action: "approve" }
: action === "skip"
? { action: "skip" }
: { action: "pause" };
return salesApiFetch("/api/sales/prospects/" + prospectId, {
method: "PUT",
body: JSON.stringify(body),
}).then(r => {
if (!r.ok) throw new Error("Action failed");
if (activeTab === "pending") {
setProspects(prev => prev.filter(p => p.id !== prospectId));
} else {
fetchProspects(activeTab, page);
}
}).catch(err => {
console.error("Prospect action error:", err);
});
}
const tabs = [
{ key: "pending", label: "Pending" },
{ key: "approved", label: "Approved" },
{ key: "skipped", label: "Skipped" },
{ key: "all", label: "All" },
];
return (
Prospect Feed
Review and approve prospects for outreach sequences
{tabs.map(t => (
))}
{loading &&
Loading prospects...
}
{error &&
{error}
}
{!loading && !error && prospects.length === 0 && (
No {activeTab === "all" ? "" : activeTab + " "}prospects found
)}
{!loading && !error && prospects.length > 0 && (
{prospects.map(p => (
setExpandedId(expandedId === p.id ? null : p.id)}
onAction={handleAction}
/>
))}
)}
{!loading && totalPages > 1 && (
Page {page} of {totalPages}
)}
);
}
// ---------------------------------------------------------------------------
// SequencesPage — contacts grouped by layer with delivery timeline
// ---------------------------------------------------------------------------
function SequenceContactCard({ contact }) {
const layerColors = {
relationship: "layer-relationship",
operator: "layer-operator",
bdr: "layer-bdr",
newsletter: "layer-newsletter",
};
function getStatusClass(contact) {
if (contact.sequence_paused) return "status-paused";
if (!contact.sequence_next_send) return "status-on-track";
const nextSend = new Date(contact.sequence_next_send);
return nextSend < new Date() ? "status-overdue" : "status-on-track";
}
function formatNextSend(isoStr) {
if (!isoStr) return "Not scheduled";
const d = new Date(isoStr);
const now = new Date();
const diffMs = d - now;
const diffDays = Math.ceil(diffMs / 86400000);
if (diffDays < 0) return Math.abs(diffDays) + "d overdue";
if (diffDays === 0) return "Today";
if (diffDays === 1) return "Tomorrow";
return "In " + diffDays + "d";
}
return (
{contact.name || "Unknown"}
{contact.company || ""}
{contact.layer || ""}
Touch {contact.sequence_touch || "?"} of {contact.sequence_total || "?"}
{formatNextSend(contact.sequence_next_send)}
{contact.last_interaction && (
{contact.last_interaction.detail || contact.last_interaction.type || ""}
)}
);
}
function SequencesPage() {
const [sequences, setSequences] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
React.useEffect(() => {
salesApiFetch("/api/sales/sequences")
.then(r => {
if (!r.ok) throw new Error("Failed to load sequences");
return r.json();
})
.then(data => {
setSequences(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
if (loading) return Loading sequences...
;
if (error) return {error}
;
const layers = ["relationship", "operator", "bdr"];
const layerLabels = { relationship: "Relationship", operator: "Operator", bdr: "BDR" };
const seqData = sequences?.sequences || {};
const totalActive = sequences?.total_active || 0;
return (
Active Sequences
Contacts currently in outreach sequences
{totalActive} active sequence{totalActive !== 1 ? "s" : ""} across {layers.length} layers
{layers.map(layer => {
const contacts = seqData[layer] || [];
return (
{layerLabels[layer]}
{contacts.length}
{contacts.length === 0 ? (
No active {layer} sequences
) : (
{contacts.map(c => )}
)}
);
})}
);
}
// ---------------------------------------------------------------------------
// DealCard — compact Kanban card with inline expand
// ---------------------------------------------------------------------------
const DEAL_STAGES = ["discovery", "qualified", "meeting", "proposal", "closed_won", "closed_lost"];
const DEAL_STAGE_LABELS = {
discovery: "Discovery",
qualified: "Qualified",
meeting: "Meeting",
proposal: "Proposal",
closed_won: "Closed Won",
closed_lost: "Closed Lost",
};
function DealCard({ deal, expanded, onToggle, onStageChange, onNoteSave }) {
const layerColors = {
relationship: "layer-relationship",
operator: "layer-operator",
bdr: "layer-bdr",
newsletter: "layer-newsletter",
};
const [noteText, setNoteText] = React.useState(deal.notes || "");
const [noteDirty, setNoteDirty] = React.useState(false);
const [saving, setSaving] = React.useState(false);
function handleNoteBlur() {
if (!noteDirty) return;
setSaving(true);
onNoteSave(deal.id, noteText).finally(() => {
setSaving(false);
setNoteDirty(false);
});
}
function handleStageChange(e) {
const newStage = e.target.value;
if (newStage && newStage !== deal.stage) {
onStageChange(deal.id, newStage);
}
}
return (
{ e.stopPropagation(); window.location.hash = "#contact/" + deal.contact_id; }}
>{deal.contact_name || "Unknown"}
{deal.contact_company || ""}
{deal.layer || ""}
{deal.days_in_stage || 0}d
{deal.last_interaction && (
{deal.last_interaction}
)}
{expanded && (
Stage
{deal.stage_history && deal.stage_history.length > 0 && (
Stage History
{deal.stage_history.slice(-3).map((sh, i) => (
{DEAL_STAGE_LABELS[sh.stage] || sh.stage} {sh.at ? " — " + relativeTime(sh.at) : ""}
))}
)}
Notes {saving && (saving...)}
)}
);
}
// ---------------------------------------------------------------------------
// DealBoardPage — 6-column Kanban
// ---------------------------------------------------------------------------
function DealBoardPage() {
const [stageData, setStageData] = React.useState({});
const [summary, setSummary] = React.useState({});
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [expandedDealId, setExpandedDealId] = React.useState(null);
const [closedCollapsed, setClosedCollapsed] = React.useState(true);
function fetchDeals() {
return salesApiFetch("/api/sales/deals")
.then(r => {
if (!r.ok) throw new Error("Failed to load deals");
return r.json();
})
.then(data => {
setStageData(data.stages || {});
setSummary(data.summary || {});
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}
React.useEffect(() => { fetchDeals(); }, []);
function handleStageChange(dealId, newStage) {
salesApiFetch("/api/sales/deals/" + dealId, {
method: "PUT",
body: JSON.stringify({ stage: newStage }),
}).then(r => {
if (!r.ok) throw new Error("Stage change failed");
setExpandedDealId(null);
return fetchDeals();
}).catch(err => console.error("Stage change error:", err));
}
function handleNoteSave(dealId, noteText) {
return salesApiFetch("/api/sales/deals/" + dealId, {
method: "PUT",
body: JSON.stringify({ notes: noteText }),
}).then(r => {
if (!r.ok) throw new Error("Note save failed");
}).catch(err => console.error("Note save error:", err));
}
if (loading) return Loading deals...
;
if (error) return {error}
;
const isClosedStage = (s) => s === "closed_won" || s === "closed_lost";
return (
Deal Board
Pipeline stages from discovery to close
{DEAL_STAGES.map(stage => {
const deals = stageData[stage] || [];
const count = summary[stage] || deals.length;
const isClosed = isClosedStage(stage);
const isCollapsed = isClosed && closedCollapsed;
return (
setClosedCollapsed(!closedCollapsed) : undefined}
>
{DEAL_STAGE_LABELS[stage]}
{isClosed && {closedCollapsed ? "+" : "-"}}
{count}
{!isCollapsed && deals.length === 0 && (
No deals
)}
{!isCollapsed && deals.map(deal => (
setExpandedDealId(expandedDealId === deal.id ? null : deal.id)}
onStageChange={handleStageChange}
onNoteSave={handleNoteSave}
/>
))}
);
})}
);
}
// ---------------------------------------------------------------------------
// Toast — brief success notification
// ---------------------------------------------------------------------------
function Toast({ message, onDone }) {
React.useEffect(() => {
const timer = setTimeout(onDone, 2000);
return () => clearTimeout(timer);
}, [onDone]);
return {message}
;
}
// ---------------------------------------------------------------------------
// ReplyInboxPage — sentiment-tagged replies with quick actions
// ---------------------------------------------------------------------------
function ReplyInboxPage() {
const [replies, setReplies] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [expandedId, setExpandedId] = React.useState(null);
const [noteText, setNoteText] = React.useState({});
const [showNoteFor, setShowNoteFor] = React.useState(null);
const [stageFor, setStageFor] = React.useState(null);
const [toast, setToast] = React.useState(null);
function fetchReplies() {
setLoading(true);
salesApiFetch("/api/sales/replies")
.then(r => {
if (!r.ok) throw new Error("Failed to load replies");
return r.json();
})
.then(data => {
setReplies(data.replies || []);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}
React.useEffect(() => { fetchReplies(); }, []);
function handleAction(contactId, action, detail) {
const body = { action };
if (detail) body.detail = detail;
return salesApiFetch("/api/sales/replies/" + contactId + "/action", {
method: "POST",
body: JSON.stringify(body),
}).then(r => {
if (!r.ok) throw new Error("Action failed");
const labels = {
book_meeting: "Meeting booked",
log_note: "Note logged",
change_stage: "Stage changed",
pause_sequence: "Sequence paused",
};
setToast(labels[action] || "Done");
setShowNoteFor(null);
setStageFor(null);
fetchReplies();
}).catch(err => console.error("Reply action error:", err));
}
if (loading) return Loading replies...
;
if (error) return {error}
;
const sentimentClass = {
positive: "sentiment-positive",
neutral: "sentiment-neutral",
negative: "sentiment-negative",
};
const sentimentLabel = {
positive: "Positive",
neutral: "Neutral",
negative: "Negative",
};
return (
Reply Inbox
Triage replies and take action on warm leads
{replies.length === 0 ? (
No replies yet. Outreach sequences will generate replies here.
) : (
{replies.map((reply, idx) => {
const isExpanded = expandedId === idx;
return (
setExpandedId(isExpanded ? null : idx)}>
{sentimentLabel[reply.sentiment] || "Neutral"}
{ e.stopPropagation(); window.location.hash = "#contact/" + reply.contact_id; }}
>
{reply.contact_name || "Unknown"}
{reply.contact_company || ""}
{relativeTime(reply.at)}
{reply.summary || ""}
{isExpanded && (
{reply.detail || reply.summary || ""}
{showNoteFor === idx && (
)}
{stageFor === idx && (
e.stopPropagation()}>
)}
)}
);
})}
)}
{toast &&
setToast(null)} />}
);
}
// ---------------------------------------------------------------------------
// ContactDetailPage — unified timeline + editable notes/tags + research
// ---------------------------------------------------------------------------
function ContactDetailPage() {
const [contact, setContact] = React.useState(null);
const [deals, setDeals] = React.useState([]);
const [research, setResearch] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [notes, setNotes] = React.useState("");
const [notesDirty, setNotesDirty] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [tags, setTags] = React.useState([]);
const [newTag, setNewTag] = React.useState("");
const [toast, setToast] = React.useState(null);
function getContactId() {
const hash = window.location.hash || "";
const parts = hash.split("/");
return parts.length > 1 ? parts[1] : null;
}
const contactId = getContactId();
function fetchContact() {
if (!contactId) { setError("No contact ID"); setLoading(false); return; }
setLoading(true);
salesApiFetch("/api/contacts/" + contactId)
.then(r => {
if (!r.ok) throw new Error("Failed to load contact");
return r.json();
})
.then(data => {
const c = data.contact || data;
setContact(c);
setNotes(c.notes || "");
setTags(c.tags || []);
setDeals(data.deals || []);
setResearch(data.research || null);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}
React.useEffect(() => { fetchContact(); }, []);
function saveNotes() {
if (!notesDirty) return;
setSaving(true);
salesApiFetch("/api/contacts/" + contactId, {
method: "PUT",
body: JSON.stringify({ notes }),
}).then(r => {
if (!r.ok) throw new Error("Save failed");
setSaving(false);
setNotesDirty(false);
setToast("Notes saved");
}).catch(err => {
console.error("Notes save error:", err);
setSaving(false);
});
}
function saveTags(updatedTags) {
setTags(updatedTags);
salesApiFetch("/api/contacts/" + contactId, {
method: "PUT",
body: JSON.stringify({ tags: updatedTags }),
}).then(r => {
if (!r.ok) throw new Error("Tag save failed");
setToast("Tags updated");
}).catch(err => console.error("Tag save error:", err));
}
function removeTag(tag) {
saveTags(tags.filter(t => t !== tag));
}
function addTag() {
const trimmed = newTag.trim();
if (!trimmed || tags.includes(trimmed)) return;
saveTags([...tags, trimmed]);
setNewTag("");
}
function handleTagKeyDown(e) {
if (e.key === "Enter") { e.preventDefault(); addTag(); }
}
function handlePauseResume() {
salesApiFetch("/api/sales/prospects/" + contactId, {
method: "PUT",
body: JSON.stringify({ action: contact?.sequence_status === "paused" ? "resume" : "pause" }),
}).then(r => {
if (!r.ok) throw new Error("Action failed");
setToast(contact?.sequence_status === "paused" ? "Sequence resumed" : "Sequence paused");
fetchContact();
}).catch(err => console.error("Pause/resume error:", err));
}
function handleStageChange(dealId, newStage) {
salesApiFetch("/api/sales/deals/" + dealId, {
method: "PUT",
body: JSON.stringify({ stage: newStage }),
}).then(r => {
if (!r.ok) throw new Error("Stage change failed");
setToast("Deal stage changed");
fetchContact();
}).catch(err => console.error("Stage change error:", err));
}
if (loading) return Loading contact...
;
if (error) return {error}
;
if (!contact) return Contact not found
;
const TIMELINE_DOT_CLASS = {
email_sent: "dot-email",
reply_received: "dot-reply",
reply: "dot-reply",
prospect_discovered: "dot-discovery",
discovery: "dot-discovery",
stage_change: "dot-stage",
note: "dot-note",
meeting_booked: "dot-meeting",
meeting: "dot-meeting",
subscribed: "dot-subscribed",
};
const interactions = contact.interactions || [];
const typeLabel = { subscriber: "Subscriber", prospect: "Prospect", customer: "Customer", churned: "Churned" };
const tierColors = { S: "tier-S", A: "tier-A", B: "tier-B", C: "tier-C" };
const layerColors = {
relationship: "layer-relationship", operator: "layer-operator",
bdr: "layer-bdr", newsletter: "layer-newsletter",
};
return (
{contact.name || "Unknown"}
{contact.title || ""}{contact.title && contact.company ? " @ " : ""}{contact.company || ""}
{contact.type && {typeLabel[contact.type] || contact.type}}
{contact.tier && {contact.tier}}
{contact.layer && {contact.layer}}
{contact.total_score != null && (
{contact.total_score}/100
)}
{deals.length > 0 && (
)}
Timeline
{interactions.length === 0 ? (
No interactions recorded yet
) : (
{interactions.map((ix, i) => (
{ix.at ? relativeTime(ix.at) : ""}
{ix.source && (
{ix.source}
)}
{(ix.type || "").replace(/_/g, " ")}
{" "}{ix.detail || ""}
))}
)}
Notes {saving && (saving...)}
{research && (
Research Dossier
{research.company_summary && (
{research.company_summary}
)}
{research.pain_signals && research.pain_signals.length > 0 && (
Pain Signals
{research.pain_signals.map((sig, i) => - {sig}
)}
)}
{research.recommended_angle && (
Recommended Angle
{research.recommended_angle}
)}
)}
{deals.length > 0 && (
Deals
{deals.map(deal => (
{(deal.stage || "").replace(/_/g, " ")}
{deal.days_in_stage || 0}d in stage
))}
)}
{toast &&
setToast(null)} />}
);
}
// ---------------------------------------------------------------------------
// ContactsPage — filterable, searchable contact table
// ---------------------------------------------------------------------------
function ContactsPage() {
const [contacts, setContacts] = React.useState([]);
const [total, setTotal] = React.useState(0);
const [page, setPage] = React.useState(1);
const [totalPages, setTotalPages] = React.useState(1);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [typeFilter, setTypeFilter] = React.useState("all");
const [layerFilter, setLayerFilter] = React.useState("all");
const [search, setSearch] = React.useState("");
const searchTimer = React.useRef(null);
function fetchContacts(pg, type, layer, q) {
setLoading(true);
let url = "/api/contacts?page=" + pg + "&per_page=20";
if (type && type !== "all") url += "&type=" + type;
if (layer && layer !== "all") url += "&layer=" + layer;
if (q) url += "&search=" + encodeURIComponent(q);
salesApiFetch(url)
.then(r => {
if (!r.ok) throw new Error("Failed to load contacts");
return r.json();
})
.then(data => {
setContacts(data.contacts || []);
setTotal(data.total || 0);
setTotalPages(data.pages || 1);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}
React.useEffect(() => {
fetchContacts(page, typeFilter, layerFilter, search);
}, [page, typeFilter, layerFilter]);
function handleSearchInput(e) {
const val = e.target.value;
setSearch(val);
if (searchTimer.current) clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => {
setPage(1);
fetchContacts(1, typeFilter, layerFilter, val);
}, 300);
}
function handleTypeChange(e) {
setTypeFilter(e.target.value);
setPage(1);
}
function handleLayerChange(e) {
setLayerFilter(e.target.value);
setPage(1);
}
const tierColors = { S: "tier-S", A: "tier-A", B: "tier-B", C: "tier-C" };
return (
All Contacts
Unified contact database with search and filters
{total} contact{total !== 1 ? "s" : ""}
{loading &&
Loading contacts...
}
{error &&
{error}
}
{!loading && !error && contacts.length === 0 && (
No contacts found
)}
{!loading && !error && contacts.length > 0 && (
| Name |
Company |
Type |
Tier |
Layer |
Score |
Last Activity |
{contacts.map(c => (
|
{ window.location.hash = "#contact/" + c.id; }}
>
{c.name || "Unknown"}
|
{c.company || ""} |
{c.type || ""} |
{c.tier && {c.tier}} |
{c.layer || ""} |
{c.total_score != null ? c.total_score + "/100" : ""} |
{c.last_activity ? relativeTime(c.last_activity) : ""} |
))}
)}
{!loading && totalPages > 1 && (
Page {page} of {totalPages}
)}
);
}
// ---------------------------------------------------------------------------
// FreestyleCreatorPage — natural language content generation workspace
// ---------------------------------------------------------------------------
function FreestyleCreatorPage() {
const [prompt, setPrompt] = React.useState("");
const [format, setFormat] = React.useState("auto");
const [tone, setTone] = React.useState("auto");
const [imageFile, setImageFile] = React.useState(null);
const [loading, setLoading] = React.useState(false);
const [result, setResult] = React.useState(null);
const [error, setError] = React.useState(null);
const [recent, setRecent] = React.useState([]);
const [scheduleStatus, setScheduleStatus] = React.useState({});
React.useEffect(() => {
salesApiFetch("/api/create/recent")
.then(r => r.ok ? r.json() : { items: [] })
.then(data => setRecent(data.items || []))
.catch(() => {});
}, []);
function handleImageChange(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setImageFile(ev.target.result);
reader.readAsDataURL(file);
}
async function handleGenerate() {
if (!prompt.trim()) return;
setLoading(true);
setResult(null);
setError(null);
try {
const body = { prompt, format: format !== "auto" ? format : undefined, tone: tone !== "auto" ? tone : undefined };
if (imageFile) body.image_data = imageFile;
const resp = await salesApiFetch("/api/create", {
method: "POST",
body: JSON.stringify(body)
});
if (!resp.ok) throw new Error(await resp.text());
setResult(await resp.json());
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}
async function handleSchedule(variantText, idx) {
try {
const resp = await salesApiFetch("/api/create/schedule", {
method: "POST",
body: JSON.stringify({ content: variantText, platform: result?.content_type || "linkedin_post" })
});
if (!resp.ok) throw new Error();
setScheduleStatus(s => ({ ...s, [idx]: "scheduled" }));
} catch {
setScheduleStatus(s => ({ ...s, [idx]: "error" }));
}
}
function handleCopy(text) {
navigator.clipboard?.writeText(text).catch(() => {});
}
const FORMAT_OPTIONS = [
{ value: "auto", label: "Auto-detect" },
{ value: "linkedin_post", label: "LinkedIn Post" },
{ value: "linkedin_ad", label: "LinkedIn Ad" },
{ value: "substack_note", label: "Substack Note" },
{ value: "email_draft", label: "Email Draft" },
{ value: "blog_outline", label: "Blog Outline" },
{ value: "thread", label: "Thread" },
];
const TONE_OPTIONS = [
{ value: "auto", label: "Auto-detect" },
{ value: "analytical", label: "Analytical" },
{ value: "provocative", label: "Provocative" },
{ value: "conversational", label: "Conversational" },
{ value: "urgent", label: "Urgent" },
];
const variants = result ? [result.primary, ...(result.variations || [])].filter(Boolean) : [];
const variantLabels = ["Variant A (recommended)", "Variant B", "Variant C"];
return (
Freestyle Creator
Natural language → LinkedIn posts, ads, emails, Substack notes
{/* Input card */}
{/* Output variants */}
{result && (
{result.pillar_tag && Pillar: {result.pillar_tag}}
{result.content_type && Format: {result.content_type}}
{result.estimated_cost_usd != null && Cost: ${result.estimated_cost_usd.toFixed(3)}}
{result.suggested_schedule && Schedule: {result.suggested_schedule}}
{variants.map((text, idx) => (
{variantLabels[idx] || `Variant ${idx + 1}`}
{text}
{idx === 0 && (
)}
))}
{result.subject_lines?.length > 0 && (
Subject Lines / Headlines
{result.subject_lines.map((s, i) => (
{s}
))}
)}
)}
{/* Recent Creations */}
{recent.length > 0 && (
Recent Creations
{recent.slice(0, 5).map((item, i) => (
setPrompt(item.prompt || "")}>
{(item.prompt || "").slice(0, 60)}{(item.prompt || "").length > 60 ? "..." : ""}
{item.content_type}
{relativeTime(item.created_at)}
))}
)}
);
}
// ---------------------------------------------------------------------------
// SegmentsPage — predefined smart segments with filtered contact list
// ---------------------------------------------------------------------------
const SEGMENT_DEFS = [
{ key: "subscribers", label: "Subscribers", desc: "Newsletter subscribers from Ghost" },
{ key: "hot_leads", label: "Hot Leads", desc: "Fit score 80+ - high priority" },
{ key: "s_tier", label: "S-Tier", desc: "Top-tier prospects for relationship building" },
{ key: "active_sequences", label: "Active Sequences", desc: "Currently receiving outreach emails" },
{ key: "replied", label: "Replied", desc: "Contacts who have replied to outreach" },
{ key: "meeting_booked", label: "Meeting Booked", desc: "Meetings scheduled or completed" },
{ key: "paused", label: "Paused", desc: "Sequences on hold" },
];
function SegmentsPage() {
const [activeSeg, setActiveSeg] = React.useState(null);
const [segContacts, setSegContacts] = React.useState([]);
const [segCount, setSegCount] = React.useState(0);
const [segLoading, setSegLoading] = React.useState(false);
const [counts, setCounts] = React.useState({});
const [countsLoading, setCountsLoading] = React.useState(true);
React.useEffect(() => {
// Fetch counts for all segments in parallel
setCountsLoading(true);
Promise.all(
SEGMENT_DEFS.map(seg =>
salesApiFetch("/api/contacts/segments/" + seg.key)
.then(r => r.ok ? r.json() : { count: 0 })
.then(data => ({ key: seg.key, count: data.count || 0 }))
.catch(() => ({ key: seg.key, count: 0 }))
)
).then(results => {
const countMap = {};
results.forEach(r => { countMap[r.key] = r.count; });
setCounts(countMap);
setCountsLoading(false);
});
}, []);
function loadSegment(segKey) {
if (activeSeg === segKey) {
setActiveSeg(null);
setSegContacts([]);
return;
}
setActiveSeg(segKey);
setSegLoading(true);
salesApiFetch("/api/contacts/segments/" + segKey)
.then(r => {
if (!r.ok) throw new Error("Failed to load segment");
return r.json();
})
.then(data => {
setSegContacts(data.contacts || []);
setSegCount(data.count || 0);
setSegLoading(false);
})
.catch(() => {
setSegContacts([]);
setSegLoading(false);
});
}
const tierColors = { S: "tier-S", A: "tier-A", B: "tier-B", C: "tier-C" };
return (
Segments
Predefined smart segments for quick access to contact groups
{SEGMENT_DEFS.map(seg => (
loadSegment(seg.key)}
>
{seg.label}
{countsLoading ? "..." : (counts[seg.key] || 0)}
{seg.desc}
))}
{activeSeg && (
{SEGMENT_DEFS.find(s => s.key === activeSeg)?.label || activeSeg}
({segCount} contacts)
{segLoading &&
Loading segment...
}
{!segLoading && segContacts.length === 0 && (
No contacts in this segment
)}
{!segLoading && segContacts.length > 0 && (
| Name |
Company |
Type |
Tier |
Score |
{segContacts.map(c => (
|
{ window.location.hash = "#contact/" + c.id; }}
>
{c.name || "Unknown"}
|
{c.company || ""} |
{c.type || ""} |
{c.tier && {c.tier}} |
{c.total_score != null ? c.total_score + "/100" : ""} |
))}
)}
)}
);
}