Spaces:
Running
Running
| 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; |