diff --git a/competitor-analysis/App.tsx b/competitor-analysis/App.tsx new file mode 100644 index 00000000..a9b2dc84 --- /dev/null +++ b/competitor-analysis/App.tsx @@ -0,0 +1,448 @@ +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 = () => (); +const MoonIcon = () => (); +const RestartIcon = () => (); +const DownloadIcon = () => (); +const ChevronDownIcon = () => (); + + +const App: React.FC = () => { + const [appState, setAppState] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [theme, setTheme] = useState<'light' | 'dark'>('dark'); + const [highestStep, setHighestStep] = useState(0); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const dropdownRef = useRef(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 = {}; + 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 handleUpdateState('products', p)} onIndustriesChange={(i) => handleUpdateState('target_industries', i)} t={t.step1} lang={appState.initial_params.language} />; + case 2: return handleUpdateState('keywords', k)} t={t.step2} />; + case 3: return handleUpdateState('competitor_candidates', c)} maxCompetitors={appState.initial_params.max_competitors} t={t.step3} />; + case 4: return ; + case 5: return ; + case 6: return ; + case 7: return ; + case 8: return ; + 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(); + 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(); + 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 ( + + + {t.appTitle} + + {appState && highestStep >= 6 && ( + + + 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" + > + + {t.step6.downloadButton} + + + + {isDropdownOpen && ( + + + { 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} + { 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} + { 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} + + + )} + + )} + {appState && ( + + + + )} + setTheme(theme === 'dark' ? 'light' : 'dark')} className="p-2 rounded-full hover:bg-light-accent dark:hover:bg-brand-accent"> + {theme === 'dark' ? : } + + + + + + {!appState && !isLoading && } + + {isLoading && !appState && } + + {appState && ( + + + + + + {error && ( + + {t.errors.title} + {error} + + )} + {isLoading ? : renderStepContent()} + + + {appState.step < 8 && !isLoading && ( + + + {t.nextStepButton} + + + )} + + + )} + + + ); +}; + +export default App; \ No newline at end of file
{t.errors.title}
{error}