feat: Enhanced Outreach UI - Top 5 default + specific role generation

- market_intel_orchestrator.py: Updated generate_outreach_campaign to identify all relevant roles, generate top 5, and return remaining as suggestions. Added specific_role mode.

- types.ts: Added OutreachResponse interface.

- geminiService.ts: Updated to handle new response structure and specificRole parameter.

- StepOutreach.tsx: Added sidebar section for Suggested Roles with on-demand generation buttons.
This commit is contained in:
2025-12-29 13:59:20 +00:00
parent 4e05bac827
commit 4b79f1aee2
4 changed files with 211 additions and 125 deletions

View File

@@ -1,8 +1,7 @@
import React, { useState } from 'react';
import { AnalysisResult, EmailDraft, Language } from '../types';
import { AnalysisResult, EmailDraft, Language, OutreachResponse } from '../types';
import { generateOutreachCampaign, translateEmailDrafts } from '../services/geminiService';
import { Upload, FileText, Sparkles, Copy, Check, Loader2, ArrowRight, CheckCircle2, Download, Languages } from 'lucide-react';
import { Upload, FileText, Sparkles, Copy, Check, Loader2, ArrowRight, CheckCircle2, Download, Languages, Plus, UserPlus } from 'lucide-react';
interface StepOutreachProps {
company: AnalysisResult;
@@ -15,9 +14,14 @@ interface StepOutreachProps {
export const StepOutreach: React.FC<StepOutreachProps> = ({ company, language, referenceUrl, onBack, knowledgeBase }) => {
const [fileContent, setFileContent] = useState<string>(knowledgeBase || '');
const [fileName, setFileName] = useState<string>(knowledgeBase ? 'Knowledge Base from Strategy Step' : '');
const [isProcessing, setIsProcessing] = useState(false);
const [isTranslating, setIsTranslating] = useState(false);
const [isGeneratingSpecific, setIsGeneratingSpecific] = useState<string | null>(null); // Track which specific role is loading
const [emails, setEmails] = useState<EmailDraft[]>([]);
const [availableRoles, setAvailableRoles] = useState<string[]>([]); // Suggested roles not yet generated
const [activeTab, setActiveTab] = useState(0);
const [copied, setCopied] = useState(false);
@@ -45,8 +49,10 @@ export const StepOutreach: React.FC<StepOutreachProps> = ({ company, language, r
if (!fileContent) return;
setIsProcessing(true);
try {
const drafts = await generateOutreachCampaign(company, fileContent, language, referenceUrl);
setEmails(drafts);
// Initial generation (Top 5 + Suggestions)
const response: OutreachResponse = await generateOutreachCampaign(company, fileContent, language, referenceUrl);
setEmails(response.campaigns);
setAvailableRoles(response.available_roles || []);
} catch (e) {
console.error(e);
alert('Failed to generate campaign. Please try again.');
@@ -55,6 +61,28 @@ export const StepOutreach: React.FC<StepOutreachProps> = ({ company, language, r
}
};
const handleGenerateSpecific = async (role: string) => {
if (!fileContent) return;
setIsGeneratingSpecific(role);
try {
// Generate single specific campaign
const response: OutreachResponse = await generateOutreachCampaign(company, fileContent, language, referenceUrl, role);
if (response.campaigns && response.campaigns.length > 0) {
// Add new campaign to list
setEmails(prev => [...prev, response.campaigns[0]]);
// Remove from available roles
setAvailableRoles(prev => prev.filter(r => r !== role));
// Switch view to new campaign
setActiveTab(emails.length);
}
} catch (e) {
console.error("Failed to generate specific role", e);
alert(`Failed to generate campaign for ${role}`);
} finally {
setIsGeneratingSpecific(null);
}
};
const handleTranslate = async (targetLang: Language) => {
if (emails.length === 0) return;
setIsTranslating(true);
@@ -103,7 +131,7 @@ export const StepOutreach: React.FC<StepOutreachProps> = ({ company, language, r
// Helper to render text with bold syntax **text**
const renderBoldText = (text: string) => {
const parts = text.split(/(\*\*.*?\*\*)/g);
const parts = text.split(/(\**.*?\**)/g);
return parts.map((part, index) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={index} className="font-bold text-slate-900">{part.slice(2, -2)}</strong>;
@@ -167,33 +195,68 @@ export const StepOutreach: React.FC<StepOutreachProps> = ({ company, language, r
<div className="bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden flex flex-col md:flex-row min-h-[600px]">
{/* Sidebar Tabs */}
<div className="w-full md:w-72 bg-slate-50 border-r border-slate-200 p-2 flex flex-col gap-2 overflow-y-auto max-h-[600px]">
{emails.map((email, idx) => (
<button
key={idx}
onClick={() => setActiveTab(idx)}
className={`text-left p-3 rounded-lg text-sm font-medium transition-all group ${
activeTab === idx
? 'bg-white shadow-sm text-indigo-700 border border-indigo-100 ring-1 ring-indigo-500/20'
: 'text-slate-600 hover:bg-slate-200/50'
}`}
>
<div className="flex items-center justify-between">
<span className="truncate">{email.persona}</span>
<span className="text-[10px] bg-slate-200 text-slate-500 px-1.5 py-0.5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">#{idx+1}</span>
<div className="w-full md:w-80 bg-slate-50 border-r border-slate-200 flex flex-col">
<div className="p-4 border-b border-slate-200">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Generated Campaigns</h3>
</div>
{/* Generated List */}
<div className="flex-1 overflow-y-auto p-2 flex flex-col gap-2">
{emails.map((email, idx) => (
<button
key={idx}
onClick={() => setActiveTab(idx)}
className={`text-left p-3 rounded-lg text-sm font-medium transition-all group ${activeTab === idx
? 'bg-white shadow-sm text-indigo-700 border border-indigo-100 ring-1 ring-indigo-500/20'
: 'text-slate-600 hover:bg-slate-200/50'
}`}
>
<div className="flex items-center justify-between">
<span className="truncate font-semibold">{email.persona}</span>
<span className="text-[10px] bg-slate-200 text-slate-500 px-1.5 py-0.5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">#{idx+1}</span>
</div>
<div className="text-xs text-slate-400 mt-1 truncate font-normal opacity-80">{email.subject}</div>
</button>
))}
</div>
{/* Suggestions List */}
{availableRoles.length > 0 && (
<div className="border-t border-slate-200 bg-slate-100/50 flex flex-col max-h-[40%]">
<div className="p-3 border-b border-slate-200 flex items-center gap-2">
<Sparkles size={14} className="text-indigo-500" />
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider">Other Relevant Roles</h3>
</div>
<div className="overflow-y-auto p-2 gap-2 flex flex-col">
{availableRoles.map((role, i) => (
<div key={i} className="flex items-center justify-between p-2 bg-white border border-slate-200 rounded-lg shadow-sm">
<span className="text-xs font-medium text-slate-700 truncate max-w-[140px]" title={role}>{role}</span>
<button
onClick={() => handleGenerateSpecific(role)}
disabled={!!isGeneratingSpecific}
className="bg-indigo-50 hover:bg-indigo-100 text-indigo-700 p-1.5 rounded-md transition-colors disabled:opacity-50"
title="Generate Campaign"
>
{isGeneratingSpecific === role ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Plus size={14} strokeWidth={3} />
)}
</button>
</div>
))}
</div>
</div>
<div className="text-xs text-slate-400 mt-1 truncate font-normal opacity-80">{email.subject}</div>
</button>
))}
)}
</div>
{/* Content Area */}
<div className="flex-1 p-8 flex flex-col overflow-y-auto">
<div className="flex-1 p-8 flex flex-col overflow-y-auto bg-slate-50/30">
{activeEmail ? (
<>
<div className="mb-6">
<label className="block text-xs font-bold text-slate-400 uppercase tracking-wider mb-1">Subject Line</label>
<div className="text-lg font-semibold text-slate-800 bg-slate-50 p-4 rounded-lg border border-slate-200 select-text">
<div className="text-lg font-semibold text-slate-800 bg-white p-4 rounded-lg border border-slate-200 select-text shadow-sm">
{activeEmail.subject}
</div>
</div>
@@ -209,14 +272,14 @@ export const StepOutreach: React.FC<StepOutreachProps> = ({ company, language, r
{copied ? "Copied" : "Copy Content"}
</button>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-6 text-slate-700 leading-relaxed whitespace-pre-wrap font-serif text-base select-text">
<div className="bg-white border border-slate-200 rounded-xl p-8 text-slate-700 leading-relaxed whitespace-pre-wrap font-serif text-base select-text shadow-sm">
{renderBoldText(activeEmail.body)}
</div>
</div>
{/* Checklist Section */}
{activeEmail.keyPoints && activeEmail.keyPoints.length > 0 && (
<div className="border-t border-slate-100 pt-6">
<div className="border-t border-slate-200 pt-6">
<h3 className="text-sm font-bold text-slate-900 uppercase tracking-wide mb-4 flex items-center gap-2">
<CheckCircle2 size={16} className="text-emerald-500" />
Persona & KPI Analysis
@@ -247,7 +310,7 @@ export const StepOutreach: React.FC<StepOutreachProps> = ({ company, language, r
<div className="max-w-2xl mx-auto mt-16 px-4">
<div className="text-center mb-10">
<div className="inline-block p-4 bg-indigo-100 rounded-full mb-4">
<Sparkles className="text-indigo-600" size={32} />
<UserPlus className="text-indigo-600" size={32} />
</div>
<h2 className="text-3xl font-bold text-slate-900 mb-4">Create Outreach Campaign</h2>
<p className="text-lg text-slate-600">
@@ -261,7 +324,7 @@ export const StepOutreach: React.FC<StepOutreachProps> = ({ company, language, r
<div className="border-2 border-dashed border-slate-300 rounded-xl p-12 text-center hover:border-indigo-400 hover:bg-slate-50 transition-all">
<input
type="file"
accept=".md,.txt,.markdown"
accept=".md,.txt,.markdown"
onChange={handleFileUpload}
id="file-upload"
className="hidden"
@@ -287,7 +350,7 @@ export const StepOutreach: React.FC<StepOutreachProps> = ({ company, language, r
<p className="text-sm text-emerald-700">{fileName}</p>
</div>
<button
onClick={() => { setFileContent(''); setFileName(''); }}
onClick={() => { setFileContent(''); setFileName(''); }}
className="text-emerald-600 hover:text-emerald-800 text-sm font-medium"
>
Change
@@ -301,7 +364,7 @@ export const StepOutreach: React.FC<StepOutreachProps> = ({ company, language, r
>
{isProcessing ? (
<>
<Loader2 className="animate-spin" /> Synthesizing Insights...
<Loader2 className="animate-spin" /> Identifying Roles & Drafting Campaigns...
</>
) : (
<>
@@ -319,4 +382,4 @@ export const StepOutreach: React.FC<StepOutreachProps> = ({ company, language, r
</div>
</div>
);
};
};

View File

@@ -1,7 +1,4 @@
import { LeadStatus, AnalysisResult, Competitor, Language, Tier, EmailDraft, SearchStrategy, SearchSignal } from "../types";
// const apiKey = process.env.API_KEY; // Nicht mehr direkt im Frontend verwendet
// const ai = new GoogleGenAI({ apiKey: apiKey || '' }); // Nicht mehr direkt im Frontend verwendet
import { LeadStatus, AnalysisResult, Competitor, Language, Tier, EmailDraft, SearchStrategy, SearchSignal, OutreachResponse } from "../types";
// URL Konfiguration:
// Im Production-Build (Docker/Nginx) nutzen wir den relativen Pfad '/api', da Nginx als Reverse Proxy fungiert.
@@ -19,7 +16,7 @@ const extractJson = (text: string): any => {
if (jsonMatch && jsonMatch[1]) {
try { return JSON.parse(jsonMatch[1]); } catch (e2) {}
}
const arrayMatch = text.match(/\[\s*[\s\S]*\s*\]/);
const arrayMatch = text.match(/\\[\s\S]*\\s*\]/);
if (arrayMatch) {
try { return JSON.parse(arrayMatch[0]); } catch (e3) {}
}
@@ -129,7 +126,7 @@ export const analyzeCompanyWithStrategy = async (
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
body: JSON.stringify({
companyName,
strategy,
targetMarket: language === 'de' ? 'Germany' : 'USA' // Einfache Ableitung, kann verfeinert werden
@@ -175,9 +172,10 @@ export const generateOutreachCampaign = async (
companyData: AnalysisResult,
knowledgeBase: string,
language: Language,
referenceUrl: string
): Promise<EmailDraft[]> => {
console.log(`Frontend: Starte Outreach-Generierung für ${companyData.companyName}...`);
referenceUrl: string,
specificRole?: string
): Promise<OutreachResponse> => {
console.log(`Frontend: Starte Outreach-Generierung für ${companyData.companyName} (Role: ${specificRole || "All"})...`);
try {
const response = await fetch(`${API_BASE_URL}/generate-outreach`, {
@@ -185,10 +183,11 @@ export const generateOutreachCampaign = async (
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
body: JSON.stringify({
companyData,
knowledgeBase,
referenceUrl
referenceUrl,
specific_role: specificRole
}),
});
@@ -197,45 +196,57 @@ export const generateOutreachCampaign = async (
throw new Error(`Backend-Fehler: ${errorData.error || response.statusText}`);
}
const result = await response.json();
console.log(`Frontend: Outreach-Generierung für ${companyData.companyName} erfolgreich.`);
const result = await response.json(); // Expected: { campaigns: [...], available_roles: [...] } or single object
console.log(`Frontend: Outreach-Generierung erfolgreich.`, result);
// Transform new backend structure to match frontend EmailDraft interface
if (Array.isArray(result)) {
return result.map((item: any) => {
// Construct a body that shows the sequence
let fullBody = "";
const firstSubject = item.emails?.[0]?.subject || "No Subject";
if (item.emails && Array.isArray(item.emails)) {
item.emails.forEach((mail: any, idx: number) => {
fullBody += `### Email ${idx + 1}: ${mail.subject}\n\n`;
fullBody += `${mail.body}\n\n`;
if (idx < item.emails.length - 1) fullBody += `\n---\n\n`;
});
} else {
// Fallback for flat structure or error
fullBody = item.body || "No content generated.";
}
let rawCampaigns: any[] = [];
let availableRoles: string[] = [];
return {
persona: item.target_role || "Unknown Role",
subject: firstSubject,
body: fullBody,
keyPoints: item.rationale ? [item.rationale] : []
};
});
} else if (result.campaign && Array.isArray(result.campaign)) {
return result.campaign as EmailDraft[];
// Handle Mode A (Batch) vs Mode B (Single) response structures
if (result.campaigns && Array.isArray(result.campaigns)) {
rawCampaigns = result.campaigns;
availableRoles = result.available_roles || [];
} else if (result.target_role && result.emails) {
// Single campaign response (Mode B)
rawCampaigns = [result];
} else if (Array.isArray(result)) {
// Legacy fallback
rawCampaigns = result;
}
return [];
const processedCampaigns: EmailDraft[] = rawCampaigns.map((item: any) => {
let fullBody = "";
const firstSubject = item.emails?.[0]?.subject || "No Subject";
if (item.emails && Array.isArray(item.emails)) {
item.emails.forEach((mail: any, idx: number) => {
fullBody += `### Email ${idx + 1}: ${mail.subject}\n\n`;
fullBody += `${mail.body}\n\n`;
if (idx < item.emails.length - 1) fullBody += `\n---\n\n`;
});
} else {
fullBody = item.body || "No content generated.";
}
return {
persona: item.target_role || "Unknown Role",
subject: firstSubject,
body: fullBody,
keyPoints: item.rationale ? [item.rationale] : []
};
});
return {
campaigns: processedCampaigns,
available_roles: availableRoles
};
} catch (error) {
console.error(`Frontend: Outreach-Generierung fehlgeschlagen für ${companyData.companyName}`, error);
throw error;
}
};
export const translateEmailDrafts = async (drafts: EmailDraft[], targetLanguage: Language): Promise<EmailDraft[]> => {
// Dieser Teil muss noch im Python-Backend oder direkt im Frontend implementiert werden
console.warn("translateEmailDrafts ist noch nicht im Python-Backend implementiert.");

View File

@@ -89,3 +89,8 @@ export interface EmailDraft {
body: string;
keyPoints: string[];
}
export interface OutreachResponse {
campaigns: EmailDraft[];
available_roles: string[];
}