RafaelJaime's picture
Update src/App.js
5fa9b6e verified
import React, { useState, useEffect, useContext, createContext } from 'react';
import { Home, Upload, List, ShoppingCart, TrendingUp, Calendar, Trash2, Edit2, Save, X } from 'lucide-react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, BarChart, Bar, PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
const StoreContext = createContext();
const StoreProvider = ({ children }) => {
const [products, setProducts] = useState(() => {
const stored = localStorage.getItem('grocery-tracker-storage');
return stored ? JSON.parse(stored) : [];
});
useEffect(() => {
localStorage.setItem('grocery-tracker-storage', JSON.stringify(products));
}, [products]);
const addProduct = (product) => {
setProducts(prev => [...prev, { ...product, id: Date.now() }]);
};
const updateProduct = (id, product) => {
setProducts(prev => prev.map(p => p.id === id ? { ...p, ...product } : p));
};
const deleteProduct = (id) => {
setProducts(prev => prev.filter(p => p.id !== id));
};
const clearProducts = () => {
setProducts([]);
};
return (
<StoreContext.Provider value={{ products, addProduct, updateProduct, deleteProduct, clearProducts }}>
{children}
</StoreContext.Provider>
);
};
const useStore = () => {
const context = useContext(StoreContext);
if (!context) {
throw new Error('useStore must be used within a StoreProvider');
}
return context;
};
const MISTRAL_API_KEY = process.env.REACT_APP_MISTRAL_API_KEY;
const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions';
const convertImageToBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
const extractTextFromImage = async (file, userApiKey) => {
const apiKeyToUse = userApiKey || MISTRAL_API_KEY;
try {
const base64Image = await convertImageToBase64(file);
const response = await fetch(MISTRAL_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKeyToUse}`
},
body: JSON.stringify({
model: 'pixtral-12b-2409',
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: 'Extract all text from this receipt image. Please provide the raw text exactly as it appears, maintaining the original structure and formatting.'
},
{
type: 'image_url',
image_url: {
url: base64Image
}
}
]
}
],
max_tokens: 1000,
temperature: 0.1
})
});
if (!response.ok) {
throw new Error(`Mistral API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
console.error('Error extracting text from image:', error);
throw new Error('Failed to extract text from image');
}
};
const processReceiptText = async (text, userApiKey) => {
const apiKeyToUse = userApiKey || MISTRAL_API_KEY;
try {
const response = await fetch(MISTRAL_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKeyToUse}`
},
body: JSON.stringify({
model: 'mistral-large-latest',
messages: [
{
role: 'user',
content: `Analyze this receipt text and extract product information. Return a JSON array with objects containing: name, price, store (with name and address), and date.
Receipt text:
${text}
Please return ONLY a valid JSON array in this format:
[
{
"name": "Product Name",
"price": 2.50,
"store": {
"name": "Store Name",
"address": "Store Address"
},
"date": "MM/DD/YYYY"
}
]`
}
],
max_tokens: 2000,
temperature: 0.1
})
});
if (!response.ok) {
throw new Error(`Mistral API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const jsonResponse = data.choices[0].message.content;
const jsonMatch = jsonResponse.match(/\[[\s\S]*\]/);
if (!jsonMatch) {
throw new Error('Invalid JSON response from Mistral');
}
const products = JSON.parse(jsonMatch[0]);
return products.map(product => ({
...product,
price: parseFloat(product.price) || 0
}));
} catch (error) {
console.error('Error processing receipt text:', error);
throw new Error('Failed to process receipt text');
}
};
const UploadTab = () => {
const { addProduct } = useStore();
const [file, setFile] = useState(null);
const [loading, setLoading] = useState(false);
const [step, setStep] = useState('upload');
const [extractedText, setExtractedText] = useState('');
const [processedProducts, setProcessedProducts] = useState([]);
const [error, setError] = useState(null);
const [apiKey, setApiKey] = useState('');
const handleFileSelect = (e) => {
const selectedFile = e.target.files[0];
if (selectedFile && selectedFile.type.startsWith('image/')) {
setFile(selectedFile);
setStep('upload');
setError(null);
}
};
const handleUpload = async () => {
if (!file) return;
if (!apiKey && !MISTRAL_API_KEY) {
setError('Please enter your Mistral API key or configure REACT_APP_MISTRAL_API_KEY environment variable.');
return;
}
setLoading(true);
setError(null);
setStep('extracting');
try {
const text = await extractTextFromImage(file, apiKey);
setExtractedText(text);
setStep('processing');
const products = await processReceiptText(text, apiKey);
setProcessedProducts(products);
setStep('review');
} catch (error) {
console.error('Error processing receipt:', error);
setError(error.message);
setStep('upload');
} finally {
setLoading(false);
}
};
const handleSaveProducts = () => {
processedProducts.forEach(product => addProduct(product));
setFile(null);
setExtractedText('');
setProcessedProducts([]);
setStep('upload');
setError(null);
alert('Products saved successfully');
};
return (
<div className="p-6">
<h2 className="text-3xl font-bold text-gray-800 mb-6">Upload Receipt</h2>
<div className="bg-white rounded-lg shadow p-6">
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700">{error}</p>
</div>
)}
{step === 'upload' && (
<div className="space-y-4">
<div className="mb-4">
<label htmlFor="apiKey" className="block text-sm font-medium text-gray-700 mb-2">
Mistral API Key
</label>
<input
id="apiKey"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Enter your Mistral API key"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<p className="text-xs text-gray-500 mt-1">
Required to process receipt images
</p>
</div>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<label className="cursor-pointer">
<span className="text-lg font-medium text-gray-700">
Select a receipt image
</span>
<input
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
</label>
{file && (
<p className="mt-2 text-sm text-gray-500">
Selected file: {file.name}
</p>
)}
</div>
{file && (
<button
onClick={handleUpload}
disabled={loading}
className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Processing...' : 'Process Receipt'}
</button>
)}
</div>
)}
{step === 'extracting' && (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-lg font-medium text-gray-700">Extracting text from image...</p>
</div>
)}
{step === 'processing' && (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600 mx-auto mb-4"></div>
<p className="text-lg font-medium text-gray-700">Processing products...</p>
</div>
)}
{step === 'review' && (
<div className="space-y-4">
<h3 className="text-xl font-semibold text-gray-800">Detected Products</h3>
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<h4 className="font-medium text-gray-700 mb-2">Extracted Text:</h4>
<pre className="text-sm text-gray-600 whitespace-pre-wrap max-h-32 overflow-y-auto">
{extractedText}
</pre>
</div>
<div className="bg-gray-50 rounded-lg p-4 max-h-96 overflow-y-auto">
{processedProducts.length > 0 ? (
processedProducts.map((product, index) => (
<div key={index} className="flex justify-between items-center py-2 border-b border-gray-200 last:border-b-0">
<div>
<span className="font-medium">{product.name}</span>
<span className="text-sm text-gray-500 ml-2">
({product.store?.name || 'Unknown Store'})
</span>
</div>
<span className="font-bold text-green-600">${product.price.toFixed(2)}</span>
</div>
))
) : (
<p className="text-gray-500 text-center py-4">No products detected</p>
)}
</div>
<div className="flex space-x-3">
<button
onClick={handleSaveProducts}
disabled={processedProducts.length === 0}
className="flex-1 bg-green-600 text-white py-3 px-4 rounded-lg hover:bg-green-700 disabled:opacity-50"
>
Save Products
</button>
<button
onClick={() => setStep('upload')}
className="flex-1 bg-gray-600 text-white py-3 px-4 rounded-lg hover:bg-gray-700"
>
Cancel
</button>
</div>
</div>
)}
</div>
</div>
);
};
const Sidebar = ({ activeTab, setActiveTab }) => {
const tabs = [
{ id: 'home', label: 'Home', icon: Home },
{ id: 'upload', label: 'Upload', icon: Upload },
{ id: 'list', label: 'List', icon: List }
];
return (
<div className="w-64 bg-white border-r border-gray-200 h-screen">
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-800 flex items-center">
<ShoppingCart className="mr-2" />
Grocery Tracker
</h1>
</div>
<nav className="mt-6">
{tabs.map(tab => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center px-6 py-3 text-left transition-colors ${
activeTab === tab.id
? 'bg-blue-50 text-blue-600 border-r-2 border-blue-600'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
<Icon className="mr-3 h-5 w-5" />
{tab.label}
</button>
);
})}
</nav>
</div>
);
};
const HomeTab = () => {
const { products } = useStore();
const totalSpent = products.reduce((sum, p) => sum + p.price, 0);
const productsByStore = products.reduce((acc, p) => {
const key = p.store.name;
if (!acc[key]) acc[key] = [];
acc[key].push(p);
return acc;
}, {});
const priceVariations = products.reduce((acc, p) => {
if (!acc[p.name]) acc[p.name] = [];
acc[p.name].push(p);
return acc;
}, {});
const productsWithVariation = Object.entries(priceVariations)
.filter(([_, prods]) => prods.length > 1)
.map(([name, prods]) => ({
name,
minPrice: Math.min(...prods.map(p => p.price)),
maxPrice: Math.max(...prods.map(p => p.price)),
difference: Math.max(...prods.map(p => p.price)) - Math.min(...prods.map(p => p.price))
}))
.sort((a, b) => b.difference - a.difference)
.slice(0, 5);
const spendingByStore = Object.entries(productsByStore).map(([name, prods]) => ({
name,
spending: prods.reduce((sum, p) => sum + p.price, 0),
products: prods.length
}));
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
return (
<div className="p-6 space-y-6">
<h2 className="text-3xl font-bold text-gray-800">Dashboard</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-700 mb-2">Total Spent</h3>
<p className="text-3xl font-bold text-blue-600">${totalSpent.toFixed(2)}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-700 mb-2">Registered Products</h3>
<p className="text-3xl font-bold text-green-600">{products.length}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-700 mb-2">Stores</h3>
<p className="text-3xl font-bold text-purple-600">{Object.keys(productsByStore).length}</p>
</div>
</div>
{spendingByStore.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-700 mb-4">Spending by Store</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={spendingByStore}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip formatter={(value) => [`$${value.toFixed(2)}`, 'Spending']} />
<Bar dataKey="spending" fill="#3b82f6" />
</BarChart>
</ResponsiveContainer>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-700 mb-4">Spending Distribution</h3>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={spendingByStore}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="spending"
>
{spendingByStore.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(value) => [`$${value.toFixed(2)}`, 'Spending']} />
</PieChart>
</ResponsiveContainer>
</div>
</div>
)}
{productsWithVariation.length > 0 && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-700 mb-4 flex items-center">
<TrendingUp className="mr-2" />
Products with Highest Price Variation
</h3>
<div className="space-y-3">
{productsWithVariation.map((product, index) => (
<div key={index} className="flex justify-between items-center p-3 bg-gray-50 rounded">
<span className="font-medium">{product.name}</span>
<div className="text-right">
<span className="text-sm text-gray-500">
${product.minPrice.toFixed(2)} - ${product.maxPrice.toFixed(2)}
</span>
<span className="ml-2 text-red-600 font-semibold">
+${product.difference.toFixed(2)}
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};
const ListTab = () => {
const { products, updateProduct, deleteProduct } = useStore();
const [editingId, setEditingId] = useState(null);
const [editForm, setEditForm] = useState({});
const startEdit = (product) => {
setEditingId(product.id);
setEditForm(product);
};
const saveEdit = () => {
updateProduct(editingId, editForm);
setEditingId(null);
setEditForm({});
};
const cancelEdit = () => {
setEditingId(null);
setEditForm({});
};
const handleDelete = (id) => {
if (window.confirm('Are you sure you want to delete this product?')) {
deleteProduct(id);
}
};
return (
<div className="p-6">
<h2 className="text-3xl font-bold text-gray-800 mb-6">Product List</h2>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Product
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Price
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Store
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{products.map((product) => (
<tr key={product.id}>
<td className="px-6 py-4 whitespace-nowrap">
{editingId === product.id ? (
<input
type="text"
value={editForm.name}
onChange={(e) => setEditForm({...editForm, name: e.target.value})}
className="w-full px-3 py-1 border border-gray-300 rounded"
/>
) : (
<div className="text-sm font-medium text-gray-900">{product.name}</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{editingId === product.id ? (
<input
type="number"
step="0.01"
value={editForm.price}
onChange={(e) => setEditForm({...editForm, price: parseFloat(e.target.value)})}
className="w-full px-3 py-1 border border-gray-300 rounded"
/>
) : (
<div className="text-sm text-gray-900">${product.price.toFixed(2)}</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{product.store.name}</div>
<div className="text-sm text-gray-500">{product.store.address}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{editingId === product.id ? (
<input
type="date"
value={editForm.date}
onChange={(e) => setEditForm({...editForm, date: e.target.value})}
className="w-full px-3 py-1 border border-gray-300 rounded"
/>
) : (
product.date
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
{editingId === product.id ? (
<>
<button
onClick={saveEdit}
className="text-green-600 hover:text-green-900"
>
<Save className="h-4 w-4" />
</button>
<button
onClick={cancelEdit}
className="text-gray-600 hover:text-gray-900"
>
<X className="h-4 w-4" />
</button>
</>
) : (
<>
<button
onClick={() => startEdit(product)}
className="text-blue-600 hover:text-blue-900"
>
<Edit2 className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(product.id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="h-4 w-4" />
</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
{products.length === 0 && (
<div className="text-center py-8">
<p className="text-gray-500">No products registered</p>
</div>
)}
</div>
</div>
</div>
);
};
const App = () => {
const [activeTab, setActiveTab] = useState('home');
const renderContent = () => {
switch (activeTab) {
case 'home':
return <HomeTab />;
case 'upload':
return <UploadTab />;
case 'list':
return <ListTab />;
default:
return <HomeTab />;
}
};
return (
<StoreProvider>
<div className="flex h-screen bg-gray-100">
<Sidebar activeTab={activeTab} setActiveTab={setActiveTab} />
<main className="flex-1 overflow-y-auto">
{renderContent()}
</main>
</div>
</StoreProvider>
);
};
export default App;