Add outreach rejection reasons tab
This commit is contained in:
1 parent
7af7b211fa
commit
0c21202253
7 files changed
+91
-3
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||
@@ -11,6 +11,8 @@ import { DeleteCandidateDialog } from './delete-candidate-dialog.js';
|
||||
import { EditMessageDialog } from './edit-message-dialog.js';
|
||||
import './outreach.css';
|
||||
|
||||
type OutreachTab = 'active' | 'rejections';
|
||||
|
||||
const STAGES: ReadonlyArray<{
|
||||
status: OutreachCandidateStatus;
|
||||
label: string;
|
||||
@@ -173,6 +175,19 @@ export function OutreachPage() {
|
||||
const [editingCandidate, setEditingCandidate] = useState<OutreachCandidate | null>(null);
|
||||
const [deletingCandidate, setDeletingCandidate] = useState<OutreachCandidate | null>(null);
|
||||
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||
const [selectedTab, setSelectedTab] = useState<OutreachTab>('active');
|
||||
const activeTabRef = useRef<HTMLButtonElement>(null);
|
||||
const rejectionsTabRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const selectTab = (tab: OutreachTab, focus = false) => {
|
||||
setSelectedTab(tab);
|
||||
if (focus) (tab === 'active' ? activeTabRef : rejectionsTabRef).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);
|
||||
};
|
||||
|
||||
// Single client-side clock for every displayed offset: retry countdowns tick
|
||||
// every second while visible, everything else every 10 seconds.
|
||||
@@ -257,7 +272,39 @@ export function OutreachPage() {
|
||||
<p className="runway-caption">Retrying posts loop back to Ready when their cooldown expires. Sent and not-sent totals, including manually cleared candidates, cover the lifetime of this queue.</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="active-candidates-heading" className="candidate-section">
|
||||
<div aria-label="Outreach funnel views" className="outreach-tabs" role="tablist">
|
||||
<button
|
||||
aria-controls="outreach-active-panel"
|
||||
aria-selected={selectedTab === 'active'}
|
||||
className="outreach-tab"
|
||||
id="outreach-active-tab"
|
||||
onClick={() => selectTab('active')}
|
||||
onKeyDown={handleTabKeyDown}
|
||||
ref={activeTabRef}
|
||||
role="tab"
|
||||
tabIndex={selectedTab === 'active' ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
Active queue <span>{data.activeCount}</span>
|
||||
</button>
|
||||
<button
|
||||
aria-controls="outreach-rejections-panel"
|
||||
aria-selected={selectedTab === 'rejections'}
|
||||
className="outreach-tab"
|
||||
id="outreach-rejections-tab"
|
||||
onClick={() => selectTab('rejections')}
|
||||
onKeyDown={handleTabKeyDown}
|
||||
ref={rejectionsTabRef}
|
||||
role="tab"
|
||||
tabIndex={selectedTab === 'rejections' ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
Rejections <span>{data.outcomes.rejected}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedTab === 'active' ? (
|
||||
<section aria-labelledby="outreach-active-tab" className="candidate-section" id="outreach-active-panel" role="tabpanel">
|
||||
<div className="panel-heading">
|
||||
<div><span className="eyebrow">Potential posts</span><h2 id="active-candidates-heading">Active queue</h2></div>
|
||||
<span className="candidate-updated">
|
||||
@@ -281,6 +328,23 @@ export function OutreachPage() {
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<section aria-labelledby="outreach-rejections-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>
|
||||
</div>
|
||||
{data.rejectionReasons.length === 0 ? (
|
||||
<div className="empty-state panel"><strong>No rejection reasons recorded.</strong><span>Rejected candidates will be grouped here as the funnel screens them.</span></div>
|
||||
) : (
|
||||
<ol aria-label="Outreach rejection reasons" className="rejection-reasons">
|
||||
{data.rejectionReasons.map(({ reason, count }) => (
|
||||
<li key={reason}><span>{reason}</span><strong>{count}</strong></li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{isConfirmingClear ? (
|
||||
<ClearFunnelDialog
|
||||
clearableCount={clearableCount}
|
||||
|
||||
@@ -20,6 +20,16 @@
|
||||
.stage-retryable .stage-marker { background: var(--warning); }
|
||||
.runway-caption { position: relative; margin: .9rem 0 0; color: var(--muted); font-size: .78rem; }
|
||||
.candidate-section { margin-top: .5rem; }
|
||||
.outreach-tabs { display: flex; width: fit-content; max-width: 100%; overflow-x: auto; padding: .25rem; background: #101a16; border: 1px solid #355044; border-radius: .7rem; }
|
||||
.outreach-tab { display: inline-flex; align-items: center; gap: .45rem; min-height: 2.3rem; padding: .45rem .7rem; color: var(--muted); background: transparent; border: 0; border-radius: .45rem; font: inherit; font-size: .8rem; font-weight: 750; white-space: nowrap; cursor: pointer; }
|
||||
.outreach-tab[aria-selected="true"] { color: #eef6f1; background: #20362b; box-shadow: inset 0 0 0 1px #426655; }
|
||||
.outreach-tab span { min-width: 1.45rem; padding: .08rem .35rem; color: #dce8e2; background: rgba(9, 17, 14, .7); border-radius: 999px; font-size: .7rem; text-align: center; }
|
||||
.outreach-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.rejection-section { display: grid; gap: .75rem; }
|
||||
.rejection-reasons { display: grid; gap: .55rem; padding: 0; margin: 0; list-style: none; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
@@ -36,6 +36,8 @@ Prepared cards in the authenticated outreach funnel expose the exact opener, gro
|
||||
|
||||
The funnel header also offers a destructive, confirmed **Clear funnel** action. Its dialog names the number of cancellable candidates, explains that audit rows remain and running discovery can refill the queue, and separately identifies any in-flight sends that cannot safely be cancelled. Success is reported from the authoritative API response before the funnel query refreshes.
|
||||
|
||||
The outreach funnel separates the live queue from its terminal screening outcomes. Its **Rejections** tab groups durable rejected candidates by their recorded reason, so an empty queue can be diagnosed without exposing the rejected post content or treating a rejection as an operator action.
|
||||
|
||||
Each non-sending candidate has a confirmed **Remove** action. Prepared candidates in ready or retrying state also have **Edit message**: the modal validates the 1–2000 character limit, the server re-applies outbound safeguards, and a successful edit is shown as operator-authored attribution. Buttons are intentionally absent once the candidate is sending, while the server still enforces the state boundary against stale pages.
|
||||
|
||||
## State model
|
||||
|
||||
@@ -72,6 +72,10 @@ export const OutreachFunnelResponseSchema = z.strictObject({
|
||||
sent: z.number().int().nonnegative(),
|
||||
rejected: z.number().int().nonnegative(),
|
||||
}),
|
||||
rejectionReasons: z.array(z.strictObject({
|
||||
reason: z.string(),
|
||||
count: z.number().int().positive(),
|
||||
})).default([]),
|
||||
oldestActiveAt: z.number().nullable(),
|
||||
candidates: z.array(OutreachCandidateSchema),
|
||||
});
|
||||
|
||||
@@ -128,6 +128,7 @@ export function createOutreachRouter(ctx: WebContext): Router {
|
||||
activeCount: ACTIVE_STATUSES.reduce((total, status) => total + stats.byStatus[status], 0),
|
||||
stages,
|
||||
outcomes: { sent: stats.byStatus.sent, rejected: stats.byStatus.rejected },
|
||||
rejectionReasons: stats.rejectionReasons,
|
||||
oldestActiveAt: queue.getOldestActiveAt(),
|
||||
candidates,
|
||||
};
|
||||
|
||||
@@ -129,6 +129,7 @@ describe('dashboard foundation', () => {
|
||||
activeCount: 3,
|
||||
stages: { discovered: 1, screening: 0, ready: 1, sending: 0, retryable: 1 },
|
||||
outcomes: { sent: 12, rejected: 7 },
|
||||
rejectionReasons: [{ reason: 'Seller signals detected', count: 5 }, { reason: 'No gender tag in title/body', count: 2 }],
|
||||
oldestActiveAt: Date.parse('2026-08-09T07:00:00Z'),
|
||||
candidates: [{
|
||||
postId: 'candidate-1', subreddit: 'example', author: 'alice', title: '[25F] A potential post',
|
||||
@@ -169,6 +170,10 @@ describe('dashboard foundation', () => {
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Outreach funnel' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '3 active posts' })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('tab', { name: 'Rejections 7' }));
|
||||
expect(screen.getByRole('tabpanel', { name: 'Rejections 7' })).toHaveTextContent('Seller signals detected');
|
||||
expect(screen.getByRole('list', { name: 'Outreach rejection reasons' })).toHaveTextContent('5');
|
||||
await user.click(screen.getByRole('tab', { name: 'Active queue 3' }));
|
||||
expect(screen.getByRole('list', { name: 'Active outreach candidates' })).toHaveTextContent('A potential post');
|
||||
expect(screen.getAllByRole('link', { name: '[25F] A potential post' }))
|
||||
.toEqual(expect.arrayContaining([
|
||||
@@ -219,6 +224,7 @@ describe('dashboard foundation', () => {
|
||||
activeCount: cleared ? 1 : 3,
|
||||
stages: { discovered: cleared ? 0 : 2, screening: 0, ready: 0, sending: 1, retryable: 0 },
|
||||
outcomes: { sent: 12, rejected: cleared ? 9 : 7 },
|
||||
rejectionReasons: [{ reason: 'Cleared manually by operator', count: cleared ? 2 : 0 }].filter(({ count }) => count > 0),
|
||||
oldestActiveAt: Date.now() - 60_000,
|
||||
candidates: [],
|
||||
})),
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('outreach route', () => {
|
||||
byStatus: { discovered: 2, screening: 1, ready: 1, sending: 1, retryable: 1, sent: 2, rejected: 1 },
|
||||
ready: 1,
|
||||
oldestReadyAt: 2_000,
|
||||
rejectionReasons: [],
|
||||
rejectionReasons: [{ reason: 'Seller signals detected', count: 1 }],
|
||||
})),
|
||||
getOldestActiveAt: vi.fn(() => 1_000),
|
||||
listActive: vi.fn(() => [{
|
||||
@@ -88,6 +88,7 @@ describe('outreach route', () => {
|
||||
activeCount: 6,
|
||||
stages: { discovered: 2, screening: 1, ready: 1, sending: 1, retryable: 1 },
|
||||
outcomes: { sent: 2, rejected: 1 },
|
||||
rejectionReasons: [{ reason: 'Seller signals detected', count: 1 }],
|
||||
candidates: [{
|
||||
postId: 'post-1', status: 'sending', hasPreparedMessage: true,
|
||||
postUrl: 'https://www.reddit.com/r/example/comments/post-1/hello/',
|
||||
|
||||
Reference in new issue
Block a user