feat: expand outreach targeting and messaging
This commit is contained in:
1 parent
7defff422d
commit
d0a8bd0e2f
40 files changed
+890
-82
No files matched your search
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -37,6 +38,52 @@ function formatWaitTime(timestamp: number, now: number): string {
|
||||
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 ? `Target ${targeting.minScore}–${targeting.maxScore}` : 'Range 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);
|
||||
@@ -61,11 +108,13 @@ function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now:
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -43,6 +43,31 @@
|
||||
.candidate-note { display: grid; gap: .18rem; margin: 0; padding: .55rem .7rem; color: #d9c99e; background: #251f13; border-left: 2px solid var(--warning); border-radius: .35rem; font-size: .78rem; overflow-wrap: anywhere; }
|
||||
.candidate-note strong { color: var(--warning-text); font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.outreach-candidate .account-owner-badge { margin-top: -.1rem; }
|
||||
.candidate-preparation { display: grid; gap: .8rem; padding-top: .8rem; border-top: 1px solid var(--border); }
|
||||
.candidate-preparation-grid { display: grid; gap: .8rem; }
|
||||
.candidate-preparation .origin-post-card { grid-template-columns: 1fr; align-items: stretch; gap: .55rem; margin: 0; padding: .6rem; background: #111915; border: 1px solid var(--border); border-radius: .6rem; }
|
||||
.candidate-preparation .origin-post-image { width: 100%; height: 12rem; }
|
||||
.candidate-preparation-copy { display: grid; gap: .7rem; min-width: 0; }
|
||||
.candidate-score-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .55rem; }
|
||||
.candidate-score-grid > div { display: grid; gap: .08rem; min-width: 0; padding: .6rem .7rem; background: #111c17; border: 1px solid var(--border); border-radius: .55rem; }
|
||||
.candidate-score-grid span, .candidate-prepared-message > span { color: var(--muted); font-size: .66rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.candidate-score-grid strong { color: #eef6f1; font-size: 1rem; text-transform: capitalize; overflow-wrap: anywhere; }
|
||||
.candidate-score-grid small { color: var(--muted); font-size: .67rem; }
|
||||
.candidate-preparation-metadata { display: flex; flex-wrap: wrap; gap: .4rem .8rem; color: var(--muted); font-size: .72rem; overflow-wrap: anywhere; }
|
||||
.candidate-preparation-metadata strong { color: #dce8e2; }
|
||||
.candidate-prepared-message { display: grid; gap: .35rem; }
|
||||
.candidate-prepared-message blockquote { margin: 0; padding: .7rem .8rem; color: #eef6f1; background: rgba(117, 214, 157, .07); border-left: 3px solid var(--accent); border-radius: .35rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.candidate-analysis { padding: .55rem .7rem; background: #111915; border: 1px solid var(--border); border-radius: .5rem; }
|
||||
.candidate-analysis summary { width: fit-content; color: var(--accent); cursor: pointer; font-size: .75rem; font-weight: 750; }
|
||||
.candidate-analysis pre { margin: .65rem 0 0; color: #dce8e2; font: inherit; font-size: .76rem; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.candidate-preparation-grid:has(.origin-post-card) { grid-template-columns: minmax(15rem, .8fr) minmax(0, 1.2fr); align-items: stretch; }
|
||||
}
|
||||
|
||||
@media (max-width: 34rem) {
|
||||
.candidate-score-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (min-width: 64rem) {
|
||||
.outreach-stages { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; }
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface SettingsDraft {
|
||||
prioritizeRecentPosts: boolean;
|
||||
recentWindowMinutes: string;
|
||||
maxReadyCandidates: string;
|
||||
physiqueTargetingEnabled: boolean;
|
||||
physiqueMinScore: string;
|
||||
physiqueMaxScore: string;
|
||||
subreddits: SubredditDraft[];
|
||||
advancedConfigJson: string;
|
||||
}
|
||||
@@ -57,12 +60,22 @@ const ValidatedSettingsSchema = z.strictObject({
|
||||
prioritizeRecentPosts: z.boolean(),
|
||||
recentWindowMinutes: z.number().finite().positive('Recent window must be greater than 0 minutes.'),
|
||||
maxReadyCandidates: z.number().int().min(1, 'Ready-post limit must be at least 1.').max(500, 'Ready-post limit cannot exceed 500.'),
|
||||
physiqueTargetingEnabled: z.boolean(),
|
||||
physiqueMinScore: z.number().int().min(1, 'Minimum physique score must be between 1 and 10.').max(10, 'Minimum physique score must be between 1 and 10.'),
|
||||
physiqueMaxScore: z.number().int().min(1, 'Maximum physique score must be between 1 and 10.').max(10, 'Maximum physique score must be between 1 and 10.'),
|
||||
subreddits: z.array(EditableSubredditSchema).min(1, 'At least one subreddit is required.'),
|
||||
}).superRefine((value, context) => {
|
||||
const names = value.subreddits.map((subreddit) => subreddit.name.trim().toLowerCase());
|
||||
if (new Set(names).size !== names.length) {
|
||||
context.addIssue({ code: 'custom', path: ['subreddits'], message: 'Subreddit names must be unique.' });
|
||||
}
|
||||
if (value.physiqueMinScore > value.physiqueMaxScore) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['physiqueMinScore'],
|
||||
message: 'Minimum physique score cannot exceed maximum physique score.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let nextRowId = 0;
|
||||
@@ -81,6 +94,9 @@ export function createSettingsDraft(config: ConfigResponse): SettingsDraft {
|
||||
prioritizeRecentPosts: config.reddit.prioritizeRecentPosts,
|
||||
recentWindowMinutes: String(config.reddit.recentWindowMinutes),
|
||||
maxReadyCandidates: String(config.reddit.maxReadyCandidates),
|
||||
physiqueTargetingEnabled: config.reddit.physiqueTargeting.enabled,
|
||||
physiqueMinScore: String(config.reddit.physiqueTargeting.minScore),
|
||||
physiqueMaxScore: String(config.reddit.physiqueTargeting.maxScore),
|
||||
subreddits: config.subreddits.map((subreddit) => ({
|
||||
rowId: createSubredditRowId(),
|
||||
name: subreddit.name,
|
||||
@@ -104,6 +120,8 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
|
||||
if (draft.jitterSeconds.trim() === '') requiredErrors.jitterSeconds = 'Jitter is required.';
|
||||
if (draft.recentWindowMinutes.trim() === '') requiredErrors.recentWindowMinutes = 'Recent window is required.';
|
||||
if (draft.maxReadyCandidates.trim() === '') requiredErrors.maxReadyCandidates = 'Ready-post limit is required.';
|
||||
if (draft.physiqueMinScore.trim() === '') requiredErrors.physiqueMinScore = 'Minimum physique score is required.';
|
||||
if (draft.physiqueMaxScore.trim() === '') requiredErrors.physiqueMaxScore = 'Maximum physique score is required.';
|
||||
draft.subreddits.forEach((subreddit, index) => {
|
||||
if (subreddit.name.trim() === '') requiredErrors[`subreddits.${index}.name`] = 'Subreddit name is required.';
|
||||
if (subreddit.weight.trim() !== '' && Number(subreddit.weight) <= 0) {
|
||||
@@ -140,6 +158,9 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
|
||||
prioritizeRecentPosts: draft.prioritizeRecentPosts,
|
||||
recentWindowMinutes: Number(draft.recentWindowMinutes),
|
||||
maxReadyCandidates: Number(draft.maxReadyCandidates),
|
||||
physiqueTargetingEnabled: draft.physiqueTargetingEnabled,
|
||||
physiqueMinScore: Number(draft.physiqueMinScore),
|
||||
physiqueMaxScore: Number(draft.physiqueMaxScore),
|
||||
subreddits: draft.subreddits.map((subreddit) => ({
|
||||
name: subreddit.name.trim(),
|
||||
enabled: subreddit.enabled,
|
||||
@@ -185,6 +206,11 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
|
||||
prioritizeRecentPosts: parsed.data.prioritizeRecentPosts,
|
||||
recentWindowMinutes: parsed.data.recentWindowMinutes,
|
||||
maxReadyCandidates: parsed.data.maxReadyCandidates,
|
||||
physiqueTargeting: {
|
||||
enabled: parsed.data.physiqueTargetingEnabled,
|
||||
minScore: parsed.data.physiqueMinScore,
|
||||
maxScore: parsed.data.physiqueMaxScore,
|
||||
},
|
||||
},
|
||||
subreddits: parsed.data.subreddits,
|
||||
...(advanced ? { advanced } : {}),
|
||||
@@ -217,7 +243,7 @@ export function isDraftDirty(draft: SettingsDraft, persisted: SettingsDraft): bo
|
||||
*/
|
||||
export function changedDraftFields(previous: SettingsDraft, next: SettingsDraft): string[] {
|
||||
const changed: string[] = [];
|
||||
const topLevelKeys = ['minDelayMinutes', 'maxMessagesPerHour', 'jitterSeconds', 'dryRun', 'prioritizeRecentPosts', 'recentWindowMinutes', 'maxReadyCandidates', 'advancedConfigJson'] as const;
|
||||
const topLevelKeys = ['minDelayMinutes', 'maxMessagesPerHour', 'jitterSeconds', 'dryRun', 'prioritizeRecentPosts', 'recentWindowMinutes', 'maxReadyCandidates', 'physiqueTargetingEnabled', 'physiqueMinScore', 'physiqueMaxScore', 'advancedConfigJson'] as const;
|
||||
for (const key of topLevelKeys) {
|
||||
if (previous[key] !== next[key]) changed.push(key);
|
||||
}
|
||||
|
||||
@@ -12,11 +12,12 @@ interface NumberFieldProps {
|
||||
label: string;
|
||||
name: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
}
|
||||
|
||||
function NumberField({ error, label, name, min, value, onChange }: NumberFieldProps) {
|
||||
function NumberField({ error, label, name, min, max, value, onChange }: NumberFieldProps) {
|
||||
const errorId = `${name}-error`;
|
||||
return (
|
||||
<label className="settings-field">
|
||||
@@ -26,6 +27,7 @@ function NumberField({ error, label, name, min, value, onChange }: NumberFieldPr
|
||||
aria-invalid={error ? 'true' : undefined}
|
||||
aria-label={label}
|
||||
min={min}
|
||||
max={max}
|
||||
name={name}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
step="1"
|
||||
@@ -66,6 +68,41 @@ export function MonitoringSection({ draft, errors, onChange }: SectionProps) {
|
||||
onChange={(maxReadyCandidates) => onChange({ ...draft, maxReadyCandidates })}
|
||||
value={draft.maxReadyCandidates}
|
||||
/>
|
||||
<div className="advanced-settings">
|
||||
<div>
|
||||
<h3>Physique targeting</h3>
|
||||
<p>Apply a subjective 1–10 visible-physique range to image posts. Images without enough visible body evidence are screened out while this is enabled.</p>
|
||||
</div>
|
||||
<label className="toggle-field">
|
||||
<input
|
||||
aria-label="Enable physique targeting"
|
||||
checked={draft.physiqueTargetingEnabled}
|
||||
onChange={(event) => onChange({ ...draft, physiqueTargetingEnabled: event.target.checked })}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span><strong>Enable physique targeting</strong><small>Chat posts are unaffected.</small></span>
|
||||
</label>
|
||||
<div className="settings-grid">
|
||||
<NumberField
|
||||
error={errors.physiqueMinScore}
|
||||
label="Minimum physique score"
|
||||
min={1}
|
||||
max={10}
|
||||
name="minimum-physique-score"
|
||||
onChange={(physiqueMinScore) => onChange({ ...draft, physiqueMinScore })}
|
||||
value={draft.physiqueMinScore}
|
||||
/>
|
||||
<NumberField
|
||||
error={errors.physiqueMaxScore}
|
||||
label="Maximum physique score"
|
||||
min={1}
|
||||
max={10}
|
||||
name="maximum-physique-score"
|
||||
onChange={(physiqueMaxScore) => onChange({ ...draft, physiqueMaxScore })}
|
||||
value={draft.physiqueMaxScore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user