feat: Market Intel Database & UI Polish

- market_db_manager.py: Created SQLite manager for saving/loading projects.

- server.cjs: Added API routes for project management.

- geminiService.ts: Added client-side DB functions.

- StepInput.tsx: Added 'Past Runs' sidebar to load previous audits.

- App.tsx: Added auto-save functionality and full state hydration logic.

- StepOutreach.tsx: Improved UI layout by merging generated campaigns and suggestions into one list.
This commit is contained in:
2025-12-29 14:44:20 +00:00
parent 47fa37dd98
commit 03610dfd13
6 changed files with 587 additions and 267 deletions

View File

@@ -1,5 +1,4 @@
import React, { useState, useCallback, useEffect } from 'react';
import React, { useState, useCallback } from 'react';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { StepInput } from './components/StepInput'; import { StepInput } from './components/StepInput';
import { StepStrategy } from './components/StepStrategy'; import { StepStrategy } from './components/StepStrategy';
@@ -8,7 +7,7 @@ import { StepProcessing } from './components/StepProcessing';
import { StepReport } from './components/StepReport'; import { StepReport } from './components/StepReport';
import { StepOutreach } from './components/StepOutreach'; import { StepOutreach } from './components/StepOutreach';
import { AppStep, Competitor, AnalysisResult, AnalysisState, Language, SearchStrategy } from './types'; import { AppStep, Competitor, AnalysisResult, AnalysisState, Language, SearchStrategy } from './types';
import { identifyCompetitors, analyzeCompanyWithStrategy, generateSearchStrategy } from './services/geminiService'; import { identifyCompetitors, analyzeCompanyWithStrategy, generateSearchStrategy, saveProject } from './services/geminiService';
const generateId = () => Math.random().toString(36).substr(2, 9); const generateId = () => Math.random().toString(36).substr(2, 9);
@@ -16,23 +15,70 @@ const App: React.FC = () => {
const [step, setStep] = useState<AppStep>(AppStep.INPUT); const [step, setStep] = useState<AppStep>(AppStep.INPUT);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [language, setLanguage] = useState<Language>('de'); const [language, setLanguage] = useState<Language>('de');
// Project State
const [projectId, setProjectId] = useState<string | null>(null);
const [projectName, setProjectName] = useState<string>('');
// Core Data
const [referenceUrl, setReferenceUrl] = useState<string>(''); const [referenceUrl, setReferenceUrl] = useState<string>('');
const [targetMarket, setTargetMarket] = useState<string>(''); const [targetMarket, setTargetMarket] = useState<string>('');
const [productContext, setProductContext] = useState<string>(''); // Added state for productContext const [productContext, setProductContext] = useState<string>('');
const [referenceCity, setReferenceCity] = useState<string>(''); // Added state for referenceCity const [referenceCity, setReferenceCity] = useState<string>('');
const [referenceCountry, setReferenceCountry] = useState<string>(''); // Added state for referenceCountry const [referenceCountry, setReferenceCountry] = useState<string>('');
// Data States
const [strategy, setStrategy] = useState<SearchStrategy | null>(null); const [strategy, setStrategy] = useState<SearchStrategy | null>(null);
const [competitors, setCompetitors] = useState<Competitor[]>([]); const [competitors, setCompetitors] = useState<Competitor[]>([]);
const [categorizedCompetitors, setCategorizedCompetitors] = useState<{ localCompetitors: Competitor[], nationalCompetitors: Competitor[], internationalCompetitors: Competitor[] } | null>(null); // New state for categorized competitors const [categorizedCompetitors, setCategorizedCompetitors] = useState<{ localCompetitors: Competitor[], nationalCompetitors: Competitor[], internationalCompetitors: Competitor[] } | null>(null);
const [analysisResults, setAnalysisResults] = useState<AnalysisResult[]>([]); const [analysisResults, setAnalysisResults] = useState<AnalysisResult[]>([]);
const [processingState, setProcessingState] = useState<AnalysisState>({ const [processingState, setProcessingState] = useState<AnalysisState>({
currentCompany: '', progress: 0, total: 0, completed: 0 currentCompany: '', progress: 0, total: 0, completed: 0
}); });
const [selectedCompanyForOutreach, setSelectedCompanyForOutreach] = useState<AnalysisResult | null>(null); const [selectedCompanyForOutreach, setSelectedCompanyForOutreach] = useState<AnalysisResult | null>(null);
// --- Auto-Save Effect ---
useEffect(() => {
// Don't save on initial load or reset
if (step === AppStep.INPUT) return;
const saveData = async () => {
if (!referenceUrl) return;
const dataToSave = {
id: projectId,
name: projectName || new URL(referenceUrl).hostname,
created_at: new Date().toISOString(), // DB updates updated_at automatically
currentStep: step,
language,
referenceUrl,
targetMarket,
productContext,
strategy,
competitors,
categorizedCompetitors,
analysisResults
};
try {
const result = await saveProject(dataToSave);
if (result.id && !projectId) {
setProjectId(result.id);
console.log("Project created with ID:", result.id);
}
} catch (e) {
console.error("Auto-save failed", e);
}
};
// Simple debounce to avoid spamming save on every keystroke/state change
const timer = setTimeout(saveData, 1000);
return () => clearTimeout(timer);
}, [step, strategy, competitors, analysisResults, referenceUrl, projectName]);
const handleBack = () => { const handleBack = () => {
if (step === AppStep.STRATEGY) setStep(AppStep.INPUT); if (step === AppStep.STRATEGY) setStep(AppStep.INPUT);
else if (step === AppStep.REVIEW_LIST) setStep(AppStep.STRATEGY); else if (step === AppStep.REVIEW_LIST) setStep(AppStep.STRATEGY);
@@ -48,14 +94,17 @@ const App: React.FC = () => {
setLanguage(selectedLang); setLanguage(selectedLang);
setReferenceUrl(url); setReferenceUrl(url);
setTargetMarket(market); setTargetMarket(market);
setProductContext(productCtx); // Store productContext in state setProductContext(productCtx);
// referenceCity and referenceCountry are not yet extracted from input in StepInput, leaving empty for now.
// Future improvement: extract from referenceUrl or add input fields. // Set explicit name for new project
setReferenceCity(''); try {
setReferenceCountry(''); const hostname = new URL(url).hostname.replace('www.', '');
setProjectName(hostname);
} catch (e) {
setProjectName("New Project");
}
try { try {
// 1. Generate Strategy first
const generatedStrategy = await generateSearchStrategy(url, productCtx, selectedLang); const generatedStrategy = await generateSearchStrategy(url, productCtx, selectedLang);
setStrategy(generatedStrategy); setStrategy(generatedStrategy);
setStep(AppStep.STRATEGY); setStep(AppStep.STRATEGY);
@@ -67,36 +116,70 @@ const App: React.FC = () => {
} }
}; };
// Hydrate state from loaded project (DB or File)
const handleLoadReport = (loadedStrategy: SearchStrategy, loadedResults: AnalysisResult[]) => { const handleLoadReport = (loadedStrategy: SearchStrategy, loadedResults: AnalysisResult[]) => {
setStrategy(loadedStrategy); // NOTE: This signature is from the old StepInput prop.
setAnalysisResults(loadedResults); // Ideally StepInput should pass the FULL project object if loaded from DB.
// Reconstruct competitors list from results for consistency if user goes back // But for backward compatibility with file load, we keep it.
const reconstructedCompetitors = loadedResults.map(r => ({ // If loaded from DB via StepInput -> handleProjectSelect, we need a new handler.
id: generateId(), // See below 'handleLoadProjectData'
name: r.companyName,
dataSource: r.dataSource setStrategy(loadedStrategy);
})); setAnalysisResults(loadedResults);
setCompetitors(reconstructedCompetitors);
setStep(AppStep.REPORT); // Reconstruct competitors list from results for consistency
const reconstructedCompetitors = loadedResults.map(r => ({
id: generateId(),
name: r.companyName,
dataSource: r.dataSource
}));
setCompetitors(reconstructedCompetitors);
setStep(AppStep.REPORT);
};
// NEW: Full Project Hydration
const handleLoadProjectData = (data: any) => {
setProjectId(data.id);
setProjectName(data.name);
setReferenceUrl(data.referenceUrl || '');
setTargetMarket(data.targetMarket || '');
setProductContext(data.productContext || '');
setLanguage(data.language || 'de');
if (data.strategy) setStrategy(data.strategy);
if (data.competitors) setCompetitors(data.competitors);
if (data.categorizedCompetitors) setCategorizedCompetitors(data.categorizedCompetitors);
if (data.analysisResults) setAnalysisResults(data.analysisResults);
// Jump to the last relevant step
// If results exist -> Report. Else if competitors -> Review. Else -> Strategy.
if (data.analysisResults && data.analysisResults.length > 0) {
setStep(AppStep.REPORT);
} else if (data.competitors && data.competitors.length > 0) {
setStep(AppStep.REVIEW_LIST);
} else if (data.strategy) {
setStep(AppStep.STRATEGY);
} else {
setStep(AppStep.INPUT);
}
}; };
const handleStrategyConfirm = async (finalStrategy: SearchStrategy) => { const handleStrategyConfirm = async (finalStrategy: SearchStrategy) => {
setStrategy(finalStrategy); setStrategy(finalStrategy);
setIsLoading(true); setIsLoading(true);
try { try {
// 2. Identify Competitors based on Reference const idealCustomerProfile = finalStrategy.idealCustomerProfile;
const idealCustomerProfile = finalStrategy.idealCustomerProfile; // Use ICP for lookalike search
const identifiedCompetitors = await identifyCompetitors(referenceUrl, targetMarket, productContext, referenceCity, referenceCountry, idealCustomerProfile); const identifiedCompetitors = await identifyCompetitors(referenceUrl, targetMarket, productContext, referenceCity, referenceCountry, idealCustomerProfile);
setCategorizedCompetitors(identifiedCompetitors); // Store categorized competitors setCategorizedCompetitors(identifiedCompetitors);
// Flatten categorized competitors into a single list for existing StepReview component
const flatCompetitors: Competitor[] = [ const flatCompetitors: Competitor[] = [
...(identifiedCompetitors.localCompetitors || []), ...(identifiedCompetitors.localCompetitors || []),
...(identifiedCompetitors.nationalCompetitors || []), ...(identifiedCompetitors.nationalCompetitors || []),
...(identifiedCompetitors.internationalCompetitors || []), ...(identifiedCompetitors.internationalCompetitors || []),
]; ];
setCompetitors(flatCompetitors); // Set the flattened list for StepReview setCompetitors(flatCompetitors);
setStep(AppStep.REVIEW_LIST); setStep(AppStep.REVIEW_LIST);
} catch (e) { } catch (e) {
alert("Failed to find companies."); alert("Failed to find companies.");
@@ -135,23 +218,14 @@ const App: React.FC = () => {
addLog(`> Analyzing ${comp.name} (${i + 1}/${competitors.length})`); addLog(`> Analyzing ${comp.name} (${i + 1}/${competitors.length})`);
try { try {
// Step-by-step logging to make it feel real and informative
addLog(` 🔍 Searching official website for ${comp.name}...`); addLog(` 🔍 Searching official website for ${comp.name}...`);
// The actual API call happens here. While waiting, the user sees the search log.
const result = await analyzeCompanyWithStrategy(comp.name, strategy, language); const result = await analyzeCompanyWithStrategy(comp.name, strategy, language);
if (result.dataSource === "Error") { if (result.dataSource === "Error") {
addLog(` ❌ Error: Could not process ${comp.name}.`); addLog(` ❌ Error: Could not process ${comp.name}.`);
} else { } else {
const websiteStatus = result.dataSource === "Digital Trace Audit" ? "Verified" : (result.dataSource || "Unknown"); const websiteStatus = result.dataSource === "Digital Trace Audit (Deep Dive)" ? "Verified" : (result.dataSource || "Unknown");
const revenue = result.revenue || "N/A";
const employees = result.employees || "N/A";
const status = result.status || "Unknown";
const tier = result.tier || "N/A";
addLog(` ✓ Found website: ${websiteStatus}`); addLog(` ✓ Found website: ${websiteStatus}`);
addLog(` 📊 Estimated: ${revenue} revenue, ${employees} employees.`);
addLog(` 🎯 Status: ${status} | Tier: ${tier}`);
addLog(` ✅ Analysis complete for ${comp.name}.`); addLog(` ✅ Analysis complete for ${comp.name}.`);
} }
@@ -169,6 +243,7 @@ const App: React.FC = () => {
}, [competitors, language, strategy]); }, [competitors, language, strategy]);
const handleRestart = () => { const handleRestart = () => {
setProjectId(null); // Reset Project ID to start fresh
setCompetitors([]); setCompetitors([]);
setAnalysisResults([]); setAnalysisResults([]);
setStrategy(null); setStrategy(null);
@@ -180,7 +255,18 @@ const App: React.FC = () => {
<Header showBack={step !== AppStep.INPUT && step !== AppStep.ANALYSIS} onBack={handleBack} /> <Header showBack={step !== AppStep.INPUT && step !== AppStep.ANALYSIS} onBack={handleBack} />
<main> <main>
{step === AppStep.INPUT && ( {step === AppStep.INPUT && (
<StepInput onSearch={handleInitialInput} onLoadReport={handleLoadReport} isLoading={isLoading} /> <StepInput
onSearch={handleInitialInput}
// We overload onLoadReport to accept full project data if available
onLoadReport={(stratOrFullProject, results) => {
if ('id' in stratOrFullProject) {
handleLoadProjectData(stratOrFullProject);
} else {
handleLoadReport(stratOrFullProject as SearchStrategy, results);
}
}}
isLoading={isLoading}
/>
)} )}
{step === AppStep.STRATEGY && strategy && ( {step === AppStep.STRATEGY && strategy && (
@@ -231,4 +317,4 @@ const App: React.FC = () => {
); );
}; };
export default App; export default App;

View File

@@ -1,8 +1,9 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Search, ArrowRight, Loader2, Globe, Link as LinkIcon, Languages, Upload, FileText, X, FolderOpen, FileUp } from 'lucide-react'; import { Search, ArrowRight, Loader2, Globe, Link as LinkIcon, Languages, Upload, FileText, X, FolderOpen, FileUp, History, Clock } from 'lucide-react';
import { Language, AnalysisResult, SearchStrategy } from '../types'; import { Language, AnalysisResult, SearchStrategy } from '../types';
import { parseMarkdownReport } from '../utils/reportParser'; import { parseMarkdownReport } from '../utils/reportParser';
import { listProjects, loadProject } from '../services/geminiService';
interface StepInputProps { interface StepInputProps {
onSearch: (url: string, context: string, market: string, language: Language) => void; onSearch: (url: string, context: string, market: string, language: Language) => void;
@@ -21,6 +22,38 @@ export const StepInput: React.FC<StepInputProps> = ({ onSearch, onLoadReport, is
const [fileName, setFileName] = useState(''); const [fileName, setFileName] = useState('');
const [market, setMarket] = useState(COUNTRIES[0]); const [market, setMarket] = useState(COUNTRIES[0]);
const [language, setLanguage] = useState<Language>('de'); const [language, setLanguage] = useState<Language>('de');
const [recentProjects, setRecentProjects] = useState<any[]>([]);
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
useEffect(() => {
const fetchProjects = async () => {
setIsLoadingProjects(true);
try {
const projects = await listProjects();
setRecentProjects(projects);
} catch (e) {
console.error("Failed to load projects", e);
} finally {
setIsLoadingProjects(false);
}
};
fetchProjects();
}, []);
const handleProjectSelect = async (projectId: string) => {
try {
const projectData = await loadProject(projectId);
if (projectData && projectData.strategy && projectData.analysisResults) {
onLoadReport(projectData.strategy, projectData.analysisResults);
} else {
alert("Project data is incomplete or corrupted.");
}
} catch (e) {
console.error(e);
alert("Failed to load project.");
}
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -64,181 +97,222 @@ export const StepInput: React.FC<StepInputProps> = ({ onSearch, onLoadReport, is
}; };
return ( return (
<div className="max-w-2xl mx-auto mt-12 p-6"> <div className="max-w-6xl mx-auto mt-12 p-6 flex flex-col md:flex-row gap-8">
<div className="text-center mb-10">
<h2 className="text-3xl font-bold text-slate-900 mb-4">Market Intelligence Agent</h2> {/* LEFT: Main Action Area */}
<div className="flex justify-center gap-2 mb-6"> <div className="flex-1 max-w-2xl mx-auto">
<button <div className="text-center mb-10">
onClick={() => setActiveMode('new')} <h2 className="text-3xl font-bold text-slate-900 mb-4">Market Intelligence Agent</h2>
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${activeMode === 'new' ? 'bg-indigo-600 text-white shadow-md' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'}`} <div className="flex justify-center gap-2 mb-6">
> <button
Start New Audit onClick={() => setActiveMode('new')}
</button> className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${activeMode === 'new' ? 'bg-indigo-600 text-white shadow-md' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'}`}
<button
onClick={() => setActiveMode('load')}
className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${activeMode === 'load' ? 'bg-indigo-600 text-white shadow-md' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'}`}
>
Load Existing Audit
</button>
</div>
{activeMode === 'new' ? (
<p className="text-lg text-slate-600">
Upload your <strong>Strategy Document</strong> to let AI design the perfect market audit.
</p>
) : (
<p className="text-lg text-slate-600">
Select an exported <strong>.md Report</strong> to continue working on an existing analysis.
</p>
)}
</div>
<div className="bg-white shadow-xl rounded-2xl p-8 border border-slate-100">
{activeMode === 'new' ? (
<form onSubmit={handleSubmit} className="space-y-8">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-3">
1. Strategic Context (Markdown/Text)
<span className="block font-normal text-slate-400 text-xs mt-1">Expected: Offer, Target Groups, Personas, Pain Points, Benefits.</span>
</label>
{!fileContent ? (
<div className="border-2 border-dashed border-slate-300 rounded-xl p-8 text-center hover:border-indigo-400 hover:bg-slate-50 transition-all group">
<input
type="file"
accept=".md,.txt,.markdown"
onChange={handleFileUpload}
id="strategy-upload"
className="hidden"
/>
<label htmlFor="strategy-upload" className="cursor-pointer flex flex-col items-center gap-3 w-full">
<div className="bg-indigo-50 p-3 rounded-full text-indigo-500 group-hover:bg-indigo-100 transition-colors">
<Upload size={24} />
</div>
<div>
<p className="font-semibold text-slate-700">Upload Strategy File</p>
<p className="text-xs text-slate-400 mt-1">.md or .txt files</p>
</div>
</label>
</div>
) : (
<div className="flex items-center gap-4 p-4 bg-emerald-50 border border-emerald-100 rounded-xl">
<div className="bg-emerald-100 p-2 rounded-lg shrink-0">
<FileText className="text-emerald-600" size={24} />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-emerald-900 truncate">{fileName}</p>
<p className="text-xs text-emerald-700">Context loaded successfully</p>
</div>
<button
type="button"
onClick={handleRemoveFile}
className="p-2 hover:bg-emerald-100 rounded-full text-emerald-600 transition-colors"
>
<X size={18} />
</button>
</div>
)}
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">2. Reference Company URL</label>
<div className="relative">
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="e.g. https://www.reference-customer.com"
className="w-full pl-12 pr-4 py-3 rounded-xl border border-slate-200 focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10 outline-none transition-all text-lg"
required
/>
<LinkIcon className="absolute left-4 top-3.5 text-slate-400" size={20} />
</div>
<p className="text-xs text-slate-400 mt-2">Used to calibrate the search and find lookalikes.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Target Market</label>
<div className="relative">
<select
value={market}
onChange={(e) => setMarket(e.target.value)}
className="w-full pl-12 pr-4 py-3 rounded-xl border border-slate-200 focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10 outline-none transition-all text-lg appearance-none bg-white cursor-pointer"
>
{COUNTRIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<Globe className="absolute left-4 top-3.5 text-slate-400" size={20} />
</div>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Report Language</label>
<div className="relative">
<select
value={language}
onChange={(e) => setLanguage(e.target.value as Language)}
className="w-full pl-12 pr-4 py-3 rounded-xl border border-slate-200 focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10 outline-none transition-all text-lg appearance-none bg-white cursor-pointer"
>
<option value="de">German</option>
<option value="en">English</option>
</select>
<Languages className="absolute left-4 top-3.5 text-slate-400" size={20} />
</div>
</div>
</div>
<button
type="submit"
disabled={isLoading || !url || !fileContent}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 disabled:cursor-not-allowed text-white font-bold py-4 rounded-xl shadow-lg shadow-indigo-600/20 transition-all transform active:scale-[0.98] flex items-center justify-center gap-2 text-lg"
> >
{isLoading ? ( Start New Audit
<> </button>
<Loader2 className="animate-spin" /> Analyzing Strategy... <button
</> onClick={() => setActiveMode('load')}
) : ( className={`px-4 py-2 rounded-lg text-sm font-bold transition-all ${activeMode === 'load' ? 'bg-indigo-600 text-white shadow-md' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'}`}
<> >
Analyze Context & Build Strategy <ArrowRight size={20} /> Load .MD File
</>
)}
</button> </button>
</form>
) : (
<div className="space-y-6">
<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 group">
<input
type="file"
accept=".md"
onChange={handleLoadReport}
id="report-load"
className="hidden"
/>
<label htmlFor="report-load" className="cursor-pointer flex flex-col items-center gap-4 w-full">
<div className="bg-indigo-50 p-4 rounded-full text-indigo-500 group-hover:bg-indigo-100 transition-colors">
<FolderOpen size={32} />
</div>
<div>
<p className="text-xl font-semibold text-slate-900">Upload Markdown Report</p>
<p className="text-sm text-slate-500 mt-1">Reconstruct your analysis from a .md file</p>
</div>
<div className="mt-4 bg-white border border-slate-200 px-6 py-2 rounded-lg font-bold text-slate-700 shadow-sm flex items-center gap-2 group-hover:bg-slate-50">
<FileUp size={18} /> Browse Files
</div>
</label>
</div> </div>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-100"> {activeMode === 'new' ? (
<h4 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">Note:</h4> <p className="text-lg text-slate-600">
<p className="text-sm text-slate-600"> Upload your <strong>Strategy Document</strong> to let AI design the perfect market audit.
Loading an existing audit will take you directly to the Report view. You can then trigger new outreach campaigns for any company in the list. </p>
</p> ) : (
<p className="text-lg text-slate-600">
Select an exported <strong>.md Report</strong> to continue working on an existing analysis.
</p>
)}
</div>
<div className="bg-white shadow-xl rounded-2xl p-8 border border-slate-100">
{activeMode === 'new' ? (
<form onSubmit={handleSubmit} className="space-y-8">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-3">
1. Strategic Context (Markdown/Text)
<span className="block font-normal text-slate-400 text-xs mt-1">Expected: Offer, Target Groups, Personas, Pain Points, Benefits.</span>
</label>
{!fileContent ? (
<div className="border-2 border-dashed border-slate-300 rounded-xl p-8 text-center hover:border-indigo-400 hover:bg-slate-50 transition-all group">
<input
type="file"
accept=".md,.txt,.markdown"
onChange={handleFileUpload}
id="strategy-upload"
className="hidden"
/>
<label htmlFor="strategy-upload" className="cursor-pointer flex flex-col items-center gap-3 w-full">
<div className="bg-indigo-50 p-3 rounded-full text-indigo-500 group-hover:bg-indigo-100 transition-colors">
<Upload size={24} />
</div>
<div>
<p className="font-semibold text-slate-700">Upload Strategy File</p>
<p className="text-xs text-slate-400 mt-1">.md or .txt files</p>
</div>
</label>
</div>
) : (
<div className="flex items-center gap-4 p-4 bg-emerald-50 border border-emerald-100 rounded-xl">
<div className="bg-emerald-100 p-2 rounded-lg shrink-0">
<FileText className="text-emerald-600" size={24} />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-emerald-900 truncate">{fileName}</p>
<p className="text-xs text-emerald-700">Context loaded successfully</p>
</div>
<button
type="button"
onClick={handleRemoveFile}
className="p-2 hover:bg-emerald-100 rounded-full text-emerald-600 transition-colors"
>
<X size={18} />
</button>
</div>
)}
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">2. Reference Company URL</label>
<div className="relative">
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="e.g. https://www.reference-customer.com"
className="w-full pl-12 pr-4 py-3 rounded-xl border border-slate-200 focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10 outline-none transition-all text-lg"
required
/>
<LinkIcon className="absolute left-4 top-3.5 text-slate-400" size={20} />
</div>
<p className="text-xs text-slate-400 mt-2">Used to calibrate the search and find lookalikes.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Target Market</label>
<div className="relative">
<select
value={market}
onChange={(e) => setMarket(e.target.value)}
className="w-full pl-12 pr-4 py-3 rounded-xl border border-slate-200 focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10 outline-none transition-all text-lg appearance-none bg-white cursor-pointer"
>
{COUNTRIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<Globe className="absolute left-4 top-3.5 text-slate-400" size={20} />
</div>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">Report Language</label>
<div className="relative">
<select
value={language}
onChange={(e) => setLanguage(e.target.value as Language)}
className="w-full pl-12 pr-4 py-3 rounded-xl border border-slate-200 focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10 outline-none transition-all text-lg appearance-none bg-white cursor-pointer"
>
<option value="de">German</option>
<option value="en">English</option>
</select>
<Languages className="absolute left-4 top-3.5 text-slate-400" size={20} />
</div>
</div>
</div>
<button
type="submit"
disabled={isLoading || !url || !fileContent}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 disabled:cursor-not-allowed text-white font-bold py-4 rounded-xl shadow-lg shadow-indigo-600/20 transition-all transform active:scale-[0.98] flex items-center justify-center gap-2 text-lg"
>
{isLoading ? (
<>
<Loader2 className="animate-spin" /> Analyzing Strategy...
</>
) : (
<>
Analyze Context & Build Strategy <ArrowRight size={20} />
</>
)}
</button>
</form>
) : (
<div className="space-y-6">
<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 group">
<input
type="file"
accept=".md"
onChange={handleLoadReport}
id="report-load"
className="hidden"
/>
<label htmlFor="report-load" className="cursor-pointer flex flex-col items-center gap-4 w-full">
<div className="bg-indigo-50 p-4 rounded-full text-indigo-500 group-hover:bg-indigo-100 transition-colors">
<FolderOpen size={32} />
</div>
<div>
<p className="text-xl font-semibold text-slate-900">Upload Markdown Report</p>
<p className="text-sm text-slate-500 mt-1">Reconstruct your analysis from a .md file</p>
</div>
<div className="mt-4 bg-white border border-slate-200 px-6 py-2 rounded-lg font-bold text-slate-700 shadow-sm flex items-center gap-2 group-hover:bg-slate-50">
<FileUp size={18} /> Browse Files
</div>
</label>
</div>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-100">
<h4 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">Note:</h4>
<p className="text-sm text-slate-600">
Loading an existing audit will take you directly to the Report view. You can then trigger new outreach campaigns for any company in the list.
</p>
</div>
</div> </div>
</div> )}
)} </div>
</div> </div>
{/* RIGHT: Recent Projects Sidebar */}
<div className="w-full md:w-80 flex flex-col gap-4">
<div className="bg-white rounded-2xl shadow-lg border border-slate-200 p-6 h-full max-h-[800px] flex flex-col">
<div className="flex items-center gap-3 mb-6">
<History className="text-indigo-600" size={24} />
<h3 className="font-bold text-slate-900 text-lg">Past Runs</h3>
</div>
<div className="flex-1 overflow-y-auto pr-2 space-y-3">
{isLoadingProjects ? (
<div className="flex justify-center p-4">
<Loader2 className="animate-spin text-slate-400" />
</div>
) : recentProjects.length > 0 ? (
recentProjects.map((p) => (
<button
key={p.id}
onClick={() => handleProjectSelect(p.id)}
className="w-full text-left p-4 rounded-xl bg-slate-50 hover:bg-indigo-50 border border-slate-100 hover:border-indigo-200 transition-all group"
>
<div className="font-semibold text-slate-800 group-hover:text-indigo-700 truncate">{p.name}</div>
<div className="flex items-center gap-2 mt-2 text-xs text-slate-400">
<Clock size={12} />
<span>{new Date(p.updated_at).toLocaleDateString()} {new Date(p.updated_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
</div>
</button>
))
) : (
<div className="text-center text-slate-400 text-sm py-10">
No saved runs yet.
</div>
)}
</div>
</div>
</div>
</div> </div>
); );
}; };

View File

@@ -194,61 +194,64 @@ export const StepOutreach: React.FC<StepOutreachProps> = ({ company, language, r
</div> </div>
<div className="bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden flex flex-col md:flex-row min-h-[600px]"> <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 */} {/* Sidebar Tabs */}
<div className="w-full md:w-80 bg-slate-50 border-r border-slate-200 flex flex-col"> <div className="w-full md:w-80 bg-slate-50 border-r border-slate-200 flex flex-col h-full max-h-[600px]">
<div className="p-4 border-b border-slate-200"> <div className="p-4 border-b border-slate-200 flex-none">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Generated Campaigns</h3> <h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Campaign Roles</h3>
</div> </div>
{/* Generated List */} {/* Unified Scroll List */}
<div className="flex-1 overflow-y-auto p-2 flex flex-col gap-2"> <div className="flex-1 overflow-y-auto p-2 flex flex-col gap-2">
{emails.map((email, idx) => ( {/* Generated Campaigns */}
<button {emails.map((email, idx) => (
key={idx} <button
onClick={() => setActiveTab(idx)} key={idx}
className={`text-left p-3 rounded-lg text-sm font-medium transition-all group ${activeTab === idx onClick={() => setActiveTab(idx)}
? 'bg-white shadow-sm text-indigo-700 border border-indigo-100 ring-1 ring-indigo-500/20' className={`text-left p-3 rounded-lg text-sm font-medium transition-all group ${
: 'text-slate-600 hover:bg-slate-200/50' 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 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>
))}
{/* Separator for Suggestions */}
{availableRoles.length > 0 && (
<div className="mt-4 mb-2 px-2 flex items-center gap-2">
<div className="h-px bg-slate-200 flex-1"></div>
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1">
<Sparkles size={10} /> Suggestions
</span>
<div className="h-px bg-slate-200 flex-1"></div>
</div>
)}
{/* Suggestions List */}
{availableRoles.map((role, i) => (
<div key={`sugg-${i}`} className="flex items-center justify-between p-2 pl-3 bg-slate-100/50 border border-slate-200/50 rounded-lg group hover:border-indigo-200 transition-colors">
<span className="text-xs font-medium text-slate-600 truncate max-w-[180px]" title={role}>{role}</span>
<button
onClick={() => handleGenerateSpecific(role)}
disabled={!!isGeneratingSpecific}
className="bg-white hover:bg-indigo-50 text-indigo-600 border border-slate-200 hover:border-indigo-200 p-1.5 rounded-md transition-all disabled:opacity-50 shadow-sm"
title="Generate Campaign"
>
{isGeneratingSpecific === role ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Plus size={14} strokeWidth={3} />
)}
</button>
</div>
))}
</div>
</div> </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>
{/* Content Area */} {/* Content Area */}
<div className="flex-1 p-8 flex flex-col overflow-y-auto bg-slate-50/30"> <div className="flex-1 p-8 flex flex-col overflow-y-auto bg-slate-50/30">

View File

@@ -213,6 +213,34 @@ app.post('/api/generate-outreach', async (req, res) => {
} }
}); });
// --- DATABASE ROUTES ---
// Initialize DB on startup
const dbScript = path.join(__dirname, '..', 'market_db_manager.py');
spawn('python3', [dbScript, 'init']);
app.get('/api/projects', (req, res) => {
runPython([dbScript, 'list'], res);
});
app.get('/api/projects/:id', (req, res) => {
runPython([dbScript, 'load', req.params.id], res);
});
app.post('/api/save-project', (req, res) => {
const projectData = req.body;
const tmpDir = path.join(__dirname, 'tmp');
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir);
const tempFilePath = path.join(tmpDir, `save_${Date.now()}.json`);
try {
fs.writeFileSync(tempFilePath, JSON.stringify(projectData));
runPython([dbScript, 'save', tempFilePath], res, [tempFilePath]);
} catch (e) {
res.status(500).json({ error: 'Failed to write project data to disk' });
}
});
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Node.js API Bridge running on http://localhost:${PORT}`); console.log(`Node.js API Bridge running on http://localhost:${PORT}`);
}); });

View File

@@ -251,4 +251,28 @@ export const translateEmailDrafts = async (drafts: EmailDraft[], targetLanguage:
// Dieser Teil muss noch im Python-Backend oder direkt im Frontend implementiert werden // Dieser Teil muss noch im Python-Backend oder direkt im Frontend implementiert werden
console.warn("translateEmailDrafts ist noch nicht im Python-Backend implementiert."); console.warn("translateEmailDrafts ist noch nicht im Python-Backend implementiert.");
return drafts; return drafts;
} }
// --- PROJECT MANAGEMENT (DB) ---
export const listProjects = async (): Promise<any[]> => {
const response = await fetch(`${API_BASE_URL}/projects`);
if (!response.ok) throw new Error("Failed to list projects");
return await response.json();
};
export const loadProject = async (id: string): Promise<any> => {
const response = await fetch(`${API_BASE_URL}/projects/${id}`);
if (!response.ok) throw new Error("Failed to load project");
return await response.json();
};
export const saveProject = async (data: any): Promise<any> => {
const response = await fetch(`${API_BASE_URL}/save-project`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error("Failed to save project");
return await response.json();
};

105
market_db_manager.py Normal file
View File

@@ -0,0 +1,105 @@
import sqlite3
import json
import os
import uuid
from datetime import datetime
DB_PATH = "/app/market_intelligence.db"
def get_db_connection():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_db_connection()
# Flexible schema: We store almost everything in a 'data' JSON column
conn.execute('''
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
data JSON NOT NULL
)
''')
conn.commit()
conn.close()
def save_project(project_data):
"""
Saves a project. If 'id' exists in data, updates it. Otherwise creates new.
"""
conn = get_db_connection()
try:
project_id = project_data.get('id')
# Extract a name for the list view (e.g. from companyName or referenceUrl)
# We assume the frontend passes a 'name' field, or we derive it.
name = project_data.get('name') or project_data.get('companyName') or "Untitled Project"
if not project_id:
# Create New
project_id = str(uuid.uuid4())
project_data['id'] = project_id
conn.execute(
'INSERT INTO projects (id, name, data) VALUES (?, ?, ?)',
(project_id, name, json.dumps(project_data))
)
else:
# Update Existing
conn.execute(
'''UPDATE projects
SET name = ?, data = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?''',
(name, json.dumps(project_data), project_id)
)
conn.commit()
return {"id": project_id, "status": "saved"}
except Exception as e:
return {"error": str(e)}
finally:
conn.close()
def get_all_projects():
conn = get_db_connection()
projects = conn.execute('SELECT id, name, created_at, updated_at FROM projects ORDER BY updated_at DESC').fetchall()
conn.close()
return [dict(ix) for ix in projects]
def load_project(project_id):
conn = get_db_connection()
project = conn.execute('SELECT data FROM projects WHERE id = ?', (project_id,)).fetchone()
conn.close()
if project:
return json.loads(project['data'])
return None
if __name__ == "__main__":
import sys
# Simple CLI for Node.js bridge
# Usage: python market_db_manager.py [init|list|save|load] [args...]
mode = sys.argv[1]
if mode == "init":
init_db()
print(json.dumps({"status": "initialized"}))
elif mode == "list":
print(json.dumps(get_all_projects()))
elif mode == "save":
# Data is passed as a JSON string file path to avoid command line length limits
data_file = sys.argv[2]
with open(data_file, 'r') as f:
data = json.load(f)
print(json.dumps(save_project(data)))
elif mode == "load":
p_id = sys.argv[2]
result = load_project(p_id)
print(json.dumps(result if result else {"error": "Project not found"}))