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.
This commit is contained in:
2026-01-10 09:10:00 +00:00
parent ff5184a26e
commit 8e5ab8949b
31 changed files with 3383 additions and 13 deletions

View File

@@ -0,0 +1,129 @@
import React, { useState } from 'react';
interface Item {
[key: string]: any;
}
interface FieldConfig {
key: string;
label: string;
type: 'text' | 'textarea';
}
interface EditableCardProps<T extends Item> {
title: string;
items: T[];
onItemsChange: (items: T[]) => void;
fieldConfigs: FieldConfig[];
newItemTemplate: T;
renderDisplay: (item: T, index: number) => React.ReactNode;
showAddButton?: boolean;
t: {
add: string;
cancel: string;
save: string;
}
}
const PencilIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.5L14.732 3.732z" /></svg>
);
const TrashIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
);
export const EditableCard = <T extends Item,>({ title, items, onItemsChange, fieldConfigs, newItemTemplate, renderDisplay, showAddButton, t }: EditableCardProps<T>) => {
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [editItem, setEditItem] = useState<T | null>(null);
const handleEdit = (index: number) => {
setEditingIndex(index);
setEditItem({ ...items[index] });
};
const handleSave = () => {
if (editingIndex !== null && editItem) {
const newItems = [...items];
newItems[editingIndex] = editItem;
onItemsChange(newItems);
setEditingIndex(null);
setEditItem(null);
}
};
const handleCancel = () => {
setEditingIndex(null);
setEditItem(null);
};
const handleRemove = (index: number) => {
onItemsChange(items.filter((_, i) => i !== index));
};
const handleAdd = () => {
onItemsChange([...items, newItemTemplate]);
setEditingIndex(items.length);
setEditItem(newItemTemplate);
};
const handleInputChange = (key: string, value: string) => {
if (editItem) {
setEditItem({ ...editItem, [key]: value });
}
};
const inputClasses = "w-full bg-light-secondary dark:bg-brand-secondary text-light-text dark:text-brand-text border border-light-accent dark:border-brand-accent rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-highlight";
return (
<div className="bg-light-secondary dark:bg-brand-secondary p-6 rounded-lg shadow-lg mb-6 border border-light-accent dark:border-brand-accent">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-bold">{title}</h3>
{(showAddButton ?? true) && (
<button onClick={handleAdd} className="bg-brand-accent hover:bg-brand-light text-white font-bold py-1 px-3 rounded-md text-sm transition-colors">+ {t.add}</button>
)}
</div>
<div className="space-y-4">
{items.map((item, index) => (
<div key={index} className="bg-light-primary dark:bg-brand-primary p-4 rounded-md">
{editingIndex === index && editItem ? (
<div className="space-y-3">
{fieldConfigs.map(field => (
<div key={field.key}>
<label className="block text-sm font-medium text-light-subtle dark:text-brand-light mb-1">{field.label}</label>
{field.type === 'textarea' ? (
<textarea
value={editItem[field.key]}
onChange={(e) => handleInputChange(field.key, e.target.value)}
className={inputClasses}
rows={3}
/>
) : (
<input
type="text"
value={editItem[field.key]}
onChange={(e) => handleInputChange(field.key, e.target.value)}
className={inputClasses}
/>
)}
</div>
))}
<div className="flex justify-end space-x-2 mt-2">
<button onClick={handleCancel} className="bg-gray-500 hover:bg-gray-600 text-white py-1 px-3 rounded-md text-sm transition-colors">{t.cancel}</button>
<button onClick={handleSave} className="bg-brand-highlight hover:bg-blue-600 text-white py-1 px-3 rounded-md text-sm transition-colors">{t.save}</button>
</div>
</div>
) : (
<div className="flex justify-between items-start">
<div className="flex-grow">{renderDisplay(item, index)}</div>
<div className="flex space-x-2 flex-shrink-0 ml-4">
<button onClick={() => handleEdit(index)} className="text-light-subtle dark:text-brand-light hover:text-light-text dark:hover:text-white p-1 rounded-full transition-colors"><PencilIcon /></button>
<button onClick={() => handleRemove(index)} className="text-light-subtle dark:text-brand-light hover:text-red-500 p-1 rounded-full transition-colors"><TrashIcon /></button>
</div>
</div>
)}
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,48 @@
import React, { useState } from 'react';
import type { Evidence } from '../types';
interface EvidencePopoverProps {
evidence: Evidence[];
}
const EvidencePopover: React.FC<EvidencePopoverProps> = ({ evidence }) => {
const [isOpen, setIsOpen] = useState(false);
if (!evidence || evidence.length === 0) {
return null;
}
return (
<div className="relative inline-block ml-2">
<button
onClick={(e) => { e.stopPropagation(); setIsOpen(!isOpen); }}
onBlur={() => setIsOpen(false)}
className="text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 focus:outline-none"
title="Show evidence"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clipRule="evenodd" />
</svg>
</button>
{isOpen && (
<div
onMouseDown={(e) => e.stopPropagation()} // Prevents onBlur from closing popover when clicking inside
className="absolute z-20 w-80 -right-2 mt-2 bg-light-secondary dark:bg-brand-secondary border border-light-accent dark:border-brand-accent rounded-lg shadow-xl p-3 text-sm">
<h5 className="font-bold mb-2 text-light-text dark:text-white">Evidence</h5>
<div className="space-y-2 max-h-60 overflow-y-auto">
{evidence.map((e, index) => (
<div key={index} className="border-t border-light-accent dark:border-brand-accent pt-2">
<p className="italic text-light-subtle dark:text-brand-light">"{e.snippet}"</p>
<a href={e.url} target="_blank" rel="noopener noreferrer" className="text-blue-500 dark:text-blue-400 hover:underline break-words">
{e.url}
</a>
</div>
))}
</div>
</div>
)}
</div>
);
};
export default EvidencePopover;

View File

@@ -0,0 +1,97 @@
import React, { useState } from 'react';
import { translations } from '../translations';
interface InputFormProps {
onStart: (startUrl: string, maxCompetitors: number, marketScope: string, language: 'de' | 'en') => void;
}
const InputForm: React.FC<InputFormProps> = ({ onStart }) => {
const [startUrl, setStartUrl] = useState('https://www.mobilexag.de');
const [maxCompetitors, setMaxCompetitors] = useState(12);
const [marketScope, setMarketScope] = useState('DACH');
const [language, setLanguage] = useState<'de' | 'en'>('de');
const t = translations[language];
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onStart(startUrl, maxCompetitors, marketScope, language);
};
const inputClasses = "w-full bg-light-primary dark:bg-brand-primary text-light-text dark:text-brand-text border border-light-accent dark:border-brand-accent rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-highlight placeholder-light-subtle dark:placeholder-brand-light";
return (
<div className="bg-light-secondary dark:bg-brand-secondary p-8 rounded-lg shadow-2xl max-w-2xl mx-auto">
<h2 className="text-3xl font-bold mb-6 text-center">{t.inputForm.title}</h2>
<p className="text-light-subtle dark:text-brand-light mb-8 text-center">{t.inputForm.subtitle}</p>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="start_url" className="block text-sm font-medium text-light-subtle dark:text-brand-light mb-2">{t.inputForm.startUrlLabel}</label>
<input
type="url"
id="start_url"
value={startUrl}
onChange={(e) => setStartUrl(e.target.value)}
className={inputClasses}
placeholder="https://www.example-company.com"
required
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="max_competitors" className="block text-sm font-medium text-light-subtle dark:text-brand-light mb-2">{t.inputForm.maxCompetitorsLabel}</label>
<input
type="number"
id="max_competitors"
value={maxCompetitors}
onChange={(e) => setMaxCompetitors(parseInt(e.target.value, 10))}
className={inputClasses}
min="1"
max="50"
/>
</div>
<div>
<label htmlFor="market_scope" className="block text-sm font-medium text-light-subtle dark:text-brand-light mb-2">{t.inputForm.marketScopeLabel}</label>
<input
type="text"
id="market_scope"
value={marketScope}
onChange={(e) => setMarketScope(e.target.value)}
className={inputClasses}
placeholder={t.inputForm.marketScopePlaceholder}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-light-subtle dark:text-brand-light mb-2">{t.inputForm.languageLabel}</label>
<div className="flex rounded-lg shadow-sm" role="group">
<button
type="button"
onClick={() => setLanguage('de')}
className={`px-4 py-2 text-sm font-medium rounded-l-lg w-full transition-colors ${language === 'de' ? 'bg-brand-highlight text-white' : 'bg-light-primary dark:bg-brand-primary text-light-text dark:text-brand-text hover:bg-light-accent dark:hover:bg-brand-accent'}`}
>
Deutsch
</button>
<button
type="button"
onClick={() => setLanguage('en')}
className={`px-4 py-2 text-sm font-medium rounded-r-lg w-full transition-colors ${language === 'en' ? 'bg-brand-highlight text-white' : 'bg-light-primary dark:bg-brand-primary text-light-text dark:text-brand-text hover:bg-light-accent dark:hover:bg-brand-accent'}`}
>
English
</button>
</div>
</div>
<div className="pt-4">
<button
type="submit"
className="w-full bg-brand-highlight hover:bg-blue-600 text-white font-bold py-3 px-4 rounded-lg shadow-lg transition duration-300 transform hover:scale-105"
>
{t.inputForm.submitButton}
</button>
</div>
</form>
</div>
);
};
export default InputForm;

View File

@@ -0,0 +1,18 @@
import React from 'react';
interface LoadingSpinnerProps {
t: {
message: string;
}
}
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({ t }) => {
return (
<div className="flex flex-col items-center justify-center p-10">
<div className="w-16 h-16 border-4 border-light-accent dark:border-brand-accent border-t-brand-highlight rounded-full animate-spin"></div>
<p className="mt-4 text-light-subtle dark:text-brand-light">{t.message}</p>
</div>
);
};
export default LoadingSpinner;

View File

@@ -0,0 +1,131 @@
import React, { useState } from 'react';
import type { Product, TargetIndustry } from '../types';
import { EditableCard } from './EditableCard';
import EvidencePopover from './EvidencePopover';
import { fetchProductDetails } from '../services/geminiService';
interface Step1ExtractionProps {
products: Product[];
industries: TargetIndustry[];
onProductsChange: (products: Product[]) => void;
onIndustriesChange: (industries: TargetIndustry[]) => void;
t: any;
lang: 'de' | 'en';
}
const Step1Extraction: React.FC<Step1ExtractionProps> = ({ products, industries, onProductsChange, onIndustriesChange, t, lang }) => {
const [newProductName, setNewProductName] = useState('');
const [newProductUrl, setNewProductUrl] = useState('');
const [isAdding, setIsAdding] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
const handleAddProduct = async (e: React.FormEvent) => {
e.preventDefault();
if (!newProductName || !newProductUrl) return;
setIsAdding(true);
setAddError(null);
try {
const newProduct = await fetchProductDetails(newProductName, newProductUrl, lang);
onProductsChange([...products, newProduct]);
setNewProductName('');
setNewProductUrl('');
} catch (error) {
console.error("Failed to add product:", error);
setAddError(t.addProductError);
} finally {
setIsAdding(false);
}
};
const inputClasses = "w-full bg-light-primary dark:bg-brand-primary text-light-text dark:text-brand-text border border-light-accent dark:border-brand-accent rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-highlight placeholder-light-subtle dark:placeholder-brand-light";
return (
<div>
<h2 className="text-2xl font-bold mb-4">{t.title}</h2>
<p className="text-light-subtle dark:text-brand-light mb-6">{t.subtitle}</p>
<div className="bg-light-secondary dark:bg-brand-secondary p-6 rounded-lg shadow-lg mb-6 border border-light-accent dark:border-brand-accent">
<h3 className="text-xl font-bold mb-4">{t.addProductTitle}</h3>
<form onSubmit={handleAddProduct} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="product_name" className="block text-sm font-medium text-light-subtle dark:text-brand-light mb-1">{t.productNameLabel}</label>
<input
id="product_name"
type="text"
value={newProductName}
onChange={(e) => setNewProductName(e.target.value)}
className={inputClasses}
placeholder={t.productNamePlaceholder}
required
/>
</div>
<div>
<label htmlFor="product_url" className="block text-sm font-medium text-light-subtle dark:text-brand-light mb-1">{t.productUrlLabel}</label>
<input
id="product_url"
type="url"
value={newProductUrl}
onChange={(e) => setNewProductUrl(e.target.value)}
className={inputClasses}
placeholder="https://..."
required
/>
</div>
</div>
{addError && <p className="text-red-500 text-sm">{addError}</p>}
<div className="text-right">
<button
type="submit"
disabled={isAdding}
className="bg-brand-accent hover:bg-brand-light text-white font-bold py-2 px-4 rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isAdding ? t.addingButton : t.addButton}
</button>
</div>
</form>
</div>
<EditableCard<Product>
title={t.productsTitle}
items={products}
onItemsChange={onProductsChange}
showAddButton={false}
fieldConfigs={[
{ key: 'name', label: t.productNameLabel, type: 'text' },
{ key: 'purpose', label: t.purposeLabel, type: 'textarea' },
]}
newItemTemplate={{ name: '', purpose: '', evidence: [] }}
renderDisplay={(item) => (
<div>
<div className="flex items-center">
<strong className="text-light-text dark:text-white">{item.name}</strong>
<EvidencePopover evidence={item.evidence} />
</div>
<p className="text-light-subtle dark:text-brand-light text-sm mt-1">{item.purpose}</p>
</div>
)}
t={t.editableCard}
/>
<EditableCard<TargetIndustry>
title={t.industriesTitle}
items={industries}
onItemsChange={onIndustriesChange}
fieldConfigs={[{ key: 'name', label: t.industryNameLabel, type: 'text' }]}
newItemTemplate={{ name: '', evidence: [] }}
renderDisplay={(item) => (
<div className="flex items-center">
<strong className="text-light-text dark:text-white">{item.name}</strong>
<EvidencePopover evidence={item.evidence} />
</div>
)}
t={t.editableCard}
/>
</div>
);
};
export default Step1Extraction;

View File

@@ -0,0 +1,38 @@
import React from 'react';
import type { Keyword } from '../types';
import { EditableCard } from './EditableCard';
interface Step2KeywordsProps {
keywords: Keyword[];
onKeywordsChange: (keywords: Keyword[]) => void;
t: any;
}
const Step2Keywords: React.FC<Step2KeywordsProps> = ({ keywords, onKeywordsChange, t }) => {
return (
<div>
<h2 className="text-2xl font-bold mb-4">{t.title}</h2>
<p className="text-light-subtle dark:text-brand-light mb-6">{t.subtitle}</p>
<EditableCard<Keyword>
title={t.cardTitle}
items={keywords}
onItemsChange={onKeywordsChange}
fieldConfigs={[
{ key: 'term', label: t.termLabel, type: 'text' },
{ key: 'rationale', label: t.rationaleLabel, type: 'textarea' },
]}
newItemTemplate={{ term: '', rationale: '' }}
renderDisplay={(item) => (
<div>
<strong className="text-light-text dark:text-white">{item.term}</strong>
<p className="text-light-subtle dark:text-brand-light text-sm mt-1">{item.rationale}</p>
</div>
)}
t={t.editableCard}
/>
</div>
);
};
export default Step2Keywords;

View File

@@ -0,0 +1,57 @@
import React from 'react';
import type { CompetitorCandidate } from '../types';
import EvidencePopover from './EvidencePopover';
import { EditableCard } from './EditableCard';
interface Step3CompetitorsProps {
candidates: CompetitorCandidate[];
onCandidatesChange: (candidates: CompetitorCandidate[]) => void;
maxCompetitors: number;
t: any;
}
const Step3Competitors: React.FC<Step3CompetitorsProps> = ({ candidates, onCandidatesChange, maxCompetitors, t }) => {
const sortedCandidates = [...candidates].sort((a, b) => b.confidence - a.confidence);
return (
<div>
<h2 className="text-2xl font-bold mb-4">{t.title}</h2>
<p className="text-light-subtle dark:text-brand-light mb-6">
{t.subtitle(maxCompetitors)}
</p>
<EditableCard<CompetitorCandidate>
title={t.cardTitle}
items={sortedCandidates}
onItemsChange={onCandidatesChange}
fieldConfigs={[
{ key: 'name', label: t.nameLabel, type: 'text' },
{ key: 'url', label: 'URL', type: 'text' },
{ key: 'why', label: t.whyLabel, type: 'textarea' },
]}
newItemTemplate={{ name: '', url: '', confidence: 0.8, why: '', evidence: [] }}
renderDisplay={(item, index) => (
<div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<strong className={`text-light-text dark:text-white ${index < maxCompetitors ? 'text-green-600 dark:text-green-400' : ''}`}>{item.name}</strong>
<a href={item.url} target="_blank" rel="noopener noreferrer" className="ml-2 text-blue-500 dark:text-blue-400 hover:underline text-sm">
{t.visitButton}
</a>
<EvidencePopover evidence={item.evidence} />
</div>
<span className="text-xs font-mono bg-light-accent dark:bg-brand-accent text-light-text dark:text-white py-1 px-2 rounded-full">
{(item.confidence * 100).toFixed(0)}%
</span>
</div>
<p className="text-light-subtle dark:text-brand-light text-sm mt-1">{item.why}</p>
{index === maxCompetitors - 1 && <div className="border-t-2 border-dashed border-red-500 mt-4 pt-2 text-red-500 dark:text-red-400 text-xs text-center font-bold">{t.shortlistBoundary}</div>}
</div>
)}
t={t.editableCard}
/>
</div>
);
};
export default Step3Competitors;

View File

@@ -0,0 +1,97 @@
import React from 'react';
import type { Analysis } from '../types';
import EvidencePopover from './EvidencePopover';
interface Step4AnalysisProps {
analyses: Analysis[];
t: any;
}
const DownloadIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" 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 downloadJSON = (data: any, filename: string) => {
const jsonStr = JSON.stringify(data, null, 2);
const blob = new Blob([jsonStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const OverlapBar: React.FC<{ score: number }> = ({ score }) => (
<div className="w-full bg-light-accent dark:bg-brand-accent rounded-full h-2.5">
<div className="bg-brand-highlight h-2.5 rounded-full" style={{ width: `${score}%` }}></div>
</div>
);
const Step4Analysis: React.FC<Step4AnalysisProps> = ({ analyses, t }) => {
const sortedAnalyses = [...analyses].sort((a, b) => b.overlap_score - a.overlap_score);
return (
<div>
<h2 className="text-2xl font-bold mb-4">{t.title}</h2>
<p className="text-light-subtle dark:text-brand-light mb-6">
{t.subtitle}
</p>
<div className="space-y-6">
{sortedAnalyses.map((analysis, index) => (
<div key={index} className="bg-light-secondary dark:bg-brand-secondary p-6 rounded-lg shadow-lg">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl font-bold text-light-text dark:text-white">{analysis.competitor.name}</h3>
<a href={analysis.competitor.url} target="_blank" rel="noopener noreferrer" className="text-blue-500 dark:text-blue-400 hover:underline text-sm">
{analysis.competitor.url}
</a>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => downloadJSON(analysis, `analysis_${analysis.competitor.name.replace(/ /g, '_')}.json`)}
title={t.downloadJson_title}
className="text-light-subtle dark:text-brand-light hover:text-light-text dark:hover:text-white p-1 rounded-full"
>
<DownloadIcon />
</button>
<EvidencePopover evidence={analysis.evidence} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="font-semibold mb-2">{t.portfolio}</h4>
<ul className="list-disc list-inside text-light-subtle dark:text-brand-light text-sm space-y-1">
{analysis.portfolio.map((p, i) => <li key={i}><strong>{p.product}:</strong> {p.purpose}</li>)}
</ul>
</div>
<div>
<h4 className="font-semibold mb-2">{t.differentiators}</h4>
<ul className="list-disc list-inside text-light-subtle dark:text-brand-light text-sm space-y-1">
{analysis.differentiators.map((d, i) => <li key={i}>{d}</li>)}
</ul>
</div>
<div>
<h4 className="font-semibold mb-2">{t.targetIndustries}</h4>
<div className="flex flex-wrap gap-2 mb-2">
{analysis.target_industries.map((ind, i) => (
<span key={i} className="bg-light-accent dark:bg-brand-accent text-xs font-medium px-2.5 py-0.5 rounded-full">{ind}</span>
))}
</div>
<span className="bg-gray-500 dark:bg-gray-600 text-white text-xs font-medium px-2.5 py-0.5 rounded-full">{analysis.delivery_model}</span>
</div>
<div>
<h4 className="font-semibold mb-2">{t.overlapScore}: {analysis.overlap_score}%</h4>
<OverlapBar score={analysis.overlap_score} />
</div>
</div>
</div>
))}
</div>
</div>
);
};
export default Step4Analysis;

View File

@@ -0,0 +1,44 @@
import React from 'react';
import type { SilverBullet } from '../types';
interface Step5SilverBulletsProps {
silver_bullets: SilverBullet[];
t: any;
}
const Step5SilverBullets: React.FC<Step5SilverBulletsProps> = ({ silver_bullets, t }) => {
if (!silver_bullets || silver_bullets.length === 0) {
return (
<div>
<h2 className="text-2xl font-bold mb-4">{t.title}</h2>
<p className="text-light-subtle dark:text-brand-light">{t.generating}</p>
</div>
);
}
return (
<div>
<h2 className="text-2xl font-bold mb-4">{t.title}</h2>
<p className="text-light-subtle dark:text-brand-light mb-6">
{t.subtitle}
</p>
<div className="space-y-4">
{silver_bullets.map((bullet, index) => (
<div key={index} className="bg-light-secondary dark:bg-brand-secondary p-6 rounded-lg shadow-lg">
<h3 className="text-lg font-semibold text-light-text dark:text-white mb-2">
{t.cardTitle} <span className="text-brand-highlight">{bullet.competitor_name}</span>
</h3>
<blockquote className="border-l-4 border-brand-highlight pl-4">
<p className="text-lg italic text-light-subtle dark:text-brand-light">
"{bullet.statement}"
</p>
</blockquote>
</div>
))}
</div>
</div>
);
};
export default Step5SilverBullets;

View File

@@ -0,0 +1,148 @@
import React from 'react';
import type { AppState } from '../types';
interface Step6ConclusionProps {
appState: AppState | null;
t: any;
}
const MatrixTable: React.FC<{ data: { [row: string]: { [col: string]: string } }, title: string }> = ({ data, title }) => {
if (!data || Object.keys(data).length === 0) {
return <p>No data available for {title}.</p>;
}
// Robustly get all unique column headers from all rows
const columnSet = new Set<string>();
Object.values(data).forEach(rowData => {
if (rowData && typeof rowData === 'object') {
Object.keys(rowData).forEach(col => columnSet.add(col));
}
});
const columns = Array.from(columnSet).sort();
const rows = Object.keys(data);
return (
<div className="bg-light-secondary dark:bg-brand-secondary p-4 rounded-lg">
<h3 className="text-lg font-bold mb-3">{title}</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-light-accent dark:bg-brand-accent uppercase">
<tr>
<th scope="col" className="px-4 py-2"></th>
{columns.map(col => <th key={col} scope="col" className="px-4 py-2 text-center whitespace-nowrap">{col}</th>)}
</tr>
</thead>
<tbody>
{rows.map(row => (
<tr key={row} className="border-b border-light-accent dark:border-brand-accent">
<th scope="row" className="px-4 py-2 font-medium whitespace-nowrap">{row}</th>
{columns.map(col => {
// Safely access cell data to prevent crashes on malformed data
const cellData = data[row]?.[col] || ' ';
return (
<td key={col} className="px-4 py-2 text-center text-green-600 dark:text-green-400 font-bold">
{cellData}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
type ProductMatrixItem = { product: string; availability: { competitor: string; has_offering: boolean }[] };
type IndustryMatrixItem = { industry: string; availability: { competitor: string; has_offering: boolean }[] };
const transformMatrixData = (matrixData: (ProductMatrixItem[] | IndustryMatrixItem[]) | 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 Step6Conclusion: React.FC<Step6ConclusionProps> = ({ appState, t }) => {
if (!appState || !appState.conclusion) return <p>{t.generating}</p>;
const { conclusion, company } = appState;
const productMatrixForTable = transformMatrixData(conclusion.product_matrix);
const industryMatrixForTable = transformMatrixData(conclusion.industry_matrix);
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold">{t.title}</h2>
</div>
<p className="text-light-subtle dark:text-brand-light mb-6">{t.subtitle}</p>
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<MatrixTable data={productMatrixForTable} title={t.productMatrix} />
<MatrixTable data={industryMatrixForTable} title={t.industryMatrix} />
</div>
<div className="bg-light-secondary dark:bg-brand-secondary p-4 rounded-lg">
<h3 className="text-lg font-bold mb-3">{t.summary}</h3>
<p className="text-light-subtle dark:text-brand-light">{conclusion.summary}</p>
</div>
<div className="bg-light-secondary dark:bg-brand-secondary p-4 rounded-lg">
<h3 className="text-lg font-bold mb-3">{t.opportunities(company.name)}</h3>
<p className="text-light-subtle dark:text-brand-light">{conclusion.opportunities}</p>
</div>
<div className="bg-light-secondary dark:bg-brand-secondary p-4 rounded-lg">
<h3 className="text-lg font-bold mb-3">{t.nextQuestions}</h3>
<ul className="list-disc list-inside text-light-subtle dark:text-brand-light space-y-1">
{(conclusion.next_questions || []).map((q, i) => <li key={i}>{q}</li>)}
</ul>
</div>
</div>
</div>
);
};
export default Step6Conclusion;

View File

@@ -0,0 +1,91 @@
import React, { useState } from 'react';
import type { AppState, Battlecard } from '../types';
interface Step7BattlecardsProps {
appState: AppState | null;
t: any;
}
const ProfileIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2 inline-block" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" /></svg>);
const StrengthsIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2 inline-block" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L9 9.414V13a1 1 0 102 0V9.414l1.293 1.293a1 1 0 001.414-1.414z" clipRule="evenodd" /></svg>);
const LandmineIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2 inline-block" viewBox="0 0 20 20" fill="currentColor"><path d="M13.477 14.89A6 6 0 015.11 6.524l8.367 8.367zM18 10a8 8 0 11-16 0 8 8 0 0116 0z" /></svg>);
const BulletIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2 inline-block" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM7 9a1 1 0 000 2h6a1 1 0 100-2H7z" clipRule="evenodd" /></svg>);
const BattlecardComponent: React.FC<{ battlecard: Battlecard, t: any }> = ({ battlecard, t }) => {
return (
<div className="bg-light-secondary dark:bg-brand-secondary p-6 rounded-lg shadow-lg relative">
<div className="space-y-6">
<div>
<h3 className="text-xl font-bold flex items-center mb-2"><ProfileIcon /> {t.profile}</h3>
<p className="text-light-subtle dark:text-brand-light pl-7">{battlecard.competitor_profile.focus}</p>
<p className="text-light-subtle dark:text-brand-light pl-7 mt-1">{battlecard.competitor_profile.positioning}</p>
</div>
<div>
<h3 className="text-xl font-bold flex items-center mb-2"><StrengthsIcon /> {t.strengths}</h3>
<ul className="list-disc list-inside space-y-1 text-light-subtle dark:text-brand-light pl-7">
{(battlecard.strengths_vs_weaknesses || []).map((item, i) => <li key={i}>{item}</li>)}
</ul>
</div>
<div>
<h3 className="text-xl font-bold flex items-center mb-2"><LandmineIcon /> {t.landmines}</h3>
<ul className="list-disc list-inside space-y-1 text-light-subtle dark:text-brand-light pl-7">
{(battlecard.landmine_questions || []).map((item, i) => <li key={i}>{item}</li>)}
</ul>
</div>
<div>
<h3 className="text-xl font-bold flex items-center mb-2"><BulletIcon /> {t.silverBullet}</h3>
<blockquote className="border-l-4 border-brand-highlight pl-4 ml-7">
<p className="text-lg italic text-light-subtle dark:text-brand-light">"{battlecard.silver_bullet}"</p>
</blockquote>
</div>
</div>
</div>
);
}
const Step7_Battlecards: React.FC<Step7BattlecardsProps> = ({ appState, t }) => {
const [activeTab, setActiveTab] = useState(0);
if (!appState || !appState.battlecards || appState.battlecards.length === 0) {
return (
<div>
<h2 className="text-2xl font-bold mb-4">{t.title}</h2>
<p className="text-light-subtle dark:text-brand-light">{t.generating}</p>
</div>
);
}
const { battlecards } = appState;
const activeBattlecard = battlecards[activeTab];
return (
<div>
<h2 className="text-2xl font-bold mb-4">{t.title}</h2>
<p className="text-light-subtle dark:text-brand-light mb-6">
{t.subtitle}
</p>
<div className="flex border-b border-light-accent dark:border-brand-accent mb-4 overflow-x-auto">
{battlecards.map((card, index) => (
<button
key={index}
onClick={() => setActiveTab(index)}
className={`py-2 px-4 font-semibold text-sm focus:outline-none whitespace-nowrap ${
activeTab === index
? 'border-b-2 border-brand-highlight text-light-text dark:text-white'
: 'text-light-subtle dark:text-brand-light hover:bg-light-accent dark:hover:bg-brand-accent'
}`}
>
{card.competitor_name}
</button>
))}
</div>
{activeBattlecard && <BattlecardComponent battlecard={activeBattlecard} t={t.card} />}
</div>
);
};
export default Step7_Battlecards;

View File

@@ -0,0 +1,81 @@
import React from 'react';
import type { ReferenceAnalysis } from '../types';
interface Step8ReferencesProps {
analyses: ReferenceAnalysis[];
groundingMetadata: any[];
t: any;
}
const LinkIcon = () => (<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 inline-block ml-1" viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clipRule="evenodd" /></svg>);
const Step8_References: React.FC<Step8ReferencesProps> = ({ analyses, groundingMetadata, t }) => {
return (
<div>
<h2 className="text-2xl font-bold mb-4">{t.title}</h2>
<p className="text-light-subtle dark:text-brand-light mb-6">
{t.subtitle}
</p>
<div className="space-y-6">
{(analyses || []).map((analysis, index) => {
// This robust check prevents crashes if `references` is null, undefined, or not an array.
const hasReferences = Array.isArray(analysis.references) && analysis.references.length > 0;
return (
<div key={index} className="bg-light-secondary dark:bg-brand-secondary p-6 rounded-lg shadow-lg">
<h3 className="text-xl font-bold text-light-text dark:text-white mb-4">{analysis.competitor_name}</h3>
{!hasReferences ? (
<p className="text-light-subtle dark:text-brand-light">{t.noReferencesFound}</p>
) : (
<div className="space-y-4">
{analysis.references.map((ref, refIndex) => (
<div key={refIndex} className="bg-light-primary dark:bg-brand-primary p-4 rounded-md border-l-4 border-brand-accent">
<div className="flex justify-between items-start">
<div>
<strong className="text-light-text dark:text-white">{ref.name}</strong>
{ref.industry && <span className="text-xs ml-2 bg-light-accent dark:bg-brand-accent px-2 py-0.5 rounded-full">{ref.industry}</span>}
</div>
{ref.case_study_url && (
<a href={ref.case_study_url} target="_blank" rel="noopener noreferrer" className="text-sm text-blue-500 dark:text-blue-400 hover:underline flex-shrink-0">
{t.caseStudyLink} <LinkIcon/>
</a>
)}
</div>
{ref.testimonial_snippet && (
<blockquote className="mt-2 text-sm italic text-light-subtle dark:text-brand-light border-l-2 border-gray-400 pl-3">
"{ref.testimonial_snippet}"
</blockquote>
)}
</div>
))}
</div>
)}
</div>
);
})}
</div>
{groundingMetadata && groundingMetadata.length > 0 && (
<div className="mt-8 bg-light-secondary dark:bg-brand-secondary p-6 rounded-lg shadow-lg">
<h3 className="text-xl font-bold text-light-text dark:text-white mb-4">{t.sourcesTitle}</h3>
<ul className="list-disc list-inside space-y-2">
{groundingMetadata
.filter(chunk => chunk.web && chunk.web.uri)
.map((chunk, index) => (
<li key={index} className="text-sm">
<a href={chunk.web.uri} target="_blank" rel="noopener noreferrer" className="text-blue-500 dark:text-blue-400 hover:underline">
{chunk.web.title || chunk.web.uri}
</a>
</li>
))}
</ul>
</div>
)}
</div>
);
};
export default Step8_References;

View File

@@ -0,0 +1,50 @@
import React from 'react';
interface StepIndicatorProps {
currentStep: number;
highestStep: number;
onStepClick: (step: number) => void;
t: {
title: string;
steps: string[];
}
}
const StepIndicator: React.FC<StepIndicatorProps> = ({ currentStep, highestStep, onStepClick, t }) => {
const steps = t.steps.map((name, index) => ({ id: index + 1, name }));
return (
<div className="bg-light-secondary dark:bg-brand-secondary p-4 rounded-lg shadow-md border border-light-accent dark:border-brand-accent">
<h3 className="font-bold text-lg mb-4">{t.title}</h3>
<ol className="space-y-3">
{steps.map((step) => {
const isCompleted = step.id < currentStep;
const isActive = step.id === currentStep;
const isClickable = step.id <= highestStep;
return (
<li
key={step.id}
className={`flex items-center p-1 rounded-md transition-colors ${isClickable ? 'cursor-pointer hover:bg-light-accent dark:hover:bg-brand-accent' : 'cursor-default'}`}
onClick={() => isClickable && onStepClick(step.id)}
>
<span className={`flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full mr-3 text-sm font-bold ${
isCompleted ? 'bg-green-500 text-white' :
isActive ? 'bg-brand-highlight text-white ring-2 ring-offset-2 ring-offset-light-secondary dark:ring-offset-brand-secondary ring-brand-highlight' :
'bg-light-accent dark:bg-brand-accent text-light-text dark:text-brand-text'
}`}>
{isCompleted ? '✓' : step.id}
</span>
<span className={`font-medium ${isActive ? 'text-light-text dark:text-white' : 'text-light-subtle dark:text-brand-light'}`}>
{step.name}
</span>
</li>
)
})}
</ol>
</div>
);
};
export default StepIndicator;