diff --git a/apps/dashboard/src/features/outreach/outreach-page.tsx b/apps/dashboard/src/features/outreach/outreach-page.tsx
index 7389135..1f36c57 100644
--- a/apps/dashboard/src/features/outreach/outreach-page.tsx
+++ b/apps/dashboard/src/features/outreach/outreach-page.tsx
@@ -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 (
+
+
+ {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 ? `Target ${targeting.minScore}–${targeting.maxScore}` : 'Range 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);
@@ -61,11 +108,13 @@ function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now:
{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}
+
);
diff --git a/apps/dashboard/src/features/outreach/outreach.css b/apps/dashboard/src/features/outreach/outreach.css
index 988046a..dac953c 100644
--- a/apps/dashboard/src/features/outreach/outreach.css
+++ b/apps/dashboard/src/features/outreach/outreach.css
@@ -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; }
diff --git a/apps/dashboard/src/features/settings/settings-model.ts b/apps/dashboard/src/features/settings/settings-model.ts
index 37327f7..67136e1 100644
--- a/apps/dashboard/src/features/settings/settings-model.ts
+++ b/apps/dashboard/src/features/settings/settings-model.ts
@@ -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);
}
diff --git a/apps/dashboard/src/features/settings/settings-sections.tsx b/apps/dashboard/src/features/settings/settings-sections.tsx
index 917d39d..a8fadd5 100644
--- a/apps/dashboard/src/features/settings/settings-sections.tsx
+++ b/apps/dashboard/src/features/settings/settings-sections.tsx
@@ -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 (