210 lines
10 KiB
TypeScript
210 lines
10 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import type { OutreachCandidate, OutreachCandidateStatus } 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';
|
||
import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js';
|
||
import { outreachApi } from './outreach-api.js';
|
||
import './outreach.css';
|
||
|
||
const STAGES: ReadonlyArray<{
|
||
status: OutreachCandidateStatus;
|
||
label: string;
|
||
description: string;
|
||
}> = [
|
||
{ status: 'discovered', label: 'Waiting', description: 'Found and queued' },
|
||
{ status: 'screening', label: 'Screening', description: 'Checking eligibility' },
|
||
{ status: 'ready', label: 'Ready', description: 'Message prepared' },
|
||
{ status: 'sending', label: 'Sending', description: 'Assigned to account' },
|
||
{ status: 'retryable', label: 'Retrying', description: 'Cooling down' },
|
||
];
|
||
|
||
const KNOWN_STATUSES = new Set<string>(STAGES.map((stage) => stage.status));
|
||
|
||
function statusLabel(status: OutreachCandidateStatus): string {
|
||
const stage = STAGES.find((candidate) => candidate.status === status);
|
||
if (stage) return stage.label;
|
||
// Unknown statuses fall back to a readable neutral label instead of the raw enum.
|
||
const readable = String(status).replaceAll(/[-_]/g, ' ');
|
||
return readable.charAt(0).toUpperCase() + readable.slice(1);
|
||
}
|
||
|
||
function formatWaitTime(timestamp: number, now: number): string {
|
||
const remaining = Math.max(0, timestamp - now);
|
||
if (remaining < 60_000) return 'in under a minute';
|
||
if (remaining < 3_600_000) return `in ${Math.ceil(remaining / 60_000)}m`;
|
||
if (remaining < 86_400_000) return `in ${Math.ceil(remaining / 3_600_000)}h`;
|
||
return `in ${Math.ceil(remaining / 86_400_000)}d`;
|
||
}
|
||
|
||
function PreparedCandidateDetails({ candidate }: { candidate: OutreachCandidate }) {
|
||
const preparation = candidate.preparation;
|
||
if (!preparation) return null;
|
||
const targeting = preparation.physiqueTargeting;
|
||
const strategy = preparation.scripted
|
||
? 'Scripted opener'
|
||
: preparation.strategyId ?? 'Default strategy';
|
||
|
||
return (
|
||
<section aria-label={`Prepared outreach for u/${candidate.author}`} className="candidate-preparation">
|
||
<div className="candidate-preparation-grid">
|
||
{preparation.sourceImageUrl ? (
|
||
<OriginPostCard post={{
|
||
title: candidate.title,
|
||
url: candidate.postUrl,
|
||
imageUrl: preparation.sourceImageUrl,
|
||
subreddit: candidate.subreddit,
|
||
postedAt: candidate.postCreatedAt,
|
||
}} />
|
||
) : null}
|
||
<div className="candidate-preparation-copy">
|
||
<div className="candidate-score-grid" aria-label="Preparation scoring">
|
||
<div><span>Attractiveness</span><strong>{preparation.attractivenessScore === null ? 'Not scored' : `${preparation.attractivenessScore}/100`}</strong><small>Used in the opener prompt</small></div>
|
||
<div><span>Physique</span><strong>{preparation.physiqueScore === null ? 'Not scorable' : `${preparation.physiqueScore}/10`}</strong><small>{targeting?.enabled ? `Current target ${targeting.minScore}–${targeting.maxScore}` : 'Current filter off'}</small></div>
|
||
<div><span>Detected gender</span><strong>{preparation.detectedGender}</strong><small>Image analysis</small></div>
|
||
</div>
|
||
<div className="candidate-preparation-metadata">
|
||
<span><strong>Strategy</strong> {strategy}</span>
|
||
{preparation.selectionReason ? <span><strong>Selection</strong> {preparation.selectionReason}</span> : null}
|
||
<span><strong>Tone</strong> {preparation.toneId}</span>
|
||
<span><strong>Model</strong> {preparation.modelId}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="candidate-prepared-message">
|
||
<span>Prepared message</span>
|
||
<blockquote>{preparation.message}</blockquote>
|
||
</div>
|
||
<details className="candidate-analysis">
|
||
<summary>Show image analysis</summary>
|
||
<pre>{preparation.imageAnalysis}</pre>
|
||
</details>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now: number }) {
|
||
const waitingUntil = candidate.availableAt > now;
|
||
const isKnownStatus = KNOWN_STATUSES.has(candidate.status);
|
||
return (
|
||
<li className={`outreach-candidate ${isKnownStatus ? `is-${candidate.status}` : 'is-unknown'}`}>
|
||
<div className="candidate-status-rail" aria-hidden="true"><span /></div>
|
||
<article>
|
||
<div className="candidate-heading">
|
||
<div className="candidate-labels">
|
||
<span className={`candidate-status ${isKnownStatus ? `status-${candidate.status}` : 'status-neutral'}`}>{statusLabel(candidate.status)}</span>
|
||
<span>r/{candidate.subreddit}</span>
|
||
<span>{candidate.sourceType === 'image' ? 'Image post' : 'Chat post'}</span>
|
||
</div>
|
||
<time dateTime={new Date(candidate.discoveredAt).toISOString()} title={formatExactTime(candidate.discoveredAt)}>
|
||
queued {formatShortRelativeTime(candidate.discoveredAt, now)}
|
||
</time>
|
||
</div>
|
||
<a className="candidate-title" href={candidate.postUrl} rel="noreferrer" target="_blank">
|
||
{candidate.title} <span aria-hidden="true">↗</span>
|
||
</a>
|
||
<span className="sr-only">(opens Reddit in a new tab)</span>
|
||
<div className="candidate-meta">
|
||
<RedditUsernameLink username={candidate.author} />
|
||
<span>{candidate.hasPreparedMessage ? 'Message prepared' : 'Not prepared yet'}</span>
|
||
{candidate.physiqueScore !== null ? <span>Physique {candidate.physiqueScore}/10</span> : null}
|
||
<span>{candidate.attemptCount} {candidate.attemptCount === 1 ? 'attempt' : 'attempts'}</span>
|
||
{waitingUntil ? <span title={formatExactTime(candidate.availableAt)}>Retry {formatWaitTime(candidate.availableAt, now)}</span> : null}
|
||
</div>
|
||
{candidate.note ? <p className="candidate-note"><strong>Latest note</strong>{candidate.note}</p> : null}
|
||
{candidate.owner ? <AccountOwnerBadge owner={candidate.owner} /> : null}
|
||
<PreparedCandidateDetails candidate={candidate} />
|
||
</article>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
export function OutreachPage() {
|
||
const query = useQuery({
|
||
queryKey: ['outreach', 'funnel'],
|
||
queryFn: outreachApi.getFunnel,
|
||
refetchInterval: 10_000,
|
||
});
|
||
|
||
// Single client-side clock for every displayed offset: retry countdowns tick
|
||
// every second while visible, everything else every 10 seconds.
|
||
const [now, setNow] = useState(() => Date.now());
|
||
const hasVisibleCountdown = (query.data?.candidates ?? []).some((candidate) => candidate.availableAt > now);
|
||
useEffect(() => {
|
||
const interval = setInterval(() => setNow(Date.now()), hasVisibleCountdown ? 1_000 : 10_000);
|
||
return () => clearInterval(interval);
|
||
}, [hasVisibleCountdown]);
|
||
|
||
if (query.isPending) return <div className="page-state" role="status"><span className="spinner" />Loading outreach funnel…</div>;
|
||
if (query.isError) {
|
||
return (
|
||
<div className="page-state error-state" role="alert">
|
||
<strong>Outreach funnel unavailable</strong>
|
||
<span>{query.error.message}</span>
|
||
<button
|
||
aria-busy={query.isFetching || undefined}
|
||
className="button secondary"
|
||
disabled={query.isFetching}
|
||
onClick={() => void query.refetch()}
|
||
type="button"
|
||
>
|
||
{query.isFetching ? 'Refreshing…' : 'Retry'}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const { data } = query;
|
||
return (
|
||
<div className="page outreach-page">
|
||
<header className="page-header">
|
||
<div><span className="eyebrow">Live candidate queue</span><h1>Outreach funnel</h1><p>Follow every potential post from discovery to delivery, and see exactly where the remaining queue is waiting.</p></div>
|
||
<button
|
||
aria-busy={query.isFetching || undefined}
|
||
className="button secondary"
|
||
disabled={query.isFetching}
|
||
onClick={() => void query.refetch()}
|
||
type="button"
|
||
>
|
||
{query.isFetching ? 'Refreshing…' : 'Refresh'}
|
||
</button>
|
||
</header>
|
||
|
||
<section aria-labelledby="active-funnel-heading" className="outreach-runway">
|
||
<div className="runway-heading">
|
||
<div><span className="eyebrow">Current burn-down</span><h2 id="active-funnel-heading">{data.activeCount} active {data.activeCount === 1 ? 'post' : 'posts'}</h2></div>
|
||
<div className="runway-outcomes"><span><strong>{data.outcomes.sent}</strong> sent</span><span><strong>{data.outcomes.rejected}</strong> screened out</span></div>
|
||
</div>
|
||
<ol className="outreach-stages">
|
||
{STAGES.map((stage) => (
|
||
<li className={`stage-${stage.status}`} key={stage.status}>
|
||
<span className="stage-marker" aria-hidden="true" />
|
||
<div><span>{stage.label}</span><small>{stage.description}</small></div>
|
||
<strong>{data.stages[stage.status]}</strong>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
<p className="runway-caption">Retrying posts loop back to Ready when their cooldown expires. Sent and screened-out totals cover the lifetime of this queue.</p>
|
||
</section>
|
||
|
||
<section aria-labelledby="active-candidates-heading" className="candidate-section">
|
||
<div className="panel-heading">
|
||
<div><span className="eyebrow">Potential posts</span><h2 id="active-candidates-heading">Active queue</h2></div>
|
||
<span className="candidate-updated">
|
||
{data.candidates.length < data.activeCount ? `Showing ${data.candidates.length} of ${data.activeCount} · ` : ''}
|
||
Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s
|
||
</span>
|
||
</div>
|
||
{data.candidates.length === 0 ? (
|
||
<div className="empty-state panel"><strong>The active queue is clear.</strong><span>New potential posts will appear here as discovery finds them.</span></div>
|
||
) : (
|
||
<ol aria-label="Active outreach candidates" className="outreach-candidates">
|
||
{data.candidates.map((candidate) => <CandidateCard candidate={candidate} key={candidate.postId} now={now} />)}
|
||
</ol>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|