Dateien nach "competitor-analysis/components" hochladen
This commit is contained in:
48
competitor-analysis/components/EvidencePopover.tsx
Normal file
48
competitor-analysis/components/EvidencePopover.tsx
Normal 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;
|
||||||
97
competitor-analysis/components/InputForm.tsx
Normal file
97
competitor-analysis/components/InputForm.tsx
Normal 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;
|
||||||
18
competitor-analysis/components/LoadingSpinner.tsx
Normal file
18
competitor-analysis/components/LoadingSpinner.tsx
Normal 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;
|
||||||
131
competitor-analysis/components/Step1_Extraction.tsx
Normal file
131
competitor-analysis/components/Step1_Extraction.tsx
Normal 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;
|
||||||
38
competitor-analysis/components/Step2_Keywords.tsx
Normal file
38
competitor-analysis/components/Step2_Keywords.tsx
Normal 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;
|
||||||
Reference in New Issue
Block a user