Files
Brancheneinstufung2/competitor-analysis-app/App.tsx
Floke 8e5ab8949b feat(competitor-analysis): Fix 404, SDK compatibility, and update docs
Resolved multiple issues preventing the 'competitor-analysis' app from running and serving its frontend:

1.  **Fixed Python SyntaxError in Prompts:** Corrected unterminated string literals and ensure proper multi-line string formatting (using .format() instead of f-strings for complex prompts) in .
2.  **Addressed Python SDK Compatibility (google-generativeai==0.3.0):**
    *   Removed  for  and  by adapting the orchestrator to pass JSON schemas as direct Python dictionaries, as required by the older SDK version.
    *   Updated  with detailed guidance on handling / imports and dictionary-based schema definitions for older SDKs.
3.  **Corrected Frontend Build Dependencies:** Moved critical build dependencies (like , , ) from  to  in .
    *   Updated  to include this  pitfall, ensuring frontend build tools are installed in Docker.
4.  **Updated Documentation:**
    *   : Added comprehensive lessons learned regarding  dependencies, Python SDK versioning (specifically  and  imports for ), and robust multi-line prompt handling.
    *   : Integrated specific details of the encountered errors and their solutions, making the migration report a more complete historical record and guide.

These changes collectively fix the 404 error by ensuring the Python backend starts correctly and serves the frontend assets after a successful build.
2026-01-10 09:10:00 +00:00

448 lines
24 KiB
TypeScript

import React, { useState, useCallback, useEffect, useRef } from 'react';
import type { AppState, CompetitorCandidate, Product, TargetIndustry, Keyword, SilverBullet, Battlecard, ReferenceAnalysis } from './types';
import { fetchStep1Data, fetchStep2Data, fetchStep3Data, fetchStep4Data, fetchStep5Data_SilverBullets, fetchStep6Data_Conclusion, fetchStep7Data_Battlecards, fetchStep8Data_ReferenceAnalysis } from './services/geminiService';
import { generatePdfReport } from './services/pdfService';
import InputForm from './components/InputForm';
import StepIndicator from './components/StepIndicator';
import Step1Extraction from './components/Step1_Extraction';
import Step2Keywords from './components/Step2_Keywords';
import Step3Competitors from './components/Step3_Competitors';
import Step4Analysis from './components/Step4_Analysis';
import Step5SilverBullets from './components/Step5_SilverBullets';
import Step6Conclusion from './components/Step6_Conclusion';
import Step7_Battlecards from './components/Step7_Battlecards';
import Step8_References from './components/Step8_References';
import LoadingSpinner from './components/LoadingSpinner';
import { translations } from './translations';
const SunIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>);
const MoonIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>);
const RestartIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h5M4 12a9 9 0 109-9" /></svg>);
const DownloadIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>);
const ChevronDownIcon = () => (<svg className="w-5 h-5 ml-2 -mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" /></svg>);
const App: React.FC = () => {
const [appState, setAppState] = useState<AppState | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [theme, setTheme] = useState<'light' | 'dark'>('dark');
const [highestStep, setHighestStep] = useState(0);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const t = translations[appState?.initial_params?.language || 'de'];
useEffect(() => {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [theme]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleRestart = () => {
setAppState(null);
setIsLoading(false);
setError(null);
setHighestStep(0);
};
const handleStartAnalysis = useCallback(async (startUrl: string, maxCompetitors: number, marketScope: string, language: 'de' | 'en') => {
setIsLoading(true);
setError(null);
setAppState(null);
setHighestStep(0);
const currentT = translations[language];
try {
const { products, target_industries } = await fetchStep1Data(startUrl, language);
setAppState({
step: 1,
initial_params: { start_url: startUrl, max_competitors: maxCompetitors, market_scope: marketScope, language },
company: { name: new URL(startUrl).hostname.replace('www.', ''), start_url: startUrl },
products: products,
target_industries: target_industries,
keywords: [],
competitor_candidates: [],
competitors_shortlist: [],
analyses: [],
silver_bullets: [],
conclusion: null,
battlecards: [],
reference_analysis: [],
reference_analysis_grounding: [],
});
setHighestStep(1);
} catch (e) {
console.error("Error in Step 1:", e);
setError(currentT.errors.step1);
} finally {
setIsLoading(false);
}
}, []);
const handleConfirmStep = useCallback(async () => {
if (!appState) return;
setIsLoading(true);
setError(null);
const nextStep = appState.step + 1;
const lang = appState.initial_params.language;
try {
let newState: Partial<AppState> = {};
switch (appState.step) {
case 1:
const { keywords } = await fetchStep2Data(appState.products, appState.target_industries, lang);
newState = { keywords, step: 2 };
break;
case 2:
const { competitor_candidates } = await fetchStep3Data(appState.keywords, appState.initial_params.market_scope, lang);
newState = { competitor_candidates, step: 3 };
break;
case 3:
const shortlist = [...appState.competitor_candidates]
.sort((a, b) => b.confidence - a.confidence)
.slice(0, appState.initial_params.max_competitors);
const { analyses } = await fetchStep4Data(appState.company, shortlist, lang);
newState = { competitors_shortlist: shortlist, analyses, step: 4 };
break;
case 4:
const { silver_bullets } = await fetchStep5Data_SilverBullets(appState.company, appState.analyses, lang);
newState = { silver_bullets, step: 5 };
break;
case 5:
const { conclusion } = await fetchStep6Data_Conclusion(appState.company, appState.products, appState.target_industries, appState.analyses, appState.silver_bullets, lang);
newState = { conclusion, step: 6 };
break;
case 6:
const { battlecards } = await fetchStep7Data_Battlecards(appState.company, appState.analyses, appState.silver_bullets, lang);
newState = { battlecards, step: 7 };
break;
case 7:
const { reference_analysis, groundingMetadata } = await fetchStep8Data_ReferenceAnalysis(appState.competitors_shortlist, lang);
newState = { reference_analysis, reference_analysis_grounding: groundingMetadata, step: 8 };
break;
}
setAppState(prevState => ({ ...prevState!, ...newState }));
if (nextStep > highestStep) {
setHighestStep(nextStep);
}
} catch (e) {
console.error(`Error in Step ${appState.step + 1}:`, e);
setError(translations[lang].errors.generic(appState.step + 1));
} finally {
setIsLoading(false);
}
}, [appState, highestStep]);
const handleUpdateState = useCallback((key: keyof AppState, value: any) => {
setAppState(prevState => {
if (!prevState) return null;
return { ...prevState, [key]: value };
});
}, []);
const renderStepContent = () => {
if (!appState) return null;
switch (appState.step) {
case 1: return <Step1Extraction products={appState.products} industries={appState.target_industries} onProductsChange={(p) => handleUpdateState('products', p)} onIndustriesChange={(i) => handleUpdateState('target_industries', i)} t={t.step1} lang={appState.initial_params.language} />;
case 2: return <Step2Keywords keywords={appState.keywords} onKeywordsChange={(k) => handleUpdateState('keywords', k)} t={t.step2} />;
case 3: return <Step3Competitors candidates={appState.competitor_candidates} onCandidatesChange={(c) => handleUpdateState('competitor_candidates', c)} maxCompetitors={appState.initial_params.max_competitors} t={t.step3} />;
case 4: return <Step4Analysis analyses={appState.analyses} t={t.step4} />;
case 5: return <Step5SilverBullets silver_bullets={appState.silver_bullets} t={t.step5} />;
case 6: return <Step6Conclusion appState={appState} t={t.step6} />;
case 7: return <Step7_Battlecards appState={appState} t={t.step7} />;
case 8: return <Step8_References analyses={appState.reference_analysis} groundingMetadata={appState.reference_analysis_grounding} t={t.step8} />;
default: return null;
}
};
// Download logic
const handleDownloadJson = () => {
if (!appState) return;
const content = JSON.stringify(appState, null, 2);
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `analysis_${appState.company.name}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
setIsDropdownOpen(false);
};
const transformMatrixData = (matrixData: any[] | undefined): { [row: string]: { [col: string]: string } } => {
const tableData: { [row: string]: { [col: string]: string } } = {};
if (!matrixData || !Array.isArray(matrixData) || matrixData.length === 0) {
return tableData;
}
const allCompetitors = new Set<string>();
matrixData.forEach(row => {
if (row && Array.isArray(row.availability)) {
row.availability.forEach(item => {
if (item && typeof item.competitor === 'string') {
allCompetitors.add(item.competitor);
}
});
}
});
const competitorList = Array.from(allCompetitors).sort();
matrixData.forEach(row => {
if (!row) return;
const rowKey = 'product' in row ? row.product : ('industry' in row ? row.industry : undefined);
if (typeof rowKey !== 'string' || !rowKey) {
return;
}
const availabilityMap = new Map<string, boolean>();
if (Array.isArray(row.availability)) {
row.availability.forEach(item => {
if (item && typeof item.competitor === 'string') {
availabilityMap.set(item.competitor, item.has_offering);
}
});
}
const rowObject: { [col: string]: string } = {};
competitorList.forEach(competitor => {
rowObject[competitor] = availabilityMap.get(competitor) ? '✓' : ' ';
});
tableData[rowKey] = rowObject;
});
return tableData;
};
const generateMarkdownReport = (): string => {
if (!appState) return "";
const langT = t.step6.markdown;
let md = `# ${langT.title}: ${appState.company.name}\n\n`;
md += `**${langT.startUrl}:** ${appState.initial_params.start_url}\n`;
md += `**${langT.marketScope}:** ${appState.initial_params.market_scope}\n\n`;
md += `## ${langT.step1_title}\n`;
md += `### ${langT.step1_products}\n`;
appState.products.forEach(p => md += `- **${p.name}:** ${p.purpose}\n`);
md += `\n### ${langT.step1_industries}\n`;
appState.target_industries.forEach(i => md += `- ${i.name}\n`);
md += `\n## ${langT.step4_title}\n`;
appState.analyses.forEach(a => {
md += `### ${a.competitor.name} (Overlap: ${a.overlap_score}%)\n`;
md += `- **${langT.portfolio}:** ${a.portfolio.map(p => p.product).join(', ')}\n`;
md += `- **${langT.targetIndustries}:** ${a.target_industries.join(', ')}\n`;
md += `- **${langT.differentiators}:**\n`;
a.differentiators.forEach(d => md += ` - ${d}\n`);
md += `\n`;
});
md += `## ${langT.step5_title}\n`;
appState.silver_bullets.forEach(b => {
md += `- **${langT.against} ${b.competitor_name}:** "${b.statement}"\n`;
});
if (appState.battlecards && appState.battlecards.length > 0) {
md += `\n## ${langT.step7_title}\n\n`;
appState.battlecards.forEach(card => {
md += `### ${langT.against} ${card.competitor_name}\n\n`;
md += `**${langT.profile}:**\n`;
md += `- **${langT.focus}:** ${card.competitor_profile.focus}\n`;
md += `- **${langT.positioning}:** ${card.competitor_profile.positioning}\n\n`;
md += `**${langT.strengthsVsWeaknesses}:**\n`;
(card.strengths_vs_weaknesses || []).forEach(s => md += `- ${s}\n`);
md += `\n`;
md += `**${langT.landmineQuestions}:**\n`;
(card.landmine_questions || []).forEach(q => md += `- ${q}\n`);
md += `\n`;
md += `**${langT.silverBullet}:**\n`;
md += `> "${card.silver_bullet}"\n\n`;
});
}
if (appState.reference_analysis && appState.reference_analysis.length > 0) {
md += `\n## ${langT.step8_title}\n`;
appState.reference_analysis.forEach(analysis => {
md += `### ${analysis.competitor_name}\n`;
if ((analysis.references || []).length > 0) {
(analysis.references || []).forEach(ref => {
md += `- **${ref.name}** (${ref.industry || 'N/A'})\n`;
if(ref.testimonial_snippet) md += ` - *"${ref.testimonial_snippet}"*\n`;
if(ref.case_study_url) md += ` - [${langT.caseStudyLink}](${ref.case_study_url})\n`;
});
} else {
md += ` - ${langT.noReferencesFound}\n`;
}
md += `\n`;
});
if (appState.reference_analysis_grounding && appState.reference_analysis_grounding.length > 0) {
md += `\n#### ${langT.sources}\n`;
appState.reference_analysis_grounding
.filter(chunk => chunk.web && chunk.web.uri)
.forEach(chunk => {
md += `- [${chunk.web.title || chunk.web.uri}](${chunk.web.uri})\n`;
});
}
}
if(appState.conclusion) {
md += `\n## ${langT.step6_title}\n`;
const transformForMdTable = (data: { [key: string]: { [key: string]: string } }) => {
if (Object.keys(data).length === 0) return { head: '', body: '' };
const headers = Object.keys(Object.values(data)[0] || {});
const head = `| | ${headers.join(' | ')} |\n`;
const separator = `|---|${headers.map(() => '---').join('|')}|\n`;
const body = Object.entries(data).map(([row, cols]) => `| **${row}** | ${headers.map(h => cols[h] || ' ').join(' | ')} |`).join('\n');
return { head, body: separator + body };
};
const productMatrixForTable = transformMatrixData(appState.conclusion.product_matrix);
const industryMatrixForTable = transformMatrixData(appState.conclusion.industry_matrix);
md += `### ${langT.productMatrix}\n`;
const prodMd = transformForMdTable(productMatrixForTable);
md += prodMd.head + prodMd.body + '\n\n';
md += `### ${langT.industryMatrix}\n`;
const indMd = transformForMdTable(industryMatrixForTable);
md += indMd.head + indMd.body + '\n\n';
md += `### ${langT.summary}\n${appState.conclusion.summary}\n\n`;
md += `### ${langT.opportunities}\n${appState.conclusion.opportunities}\n\n`;
md += `### ${langT.nextQuestions}\n`;
(appState.conclusion.next_questions || []).forEach(q => md += `- ${q}\n`);
}
return md;
};
const handleDownloadMd = () => {
const mdContent = generateMarkdownReport();
const blob = new Blob([mdContent], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
if (appState) {
link.download = `analysis_${appState.company.name}.md`;
}
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
setIsDropdownOpen(false);
};
const handleDownloadPdf = async () => {
if (!appState) return;
await generatePdfReport(appState, t.step6.markdown);
setIsDropdownOpen(false);
};
return (
<div className="min-h-screen p-4 sm:p-6 lg:p-8">
<header className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">{t.appTitle}</h1>
<div className="flex items-center space-x-4">
{appState && highestStep >= 6 && (
<div className="relative inline-block text-left" ref={dropdownRef}>
<div>
<button
type="button"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="inline-flex justify-center w-full rounded-lg border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-light-secondary dark:focus:ring-offset-brand-secondary focus:ring-green-500"
>
<DownloadIcon />
{t.step6.downloadButton}
<ChevronDownIcon />
</button>
</div>
{isDropdownOpen && (
<div className="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-light-secondary dark:bg-brand-secondary ring-1 ring-black ring-opacity-5 z-20">
<div className="py-1" role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
<a href="#" onClick={(e) => { e.preventDefault(); handleDownloadJson(); }} className="block px-4 py-2 text-sm text-light-text dark:text-brand-text hover:bg-light-accent dark:hover:bg-brand-accent" role="menuitem">{t.step6.downloadJson}</a>
<a href="#" onClick={(e) => { e.preventDefault(); handleDownloadMd(); }} className="block px-4 py-2 text-sm text-light-text dark:text-brand-text hover:bg-light-accent dark:hover:bg-brand-accent" role="menuitem">{t.step6.downloadMd}</a>
<a href="#" onClick={(e) => { e.preventDefault(); handleDownloadPdf(); }} className="block px-4 py-2 text-sm text-light-text dark:text-brand-text hover:bg-light-accent dark:hover:bg-brand-accent" role="menuitem">{t.step6.downloadPdf}</a>
</div>
</div>
)}
</div>
)}
{appState && (
<button onClick={handleRestart} className="p-2 rounded-full hover:bg-light-accent dark:hover:bg-brand-accent" title={t.restartAnalysis}>
<RestartIcon />
</button>
)}
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} className="p-2 rounded-full hover:bg-light-accent dark:hover:bg-brand-accent">
{theme === 'dark' ? <SunIcon /> : <MoonIcon />}
</button>
</div>
</header>
<main className="mx-auto">
{!appState && !isLoading && <InputForm onStart={handleStartAnalysis} />}
{isLoading && !appState && <LoadingSpinner t={t.loadingSpinner} />}
{appState && (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 items-start">
<aside className="lg:col-span-1 lg:sticky lg:top-8">
<div className="bg-light-secondary dark:bg-brand-secondary p-4 rounded-lg shadow-md mb-6">
<h3 className="font-bold text-lg mb-2">{t.companyCard.title}</h3>
<p className="text-sm break-words">{appState.company.name}</p>
</div>
<StepIndicator
currentStep={appState.step}
highestStep={highestStep}
onStepClick={(step) => setAppState(prevState => ({...prevState!, step}))}
t={t.stepIndicator}
/>
</aside>
<div className="lg:col-span-3">
<div className="min-h-[400px]">
{error && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded-lg mb-6" role="alert">
<p className="font-bold">{t.errors.title}</p>
<p>{error}</p>
</div>
)}
{isLoading ? <LoadingSpinner t={t.loadingSpinner} /> : renderStepContent()}
</div>
{appState.step < 8 && !isLoading && (
<div className="mt-8 text-right">
<button
onClick={handleConfirmStep}
className="bg-brand-highlight hover:bg-blue-600 text-white font-bold py-3 px-6 rounded-lg shadow-lg transition duration-300 transform hover:scale-105"
>
{t.nextStepButton}
</button>
</div>
)}
</div>
</div>
)}
</main>
</div>
);
};
export default App;