diff --git a/competitor-analysis/components/EvidencePopover.tsx b/competitor-analysis/components/EvidencePopover.tsx new file mode 100644 index 00000000..38a56c69 --- /dev/null +++ b/competitor-analysis/components/EvidencePopover.tsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react'; +import type { Evidence } from '../types'; + +interface EvidencePopoverProps { + evidence: Evidence[]; +} + +const EvidencePopover: React.FC = ({ evidence }) => { + const [isOpen, setIsOpen] = useState(false); + + if (!evidence || evidence.length === 0) { + return null; + } + + return ( +
+ + {isOpen && ( +
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"> +
Evidence
+
+ {evidence.map((e, index) => ( +
+

"{e.snippet}"

+ + {e.url} + +
+ ))} +
+
+ )} +
+ ); +}; + +export default EvidencePopover; \ No newline at end of file diff --git a/competitor-analysis/components/InputForm.tsx b/competitor-analysis/components/InputForm.tsx new file mode 100644 index 00000000..1279513d --- /dev/null +++ b/competitor-analysis/components/InputForm.tsx @@ -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 = ({ 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 ( +
+

{t.inputForm.title}

+

{t.inputForm.subtitle}

+
+
+ + setStartUrl(e.target.value)} + className={inputClasses} + placeholder="https://www.example-company.com" + required + /> +
+
+
+ + setMaxCompetitors(parseInt(e.target.value, 10))} + className={inputClasses} + min="1" + max="50" + /> +
+
+ + setMarketScope(e.target.value)} + className={inputClasses} + placeholder={t.inputForm.marketScopePlaceholder} + /> +
+
+
+ +
+ + +
+
+
+ +
+
+
+ ); +}; + +export default InputForm; \ No newline at end of file diff --git a/competitor-analysis/components/LoadingSpinner.tsx b/competitor-analysis/components/LoadingSpinner.tsx new file mode 100644 index 00000000..c0dfa0e5 --- /dev/null +++ b/competitor-analysis/components/LoadingSpinner.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +interface LoadingSpinnerProps { + t: { + message: string; + } +} + +const LoadingSpinner: React.FC = ({ t }) => { + return ( +
+
+

{t.message}

+
+ ); +}; + +export default LoadingSpinner; \ No newline at end of file diff --git a/competitor-analysis/components/Step1_Extraction.tsx b/competitor-analysis/components/Step1_Extraction.tsx new file mode 100644 index 00000000..bd9efda4 --- /dev/null +++ b/competitor-analysis/components/Step1_Extraction.tsx @@ -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 = ({ products, industries, onProductsChange, onIndustriesChange, t, lang }) => { + const [newProductName, setNewProductName] = useState(''); + const [newProductUrl, setNewProductUrl] = useState(''); + const [isAdding, setIsAdding] = useState(false); + const [addError, setAddError] = useState(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 ( +
+

{t.title}

+

{t.subtitle}

+ +
+

{t.addProductTitle}

+
+
+
+ + setNewProductName(e.target.value)} + className={inputClasses} + placeholder={t.productNamePlaceholder} + required + /> +
+
+ + setNewProductUrl(e.target.value)} + className={inputClasses} + placeholder="https://..." + required + /> +
+
+ {addError &&

{addError}

} +
+ +
+
+
+ + + + 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) => ( +
+
+ {item.name} + +
+

{item.purpose}

+
+ )} + t={t.editableCard} + /> + + + title={t.industriesTitle} + items={industries} + onItemsChange={onIndustriesChange} + fieldConfigs={[{ key: 'name', label: t.industryNameLabel, type: 'text' }]} + newItemTemplate={{ name: '', evidence: [] }} + renderDisplay={(item) => ( +
+ {item.name} + +
+ )} + t={t.editableCard} + /> +
+ ); +}; + +export default Step1Extraction; \ No newline at end of file diff --git a/competitor-analysis/components/Step2_Keywords.tsx b/competitor-analysis/components/Step2_Keywords.tsx new file mode 100644 index 00000000..6a2880af --- /dev/null +++ b/competitor-analysis/components/Step2_Keywords.tsx @@ -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 = ({ keywords, onKeywordsChange, t }) => { + return ( +
+

{t.title}

+

{t.subtitle}

+ + + 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) => ( +
+ {item.term} +

{item.rationale}

+
+ )} + t={t.editableCard} + /> +
+ ); +}; + +export default Step2Keywords; \ No newline at end of file