Add recent outreach rejections stream
This commit is contained in:
1 parent
0c21202253
commit
94049ed9c4
9 files changed
+170
-16
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { OutreachCandidate, OutreachCandidateStatus } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import type { OutreachCandidate, OutreachCandidateStatus, OutreachRejectedCandidate } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||
import { OriginPostCard } from '../../components/origin-post.js';
|
||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||
@@ -11,7 +11,7 @@ import { DeleteCandidateDialog } from './delete-candidate-dialog.js';
|
||||
import { EditMessageDialog } from './edit-message-dialog.js';
|
||||
import './outreach.css';
|
||||
|
||||
type OutreachTab = 'active' | 'rejections';
|
||||
type OutreachTab = 'active' | 'reasons' | 'recent-rejections';
|
||||
|
||||
const STAGES: ReadonlyArray<{
|
||||
status: OutreachCandidateStatus;
|
||||
@@ -152,6 +152,29 @@ function CandidateCard({
|
||||
);
|
||||
}
|
||||
|
||||
function RejectedCandidateCard({ candidate, now }: { candidate: OutreachRejectedCandidate; now: number }) {
|
||||
return (
|
||||
<li className="rejected-candidate">
|
||||
<div className="rejected-candidate-heading">
|
||||
<div className="candidate-labels">
|
||||
<span className="candidate-status status-rejected">Rejected</span>
|
||||
<span>r/{candidate.subreddit}</span>
|
||||
<span>{candidate.sourceType === 'image' ? 'Image post' : 'Chat post'}</span>
|
||||
</div>
|
||||
<time dateTime={new Date(candidate.rejectedAt).toISOString()} title={formatExactTime(candidate.rejectedAt)}>
|
||||
{formatShortRelativeTime(candidate.rejectedAt, now)}
|
||||
</time>
|
||||
</div>
|
||||
<a className="candidate-title" href={candidate.postUrl} rel="noreferrer" target="_blank">
|
||||
{candidate.title} <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
<RedditUsernameLink username={candidate.author} />
|
||||
{candidate.sourceContent ? <blockquote className="rejected-source-content">{candidate.sourceContent}</blockquote> : null}
|
||||
<p className="rejection-reason"><strong>Rejected because</strong>{candidate.reason}</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function OutreachPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const query = useQuery({
|
||||
@@ -177,16 +200,20 @@ export function OutreachPage() {
|
||||
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||
const [selectedTab, setSelectedTab] = useState<OutreachTab>('active');
|
||||
const activeTabRef = useRef<HTMLButtonElement>(null);
|
||||
const rejectionsTabRef = useRef<HTMLButtonElement>(null);
|
||||
const reasonsTabRef = useRef<HTMLButtonElement>(null);
|
||||
const recentRejectionsTabRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const selectTab = (tab: OutreachTab, focus = false) => {
|
||||
setSelectedTab(tab);
|
||||
if (focus) (tab === 'active' ? activeTabRef : rejectionsTabRef).current?.focus();
|
||||
if (focus) ({ active: activeTabRef, reasons: reasonsTabRef, 'recent-rejections': recentRejectionsTabRef }[tab]).current?.focus();
|
||||
};
|
||||
const handleTabKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight' && event.key !== 'Home' && event.key !== 'End') return;
|
||||
event.preventDefault();
|
||||
selectTab(event.key === 'ArrowLeft' || event.key === 'Home' ? 'active' : 'rejections', true);
|
||||
const tabs: OutreachTab[] = ['active', 'reasons', 'recent-rejections'];
|
||||
const currentIndex = tabs.indexOf(selectedTab);
|
||||
const nextIndex = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (currentIndex + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
|
||||
selectTab(tabs[nextIndex], true);
|
||||
};
|
||||
|
||||
// Single client-side clock for every displayed offset: retry countdowns tick
|
||||
@@ -289,17 +316,31 @@ export function OutreachPage() {
|
||||
</button>
|
||||
<button
|
||||
aria-controls="outreach-rejections-panel"
|
||||
aria-selected={selectedTab === 'rejections'}
|
||||
aria-selected={selectedTab === 'reasons'}
|
||||
className="outreach-tab"
|
||||
id="outreach-rejections-tab"
|
||||
onClick={() => selectTab('rejections')}
|
||||
id="outreach-reasons-tab"
|
||||
onClick={() => selectTab('reasons')}
|
||||
onKeyDown={handleTabKeyDown}
|
||||
ref={rejectionsTabRef}
|
||||
ref={reasonsTabRef}
|
||||
role="tab"
|
||||
tabIndex={selectedTab === 'rejections' ? 0 : -1}
|
||||
tabIndex={selectedTab === 'reasons' ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
Rejections <span>{data.outcomes.rejected}</span>
|
||||
Rejection reasons <span>{data.outcomes.rejected}</span>
|
||||
</button>
|
||||
<button
|
||||
aria-controls="outreach-recent-rejections-panel"
|
||||
aria-selected={selectedTab === 'recent-rejections'}
|
||||
className="outreach-tab"
|
||||
id="outreach-recent-rejections-tab"
|
||||
onClick={() => selectTab('recent-rejections')}
|
||||
onKeyDown={handleTabKeyDown}
|
||||
ref={recentRejectionsTabRef}
|
||||
role="tab"
|
||||
tabIndex={selectedTab === 'recent-rejections' ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
Recent rejections <span>{data.recentRejections.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -328,8 +369,8 @@ export function OutreachPage() {
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<section aria-labelledby="outreach-rejections-tab" className="rejection-section" id="outreach-rejections-panel" role="tabpanel">
|
||||
) : selectedTab === 'reasons' ? (
|
||||
<section aria-labelledby="outreach-reasons-tab" className="rejection-section" id="outreach-rejections-panel" role="tabpanel">
|
||||
<div className="panel-heading">
|
||||
<div><span className="eyebrow">Terminal screening outcomes</span><h2>Rejections</h2></div>
|
||||
<span className="candidate-updated">Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s</span>
|
||||
@@ -344,6 +385,20 @@ export function OutreachPage() {
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<section aria-labelledby="outreach-recent-rejections-tab" className="rejection-section" id="outreach-recent-rejections-panel" role="tabpanel">
|
||||
<div className="panel-heading">
|
||||
<div><span className="eyebrow">Latest 50 terminal outcomes</span><h2>Recent rejections</h2></div>
|
||||
<span className="candidate-updated">Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s</span>
|
||||
</div>
|
||||
{data.recentRejections.length === 0 ? (
|
||||
<div className="empty-state panel"><strong>No rejected candidates yet.</strong><span>The most recent 50 screening rejections will appear here with their source and reason.</span></div>
|
||||
) : (
|
||||
<ol aria-label="Recent rejected outreach candidates" className="recent-rejections">
|
||||
{data.recentRejections.map((candidate) => <RejectedCandidateCard candidate={candidate} key={candidate.postId} now={now} />)}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{isConfirmingClear ? (
|
||||
<ClearFunnelDialog
|
||||
|
||||
@@ -30,6 +30,14 @@
|
||||
.rejection-reasons li { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 1rem; padding: .8rem .9rem; color: #e7d9b1; background: #211b12; border: 1px solid #4c3b1d; border-left: 3px solid var(--warning); border-radius: .65rem; }
|
||||
.rejection-reasons span { overflow-wrap: anywhere; }
|
||||
.rejection-reasons strong { min-width: 2rem; color: var(--warning-text); font-size: 1.1rem; text-align: right; }
|
||||
.recent-rejections { display: grid; gap: .7rem; padding: 0; margin: 0; list-style: none; }
|
||||
.rejected-candidate { display: grid; gap: .65rem; padding: .9rem 1rem; background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--warning); border-radius: .75rem; }
|
||||
.rejected-candidate-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
|
||||
.rejected-candidate-heading time { flex: 0 0 auto; color: var(--muted); font-size: .74rem; }
|
||||
.status-rejected { color: var(--warning-text); background: #42351b; }
|
||||
.rejected-source-content { margin: 0; padding: .65rem .75rem; color: #dce8e2; background: #111915; border-left: 3px solid #426655; border-radius: .35rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.rejection-reason { display: grid; gap: .18rem; margin: 0; padding: .55rem .7rem; color: #e7d9b1; background: #211b12; border-left: 2px solid var(--warning); border-radius: .35rem; overflow-wrap: anywhere; }
|
||||
.rejection-reason strong { color: var(--warning-text); font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.candidate-updated { color: var(--muted); font-size: .8rem; }
|
||||
.outreach-candidates { display: grid; gap: .7rem; padding: 0; margin: 0; list-style: none; }
|
||||
.outreach-candidate { display: grid; grid-template-columns: .32rem minmax(0, 1fr); overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: .85rem; }
|
||||
|
||||
Reference in new issue
Block a user