Files
Brancheneinstufung2/b2b-marketing-assistant/App.tsx

389 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useCallback, useRef } from 'react';
import { GoogleGenAI, Chat } from "@google/genai";
import { InputForm } from './components/InputForm';
import { StepDisplay } from './components/StepDisplay';
import { LoadingSpinner, BotIcon, SparklesIcon, MarkdownIcon, PrintIcon } from './components/Icons';
import { ExportMenu } from './components/ExportMenu';
import { PROMPTS, translations } from './constants';
import type { AnalysisStep, AnalysisData, InputData } from './types';
import { parseGeminiStepResponse } from './services/parser';
import { generateMarkdown, downloadFile, tableToMarkdown } from './services/export';
const App: React.FC = () => {
const [inputData, setInputData] = useState<InputData>({
companyUrl: '',
language: 'de',
regions: '',
focus: '',
channels: ['LinkedIn', 'Kaltmail', 'Landingpage']
});
const [analysisData, setAnalysisData] = useState<Partial<AnalysisData>>({});
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isEnriching, setIsEnriching] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [generationStep, setGenerationStep] = useState<number>(0); // 0: idle, 1-6: step X is complete
const chatRef = useRef<Chat | null>(null);
const t = translations[inputData.language];
const STEP_TITLES = t.stepTitles;
const STEP_KEYS: (keyof AnalysisData)[] = ['offer', 'targetGroups', 'personas', 'painPoints', 'gains', 'messages'];
const handleStartGeneration = useCallback(async () => {
if (!inputData.companyUrl) {
setError('Bitte geben Sie eine Unternehmens-URL ein.');
return;
}
setIsLoading(true);
setError(null);
setAnalysisData({});
setGenerationStep(0);
try {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const currentPrompts = PROMPTS[inputData.language];
const newChat = ai.chats.create({
model: 'gemini-2.5-pro',
config: {
systemInstruction: currentPrompts.SYSTEM_PROMPT.replace('{{language}}', inputData.language)
}
});
chatRef.current = newChat;
let prompt = currentPrompts.STEP_PROMPTS[0];
prompt = prompt.replace('{{company_url}}', inputData.companyUrl);
prompt = prompt.replace('{{language}}', inputData.language);
prompt = prompt.replace('{{regions}}', inputData.regions);
prompt = prompt.replace('{{focus}}', inputData.focus);
const response = await newChat.sendMessage({ message: prompt });
const parsedData = parseGeminiStepResponse(response.text);
setAnalysisData(parsedData);
setGenerationStep(1);
} catch (e) {
console.error(e);
setError(e instanceof Error ? `Ein Fehler ist aufgetreten: ${e.message}` : 'Ein unbekannter Fehler ist aufgetreten.');
} finally {
setIsLoading(false);
}
}, [inputData]);
const handleGenerateNextStep = useCallback(async () => {
if (!chatRef.current || generationStep >= 6) return;
setIsLoading(true);
setError(null);
const nextStepIndex = generationStep;
try {
let context = '';
for (let i = 0; i < generationStep; i++) {
const stepKey = STEP_KEYS[i];
const stepObject = analysisData[stepKey];
if (stepObject) {
context += `\n\n## ${STEP_TITLES[stepKey]}\n\n`;
const summary = stepObject.summary && stepObject.summary.length > 0 ? `**${t.summaryTitle}**\n${stepObject.summary.map(s => `* ${s}`).join('\n')}\n\n` : '';
const table = tableToMarkdown(stepObject);
context += `${summary}${table}`;
}
}
const currentPrompts = PROMPTS[inputData.language];
let prompt = currentPrompts.STEP_PROMPTS[nextStepIndex];
prompt = prompt.replace('{{previous_steps_data}}', context);
if (nextStepIndex === 5) { // Step 6 is index 5
prompt = prompt.replace('{{channels}}', inputData.channels.join(', '));
}
const response = await chatRef.current.sendMessage({ message: prompt });
const parsedData = parseGeminiStepResponse(response.text);
setAnalysisData(prev => ({ ...prev, ...parsedData }));
setGenerationStep(prev => prev + 1);
} catch (e) {
console.error(e);
setError(e instanceof Error ? `Ein Fehler ist aufgetreten: ${e.message}` : 'Ein unbekannter Fehler ist aufgetreten.');
setGenerationStep(prev => prev); // Stay on the current step if error
} finally {
setIsLoading(false);
}
}, [analysisData, generationStep, inputData.channels, inputData.language, STEP_KEYS, STEP_TITLES, t.summaryTitle]);
const handleDataChange = <K extends keyof AnalysisData>(step: K, newData: AnalysisData[K]) => {
if (analysisData[step]) {
setAnalysisData(prev => prev ? { ...prev, [step]: newData } : { [step]: newData });
}
};
const getEnrichPrompt = (lang: 'de' | 'en', url: string, productName: string) => {
if (lang === 'en') {
return `# Task
Fill in the information for the following product/solution based on the website ${url}. Respond ONLY with the content for the remaining 4 columns, separated by '|||'.
# Product/Solution
${productName}
# Column Order
1. Description (12 sentences)
2. Core Features
3. Differentiation
4. Primary Source (URL)
# Important
Respond *only* with the text for the 4 columns, separated by '|||'. Do not output any headers or explanations.`;
}
// German (original)
return `# Aufgabe
Fülle die Informationen für das folgende Produkt/Lösung basierend auf der Webseite ${url} aus. Antworte NUR mit dem Inhalt für die restlichen 4 Spalten, getrennt durch '|||'.
# Produkt/Lösung
${productName}
# Spalten-Reihenfolge
1. Beschreibung (12 Sätze)
2. Kernfunktionen
3. Differenzierung
4. Primäre Quelle (URL)
# Wichtig
Antworte *nur* mit dem Text für die 4 Spalten, getrennt durch '|||'. Gib keine Überschriften oder Erklärungen aus.`;
};
const handleEnrichOfferRow = useCallback(async (productName: string, productUrl?: string) => {
if (!analysisData.offer) return;
setIsEnriching(true);
setError(null);
const loadingText = t.loadingButton.replace('...', '');
const placeholderRow = [productName, loadingText, loadingText, loadingText, loadingText];
setAnalysisData(prev => {
if (!prev.offer) return prev;
const currentOffer = prev.offer;
const updatedRows = [...currentOffer.rows, placeholderRow];
return {
...prev,
offer: {
...currentOffer,
rows: updatedRows,
}
};
});
try {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
// Use the specific product URL if provided, otherwise fallback to the main company URL
const targetUrl = productUrl && productUrl.trim() !== '' ? productUrl.trim() : inputData.companyUrl;
const enrichPrompt = getEnrichPrompt(inputData.language, targetUrl, productName);
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: enrichPrompt
});
const text = response.text || '';
const enrichedParts = text.split('|||').map(s => s.trim());
const finalEnrichedData = Array(4).fill('');
for (let i = 0; i < 4; i++) {
if (enrichedParts[i]) {
finalEnrichedData[i] = enrichedParts[i];
}
}
const newRow = [productName, ...finalEnrichedData];
setAnalysisData(prev => {
const offerData = prev.offer;
if (!offerData) return prev;
const finalRows = offerData.rows.map(row =>
row[0] === productName && row[1] === loadingText ? newRow : row
);
return { ...prev, offer: { ...offerData, rows: finalRows } };
});
} catch (e) {
console.error(e);
// On error, clear loading text so user can edit manually
setAnalysisData(prev => {
const offerData = prev.offer;
if (!offerData) return prev;
const emptyRow = [productName, '', '', '', ''];
const finalRows = offerData.rows.map(row =>
row[0] === productName && row[1] === loadingText ? emptyRow : row
);
return { ...prev, offer: { ...offerData, rows: finalRows } };
});
} finally {
setIsEnriching(false);
}
}, [analysisData.offer, inputData.companyUrl, inputData.language, t.loadingButton]);
const handleDownloadMarkdown = () => {
if (!analysisData) return;
const markdownContent = generateMarkdown(analysisData as AnalysisData, STEP_TITLES, t.summaryTitle);
downloadFile(markdownContent, 'b2b-marketing-analysis.md', 'text/markdown;charset=utf-8');
};
const handlePrint = () => {
window.print();
};
const renderStep = (stepKey: keyof AnalysisData, title: string) => {
const step = analysisData[stepKey] as AnalysisStep | undefined;
if (!step) return null;
const canDelete = ['offer', 'targetGroups', 'personas'].includes(stepKey);
return (
<StepDisplay
key={stepKey}
title={title}
summary={step.summary}
headers={step.headers}
rows={step.rows}
onDataChange={(newRows) => handleDataChange(stepKey, { ...step, rows: newRows })}
canAddRows={stepKey === 'offer'}
onEnrichRow={stepKey === 'offer' ? handleEnrichOfferRow : undefined}
isEnriching={isEnriching}
canDeleteRows={canDelete}
t={t}
/>
);
};
const renderContinueButton = (stepNumber: number) => {
if (isLoading || generationStep !== stepNumber - 1) return null;
return (
<div className="my-8 text-center print:hidden">
<button
onClick={handleGenerateNextStep}
className="flex items-center justify-center w-auto mx-auto px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:bg-slate-400 dark:disabled:bg-slate-600 disabled:cursor-not-allowed transition-colors duration-200"
>
<SparklesIcon className="mr-2 h-5 w-5" />
{t.continueButton.replace('{{step}}', (stepNumber - 1).toString()).replace('{{nextStep}}', stepNumber.toString())}
</button>
</div>
);
}
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 text-slate-800 dark:text-slate-200 font-sans">
<main className="container mx-auto px-4 py-8 md:py-12">
<header className="text-center mb-10 print:hidden">
<h1 className="text-4xl md:text-5xl font-extrabold text-slate-900 dark:text-white">
{t.appTitle}
</h1>
<p className="mt-4 text-lg text-slate-600 dark:text-slate-400">
{t.appSubtitle}
</p>
</header>
<div className="max-w-4xl mx-auto bg-white dark:bg-slate-800/50 rounded-2xl shadow-lg p-6 md:p-8 border border-slate-200 dark:border-slate-700 print:hidden">
<InputForm
inputData={inputData}
setInputData={setInputData}
onGenerate={handleStartGeneration}
isLoading={isLoading && generationStep === 0}
t={t}
/>
</div>
{error && (
<div className="max-w-4xl mx-auto mt-8 bg-red-100 dark:bg-red-900/50 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-200 px-4 py-3 rounded-lg print:hidden" role="alert">
<strong className="font-bold">{t.errorTitle} </strong>
<span className="block sm:inline">{error}</span>
</div>
)}
<div className="mt-12 space-y-12">
{isLoading && (
<div className="flex flex-col items-center justify-center text-center p-8">
<LoadingSpinner />
<p className="mt-4 text-lg text-slate-600 dark:text-slate-400 animate-pulse">
{t.generatingStep.replace('{{stepTitle}}', STEP_TITLES[STEP_KEYS[generationStep]])}
</p>
</div>
)}
{!isLoading && generationStep === 0 && !error && (
<div className="text-center p-8 bg-white dark:bg-slate-800/50 rounded-2xl shadow-md border border-slate-200 dark:border-slate-700 print:hidden">
<BotIcon className="mx-auto h-16 w-16 text-sky-500" />
<h3 className="mt-4 text-xl font-semibold text-slate-900 dark:text-white">{t.readyTitle}</h3>
<p className="mt-2 text-slate-600 dark:text-slate-400">{t.readyText}</p>
</div>
)}
{generationStep > 0 && (
<>
{generationStep >= 6 && (
<div className="flex justify-end mb-8 print:hidden">
<ExportMenu
onDownloadMarkdown={handleDownloadMarkdown}
onPrint={handlePrint}
t={t}
/>
</div>
)}
{renderStep('offer', STEP_TITLES.offer)}
{renderContinueButton(2)}
{generationStep >= 2 && renderStep('targetGroups', STEP_TITLES.targetGroups)}
{renderContinueButton(3)}
{generationStep >= 3 && renderStep('personas', STEP_TITLES.personas)}
{renderContinueButton(4)}
{generationStep >= 4 && renderStep('painPoints', STEP_TITLES.painPoints)}
{renderContinueButton(5)}
{generationStep >= 5 && renderStep('gains', STEP_TITLES.gains)}
{renderContinueButton(6)}
{generationStep >= 6 && renderStep('messages', STEP_TITLES.messages)}
{generationStep === 6 && !isLoading && (
<div className="p-8 bg-sky-50 dark:bg-sky-900/20 rounded-2xl border border-sky-100 dark:border-sky-800 text-center print:hidden">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-sky-100 dark:bg-sky-800 mb-4">
<SparklesIcon className="h-6 w-6 text-sky-600 dark:text-sky-300" />
</div>
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-2">
{t.analysisCompleteTitle}
</h3>
<p className="text-slate-600 dark:text-slate-300 mb-8 max-w-2xl mx-auto">
{t.analysisCompleteText.replace('{{otherLanguage}}', t.otherLanguage)}
</p>
<div className="flex flex-col sm:flex-row justify-center items-center gap-4">
<button
onClick={handleDownloadMarkdown}
className="w-full sm:w-auto flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white bg-slate-900 dark:bg-sky-600 hover:bg-slate-800 dark:hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500 shadow-md transition-all"
>
<MarkdownIcon className="mr-2 h-5 w-5" />
{t.exportAsMarkdown}
</button>
<button
onClick={handlePrint}
className="w-full sm:w-auto flex items-center justify-center px-6 py-3 border border-slate-300 dark:border-slate-600 text-base font-medium rounded-lg text-slate-700 dark:text-slate-200 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500 shadow-sm transition-all"
>
<PrintIcon className="mr-2 h-5 w-5" />
{t.exportAsPdf}
</button>
</div>
</div>
)}
</>
)}
</div>
</main>
</div>
);
};
export default App;