Add outreach candidate editing controls
This commit is contained in:
1 parent
c75e71f26e
commit
79c508be2b
19 files changed
+434
-12
No files matched your search
@@ -0,0 +1,27 @@
|
||||
import type { OutreachCandidate } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { Modal } from '../../components/modal.js';
|
||||
|
||||
interface DeleteCandidateDialogProps {
|
||||
candidate: OutreachCandidate;
|
||||
errorMessage?: string | null;
|
||||
isPending: boolean;
|
||||
onCancel(): void;
|
||||
onConfirm(): void;
|
||||
}
|
||||
|
||||
export function DeleteCandidateDialog({ candidate, errorMessage = null, isPending, onCancel, onConfirm }: DeleteCandidateDialogProps) {
|
||||
return (
|
||||
<Modal className="dialog-card" isDismissLocked={isPending} labelledBy="delete-candidate-title" onClose={onCancel}>
|
||||
<span className="eyebrow">Confirm queue action</span>
|
||||
<h2 id="delete-candidate-title">Remove u/{candidate.author} from the funnel?</h2>
|
||||
<p>This candidate will be cancelled and retained in the audit history, so the same item will not be rediscovered.</p>
|
||||
{errorMessage ? <p className="dialog-error" role="alert">{errorMessage}</p> : null}
|
||||
<div className="dialog-actions">
|
||||
<button autoFocus className="button secondary" disabled={isPending} onClick={onCancel} type="button">Cancel</button>
|
||||
<button className="button danger" disabled={isPending} onClick={onConfirm} type="button">
|
||||
{isPending ? 'Removing…' : 'Remove candidate'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react';
|
||||
import type { OutreachCandidate } from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { TextAreaField } from '../../components/form-fields.js';
|
||||
import { Modal } from '../../components/modal.js';
|
||||
|
||||
interface EditMessageDialogProps {
|
||||
candidate: OutreachCandidate;
|
||||
errorMessage?: string | null;
|
||||
isPending: boolean;
|
||||
onCancel(): void;
|
||||
onSave(message: string): void;
|
||||
}
|
||||
|
||||
export function EditMessageDialog({ candidate, errorMessage = null, isPending, onCancel, onSave }: EditMessageDialogProps) {
|
||||
const [message, setMessage] = useState(candidate.preparation?.message ?? '');
|
||||
const trimmed = message.trim();
|
||||
const validationError = trimmed.length === 0
|
||||
? 'Message cannot be empty.'
|
||||
: trimmed.length > 2000 ? 'Message cannot exceed 2000 characters.' : undefined;
|
||||
return (
|
||||
<Modal className="dialog-card outreach-edit-dialog" isDismissLocked={isPending} labelledBy="edit-outreach-title" onClose={onCancel}>
|
||||
<span className="eyebrow">Prepared outreach</span>
|
||||
<h2 id="edit-outreach-title">Edit message for u/{candidate.author}</h2>
|
||||
<p>The saved text will replace the generated opener and be marked as operator-edited for attribution.</p>
|
||||
<TextAreaField
|
||||
error={validationError}
|
||||
label="Message"
|
||||
name="outreach-message"
|
||||
onChange={setMessage}
|
||||
rows={7}
|
||||
value={message}
|
||||
/>
|
||||
<small className="outreach-character-count">{message.length}/2000</small>
|
||||
{errorMessage ? <p className="dialog-error" role="alert">{errorMessage}</p> : null}
|
||||
<div className="dialog-actions">
|
||||
<button className="button secondary" disabled={isPending} onClick={onCancel} type="button">Cancel</button>
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={isPending || validationError !== undefined || trimmed === candidate.preparation?.message}
|
||||
onClick={() => onSave(trimmed)}
|
||||
type="button"
|
||||
>
|
||||
{isPending ? 'Saving…' : 'Save message'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
OutreachClearResponseSchema,
|
||||
OutreachCandidateDeleteResponseSchema,
|
||||
OutreachFunnelResponseSchema,
|
||||
OutreachMessageUpdateResponseSchema,
|
||||
} from '../../../../../packages/api-contracts/src/index.js';
|
||||
import { createDashboardRequest } from '../../api/request.js';
|
||||
|
||||
@@ -31,6 +33,30 @@ export function createOutreachApi(fetchImplementation?: typeof fetch) {
|
||||
}
|
||||
return OutreachClearResponseSchema.parse(payload);
|
||||
},
|
||||
async updateMessage(postId: string, message: string) {
|
||||
const response = await request(`/api/outreach/${encodeURIComponent(postId)}/message`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const detail = typeof payload === 'object' && payload !== null && 'error' in payload
|
||||
&& typeof payload.error === 'string' ? payload.error : `Could not edit message (${response.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
return OutreachMessageUpdateResponseSchema.parse(payload);
|
||||
},
|
||||
async deleteCandidate(postId: string) {
|
||||
const response = await request(`/api/outreach/${encodeURIComponent(postId)}`, { method: 'DELETE' });
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const detail = typeof payload === 'object' && payload !== null && 'error' in payload
|
||||
&& typeof payload.error === 'string' ? payload.error : `Could not remove candidate (${response.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
return OutreachCandidateDeleteResponseSchema.parse(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||
import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js';
|
||||
import { outreachApi } from './outreach-api.js';
|
||||
import { ClearFunnelDialog } from './clear-funnel-dialog.js';
|
||||
import { DeleteCandidateDialog } from './delete-candidate-dialog.js';
|
||||
import { EditMessageDialog } from './edit-message-dialog.js';
|
||||
import './outreach.css';
|
||||
|
||||
const STAGES: ReadonlyArray<{
|
||||
@@ -43,7 +45,9 @@ function PreparedCandidateDetails({ candidate }: { candidate: OutreachCandidate
|
||||
const preparation = candidate.preparation;
|
||||
if (!preparation) return null;
|
||||
const targeting = preparation.physiqueTargeting;
|
||||
const strategy = preparation.scripted
|
||||
const strategy = preparation.operatorEdited
|
||||
? 'Operator edited'
|
||||
: preparation.scripted
|
||||
? 'Scripted opener'
|
||||
: preparation.strategyId ?? 'Default strategy';
|
||||
|
||||
@@ -85,7 +89,17 @@ function PreparedCandidateDetails({ candidate }: { candidate: OutreachCandidate
|
||||
);
|
||||
}
|
||||
|
||||
function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now: number }) {
|
||||
function CandidateCard({
|
||||
candidate,
|
||||
now,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}: {
|
||||
candidate: OutreachCandidate;
|
||||
now: number;
|
||||
onDelete(candidate: OutreachCandidate): void;
|
||||
onEdit(candidate: OutreachCandidate): void;
|
||||
}) {
|
||||
const waitingUntil = candidate.availableAt > now;
|
||||
const isKnownStatus = KNOWN_STATUSES.has(candidate.status);
|
||||
return (
|
||||
@@ -113,6 +127,14 @@ function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now:
|
||||
<span>{candidate.attemptCount} {candidate.attemptCount === 1 ? 'attempt' : 'attempts'}</span>
|
||||
{waitingUntil ? <span title={formatExactTime(candidate.availableAt)}>Retry {formatWaitTime(candidate.availableAt, now)}</span> : null}
|
||||
</div>
|
||||
<div className="candidate-actions">
|
||||
{candidate.preparation && (candidate.status === 'ready' || candidate.status === 'retryable') ? (
|
||||
<button className="button secondary" onClick={() => onEdit(candidate)} type="button">Edit message</button>
|
||||
) : null}
|
||||
{candidate.status !== 'sending' ? (
|
||||
<button className="button danger" onClick={() => onDelete(candidate)} type="button">Remove</button>
|
||||
) : 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} />
|
||||
@@ -132,7 +154,18 @@ export function OutreachPage() {
|
||||
mutationFn: outreachApi.clearFunnel,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['outreach', 'funnel'] }),
|
||||
});
|
||||
const editMutation = useMutation({
|
||||
mutationFn: ({ postId, message }: { postId: string; message: string }) => outreachApi.updateMessage(postId, message),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['outreach', 'funnel'] }),
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (postId: string) => outreachApi.deleteCandidate(postId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['outreach', 'funnel'] }),
|
||||
});
|
||||
const [isConfirmingClear, setIsConfirmingClear] = useState(false);
|
||||
const [editingCandidate, setEditingCandidate] = useState<OutreachCandidate | null>(null);
|
||||
const [deletingCandidate, setDeletingCandidate] = useState<OutreachCandidate | null>(null);
|
||||
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||
|
||||
// Single client-side clock for every displayed offset: retry countdowns tick
|
||||
// every second while visible, everything else every 10 seconds.
|
||||
@@ -173,7 +206,7 @@ export function OutreachPage() {
|
||||
<button
|
||||
className="button danger"
|
||||
disabled={clearableCount === 0 || clearMutation.isPending}
|
||||
onClick={() => { clearMutation.reset(); setIsConfirmingClear(true); }}
|
||||
onClick={() => { clearMutation.reset(); setActionNotice(null); setIsConfirmingClear(true); }}
|
||||
type="button"
|
||||
>
|
||||
Clear funnel
|
||||
@@ -198,6 +231,7 @@ export function OutreachPage() {
|
||||
: ''}
|
||||
</div>
|
||||
) : null}
|
||||
{actionNotice ? <div className="outreach-clear-notice" role="status">{actionNotice}</div> : null}
|
||||
|
||||
<section aria-labelledby="active-funnel-heading" className="outreach-runway">
|
||||
<div className="runway-heading">
|
||||
@@ -228,7 +262,15 @@ export function OutreachPage() {
|
||||
<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} />)}
|
||||
{data.candidates.map((candidate) => (
|
||||
<CandidateCard
|
||||
candidate={candidate}
|
||||
key={candidate.postId}
|
||||
now={now}
|
||||
onDelete={(selected) => { clearMutation.reset(); deleteMutation.reset(); setActionNotice(null); setDeletingCandidate(selected); }}
|
||||
onEdit={(selected) => { clearMutation.reset(); editMutation.reset(); setActionNotice(null); setEditingCandidate(selected); }}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
@@ -242,6 +284,30 @@ export function OutreachPage() {
|
||||
sendingCount={data.stages.sending}
|
||||
/>
|
||||
) : null}
|
||||
{editingCandidate ? (
|
||||
<EditMessageDialog
|
||||
candidate={editingCandidate}
|
||||
errorMessage={editMutation.isError ? editMutation.error.message : null}
|
||||
isPending={editMutation.isPending}
|
||||
onCancel={() => { setEditingCandidate(null); editMutation.reset(); }}
|
||||
onSave={(message) => editMutation.mutate(
|
||||
{ postId: editingCandidate.postId, message },
|
||||
{ onSuccess: () => { setActionNotice(`Message for u/${editingCandidate.author} updated.`); setEditingCandidate(null); } },
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{deletingCandidate ? (
|
||||
<DeleteCandidateDialog
|
||||
candidate={deletingCandidate}
|
||||
errorMessage={deleteMutation.isError ? deleteMutation.error.message : null}
|
||||
isPending={deleteMutation.isPending}
|
||||
onCancel={() => { setDeletingCandidate(null); deleteMutation.reset(); }}
|
||||
onConfirm={() => deleteMutation.mutate(
|
||||
deletingCandidate.postId,
|
||||
{ onSuccess: () => { setActionNotice(`Removed u/${deletingCandidate.author} from the funnel.`); setDeletingCandidate(null); } },
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,10 @@
|
||||
.outreach-candidate.is-sending .candidate-status-rail span { background: #c1ef91; }
|
||||
.outreach-candidate.is-retryable .candidate-status-rail span { background: var(--warning); }
|
||||
.outreach-candidate article { display: grid; gap: .7rem; min-width: 0; padding: .9rem 1rem; }
|
||||
.candidate-actions { display: flex; flex-wrap: wrap; gap: .55rem; }
|
||||
.candidate-actions .button { min-height: 2.2rem; padding: .4rem .7rem; }
|
||||
dialog.outreach-edit-dialog { width: min(38rem, calc(100% - 2rem)); }
|
||||
.outreach-character-count { display: block; margin-top: .35rem; color: var(--muted); text-align: right; }
|
||||
.candidate-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
|
||||
.candidate-labels { display: flex; flex-wrap: wrap; align-items: center; gap: .45rem; color: var(--muted); font-size: .72rem; }
|
||||
.candidate-status { padding: .25rem .45rem; color: #dce8e2; background: #2b3832; border-radius: .35rem; font-weight: 800; }
|
||||
|
||||
Reference in new issue
Block a user