Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 13s
195 lines
9.0 KiB
TypeScript
195 lines
9.0 KiB
TypeScript
|
|
import React, { useState, useCallback } from 'react';
|
|
import { AnalysisType, FlyerItem, Store } from '../types';
|
|
import type { GroundingChunk } from '@google/genai';
|
|
import { getQuickInsights, getDeepDiveAnalysis, searchWeb, planTripWithMaps, generateImageFromText } from '../services/geminiService';
|
|
import { LoadingSpinner } from './LoadingSpinner';
|
|
import { LightbulbIcon } from './icons/LightbulbIcon';
|
|
import { BrainIcon } from './icons/BrainIcon';
|
|
import { SearchIcon } from './icons/SearchIcon';
|
|
import { MapPinIcon } from './icons/MapPinIcon';
|
|
import { PhotoIcon } from './icons/PhotoIcon';
|
|
|
|
interface AnalysisPanelProps {
|
|
flyerItems: FlyerItem[];
|
|
store?: Store;
|
|
}
|
|
|
|
interface Source {
|
|
uri: string;
|
|
title: string;
|
|
}
|
|
|
|
interface TabButtonProps {
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
isActive: boolean;
|
|
onClick: () => void;
|
|
}
|
|
|
|
const TabButton: React.FC<TabButtonProps> = ({ label, icon, isActive, onClick }) => {
|
|
const activeClasses = 'bg-brand-primary text-white';
|
|
const inactiveClasses = 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600';
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={`flex-1 flex items-center justify-center space-x-2 px-4 py-2.5 text-sm font-medium rounded-md transition-colors duration-200 ${isActive ? activeClasses : inactiveClasses}`}
|
|
>
|
|
{icon}
|
|
<span>{label}</span>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ flyerItems, store }) => {
|
|
const [activeTab, setActiveTab] = useState<AnalysisType>(AnalysisType.QUICK_INSIGHTS);
|
|
const [results, setResults] = useState<{ [key in AnalysisType]?: string }>({});
|
|
const [sources, setSources] = useState<Source[]>([]);
|
|
const [loadingStates, setLoadingStates] = useState<{ [key in AnalysisType]?: boolean }>({});
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// State for new feature stubs
|
|
const [generatedImageUrl, setGeneratedImageUrl] = useState<string | null>(null);
|
|
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
|
|
|
|
const handleAnalysis = useCallback(async (type: AnalysisType) => {
|
|
setLoadingStates(prev => ({ ...prev, [type]: true }));
|
|
setError(null);
|
|
setGeneratedImageUrl(null); // Clear generated image on new analysis
|
|
// Clear sources if the new tab doesn't generate them
|
|
if (type !== AnalysisType.WEB_SEARCH && type !== AnalysisType.PLAN_TRIP) {
|
|
setSources([]);
|
|
}
|
|
try {
|
|
let responseText = '';
|
|
let newSources: Source[] = [];
|
|
if (type === AnalysisType.QUICK_INSIGHTS) {
|
|
responseText = await getQuickInsights(flyerItems);
|
|
} else if (type === AnalysisType.DEEP_DIVE) {
|
|
responseText = await getDeepDiveAnalysis(flyerItems);
|
|
} else if (type === AnalysisType.WEB_SEARCH) {
|
|
const { text, sources } = await searchWeb(flyerItems);
|
|
const mappedSources: Source[] = sources.map((s: GroundingChunk) => ({
|
|
uri: s.web?.uri || '',
|
|
title: s.web?.title || 'Untitled Source'
|
|
}));
|
|
responseText = text;
|
|
newSources = mappedSources;
|
|
} else if (type === AnalysisType.PLAN_TRIP) {
|
|
const userLocation = await new Promise<GeolocationCoordinates>((resolve, reject) => {
|
|
navigator.geolocation.getCurrentPosition(
|
|
(position) => resolve(position.coords),
|
|
(err: GeolocationPositionError) => reject(err) // Type the error for better handling
|
|
);
|
|
});
|
|
const { text, sources } = await planTripWithMaps(flyerItems, store, userLocation);
|
|
responseText = text;
|
|
newSources = sources;
|
|
}
|
|
setResults(prev => ({ ...prev, [type]: responseText }));
|
|
setSources(newSources); // Update sources once after all logic
|
|
} catch (e) { // Type 'e' is implicitly 'unknown'
|
|
console.error(`Analysis failed for type ${type}:`, e);
|
|
let userFriendlyMessage = `Failed to get ${type.replace('_', ' ')}. Please try again.`;
|
|
if (e instanceof GeolocationPositionError && e.code === GeolocationPositionError.PERMISSION_DENIED) {
|
|
userFriendlyMessage = "Please allow location access to use this feature.";
|
|
} else if (e instanceof Error) {
|
|
userFriendlyMessage = e.message; // Use specific error message if it's a standard Error
|
|
}
|
|
setError(userFriendlyMessage);
|
|
} finally {
|
|
setLoadingStates(prev => ({ ...prev, [type]: false }));
|
|
}
|
|
}, [flyerItems, store]);
|
|
|
|
const handleGenerateImage = useCallback(async () => {
|
|
const mealPlanText = results[AnalysisType.DEEP_DIVE];
|
|
if (!mealPlanText) return;
|
|
|
|
setIsGeneratingImage(true);
|
|
try {
|
|
const base64Image = await generateImageFromText(mealPlanText);
|
|
setGeneratedImageUrl(`data:image/png;base64,${base64Image}`);
|
|
} catch (e) {
|
|
const errorMessage = e instanceof Error ? e.message : 'An unknown error occurred.';
|
|
setError(`Failed to generate image: ${errorMessage}`);
|
|
} finally {
|
|
setIsGeneratingImage(false);
|
|
}
|
|
}, [results]);
|
|
|
|
const renderContent = () => {
|
|
if (loadingStates[activeTab]) {
|
|
return <div className="flex justify-center items-center h-48"><LoadingSpinner /></div>;
|
|
}
|
|
|
|
const resultText = results[activeTab];
|
|
if (resultText) {
|
|
const isSearchType = activeTab === AnalysisType.WEB_SEARCH || activeTab === AnalysisType.PLAN_TRIP;
|
|
return (
|
|
<div className="prose prose-sm dark:prose-invert max-w-none whitespace-pre-wrap">
|
|
{resultText}
|
|
{isSearchType && sources.length > 0 && (
|
|
<div className="mt-4">
|
|
<h4 className="font-semibold">Sources:</h4>
|
|
<ul className="list-disc pl-5">
|
|
{sources.map((source) => {
|
|
if (!source.uri) return null;
|
|
return (
|
|
<li key={source.uri}>
|
|
<a href={source.uri} target="_blank" rel="noopener noreferrer" className="text-brand-primary hover:underline">
|
|
{source.title}
|
|
</a>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
{activeTab === AnalysisType.DEEP_DIVE && (
|
|
<div className="mt-6 text-center">
|
|
{generatedImageUrl ? (
|
|
<img src={generatedImageUrl} alt="AI generated meal plan" className="rounded-lg shadow-md mx-auto" />
|
|
) : (
|
|
<button
|
|
onClick={handleGenerateImage}
|
|
disabled={isGeneratingImage}
|
|
className="inline-flex items-center justify-center bg-indigo-500 hover:bg-indigo-600 disabled:bg-indigo-300 text-white font-bold py-2 px-4 rounded-lg"
|
|
>
|
|
{isGeneratingImage ? <><LoadingSpinner /> <span className="ml-2">Generating...</span></> : <><PhotoIcon className="w-4 h-4 mr-2"/> Generate an image for this meal plan</>}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="text-center py-10">
|
|
<p className="text-gray-500 mb-4">Click below to generate AI-powered insights.</p>
|
|
<button
|
|
onClick={() => handleAnalysis(activeTab)}
|
|
className="bg-brand-secondary hover:bg-brand-dark text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300"
|
|
>
|
|
Generate {activeTab.replace('_', ' ')}
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
<div className="flex space-x-2 mb-4">
|
|
<TabButton label="Quick Insights" icon={<LightbulbIcon className="w-4 h-4" />} isActive={activeTab === AnalysisType.QUICK_INSIGHTS} onClick={() => setActiveTab(AnalysisType.QUICK_INSIGHTS)} />
|
|
<TabButton label="Deep Dive" icon={<BrainIcon className="w-4 h-4" />} isActive={activeTab === AnalysisType.DEEP_DIVE} onClick={() => setActiveTab(AnalysisType.DEEP_DIVE)} />
|
|
<TabButton label="Web Search" icon={<SearchIcon className="w-4 h-4" />} isActive={activeTab === AnalysisType.WEB_SEARCH} onClick={() => setActiveTab(AnalysisType.WEB_SEARCH)} />
|
|
<TabButton label="Plan Trip" icon={<MapPinIcon className="w-4 h-4" />} isActive={activeTab === AnalysisType.PLAN_TRIP} onClick={() => setActiveTab(AnalysisType.PLAN_TRIP)} />
|
|
</div>
|
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-md p-4 min-h-[200px] overflow-y-auto">
|
|
{error && <p className="text-red-500 text-center">{error}</p>}
|
|
{renderContent()}
|
|
</div>
|
|
</div>
|
|
);
|
|
}; |