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(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 (
{preparation.sourceImageUrl ? ( ) : null}
Attractiveness{preparation.attractivenessScore === null ? 'Not scored' : `${preparation.attractivenessScore}/100`}Used in the opener prompt
Physique{preparation.physiqueScore === null ? 'Not scorable' : `${preparation.physiqueScore}/10`}{targeting?.enabled ? `Current target ${targeting.minScore}–${targeting.maxScore}` : 'Current filter off'}
Detected gender{preparation.detectedGender}Image analysis
Strategy {strategy} {preparation.selectionReason ? Selection {preparation.selectionReason} : null} Tone {preparation.toneId} Model {preparation.modelId}
Prepared message
{preparation.message}
Show image analysis
{preparation.imageAnalysis}
); } function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now: number }) { const waitingUntil = candidate.availableAt > now; const isKnownStatus = KNOWN_STATUSES.has(candidate.status); return (
  • {statusLabel(candidate.status)} r/{candidate.subreddit} {candidate.sourceType === 'image' ? 'Image post' : 'Chat post'}
    {candidate.title} (opens Reddit in a new tab)
    {candidate.hasPreparedMessage ? 'Message prepared' : 'Not prepared yet'} {candidate.physiqueScore !== null ? Physique {candidate.physiqueScore}/10 : null} {candidate.attemptCount} {candidate.attemptCount === 1 ? 'attempt' : 'attempts'} {waitingUntil ? Retry {formatWaitTime(candidate.availableAt, now)} : null}
    {candidate.note ?

    Latest note{candidate.note}

    : null} {candidate.owner ? : null}
  • ); } 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
    Loading outreach funnel…
    ; if (query.isError) { return (
    Outreach funnel unavailable {query.error.message}
    ); } const { data } = query; return (
    Live candidate queue

    Outreach funnel

    Follow every potential post from discovery to delivery, and see exactly where the remaining queue is waiting.

    Current burn-down

    {data.activeCount} active {data.activeCount === 1 ? 'post' : 'posts'}

    {data.outcomes.sent} sent{data.outcomes.rejected} screened out
      {STAGES.map((stage) => (
    1. ))}

    Retrying posts loop back to Ready when their cooldown expires. Sent and screened-out totals cover the lifetime of this queue.

    Potential posts

    Active queue

    {data.candidates.length < data.activeCount ? `Showing ${data.candidates.length} of ${data.activeCount} · ` : ''} Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s
    {data.candidates.length === 0 ? (
    The active queue is clear.New potential posts will appear here as discovery finds them.
    ) : (
      {data.candidates.map((candidate) => )}
    )}
    ); }