diff --git a/backend/main.py b/backend/main.py index c90a264..5eae58b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,14 +5,15 @@ from sqlalchemy import text from typing import List import models, schemas from database import engine, get_db +from version import __version__, __app_name__, __description__ # Create database tables models.Base.metadata.create_all(bind=engine) app = FastAPI( - title="Product Tracker API", - description="API for tracking product prices and shopping events", - version="1.0.0" + title=__app_name__, + description=__description__, + version=__version__ ) # CORS middleware for React frontend @@ -92,7 +93,7 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s # Root endpoint @app.get("/") def read_root(): - return {"message": "Product Tracker API", "version": "1.0.0"} + return {"message": __app_name__, "version": __version__, "name": "Groceries Tracker Backend"} # Product endpoints @app.post("/products/", response_model=schemas.Product) diff --git a/backend/version.py b/backend/version.py new file mode 100644 index 0000000..7420c90 --- /dev/null +++ b/backend/version.py @@ -0,0 +1,8 @@ +""" +Version configuration for Groceries Tracker Backend +Single source of truth for version information +""" + +__version__ = "1.0.0" +__app_name__ = "Groceries Tracker API" +__description__ = "API for tracking grocery shopping events, products, and expenses" \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index d456196..b2b704f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { - "name": "product-tracker-frontend", - "version": "0.1.0", + "name": "groceries-tracker-frontend", + "version": "1.0.1", "private": true, "dependencies": { "@types/node": "^20.10.5", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8d6cbd1..0f1b617 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,8 +7,9 @@ import ShoppingEventList from './components/ShoppingEventList'; import BrandList from './components/BrandList'; import GroceryCategoryList from './components/GroceryCategoryList'; import ImportExportModal from './components/ImportExportModal'; +import AboutModal from './components/AboutModal'; -function Navigation({ onImportExportClick }: { onImportExportClick: () => void }) { +function Navigation({ onImportExportClick, onAboutClick }: { onImportExportClick: () => void; onAboutClick: () => void }) { const location = useLocation(); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); @@ -74,8 +75,8 @@ function Navigation({ onImportExportClick }: { onImportExportClick: () => void } - {/* Import/Export Button */} -
+ {/* Desktop Action Buttons */} +
+
@@ -95,6 +105,34 @@ function Navigation({ onImportExportClick }: { onImportExportClick: () => void } {navLinks.map(({ path, label }) => ( ))} + + {/* Mobile Action Buttons */} +
+ + +
)} @@ -105,6 +143,7 @@ function Navigation({ onImportExportClick }: { onImportExportClick: () => void } function App() { const [showImportExportModal, setShowImportExportModal] = useState(false); + const [showAboutModal, setShowAboutModal] = useState(false); const handleDataChanged = () => { // This will be called when data is imported, but since we're at the app level, @@ -115,7 +154,10 @@ function App() { return (
- setShowImportExportModal(true)} /> + setShowImportExportModal(true)} + onAboutClick={() => setShowAboutModal(true)} + />
@@ -134,6 +176,11 @@ function App() { onClose={() => setShowImportExportModal(false)} onDataChanged={handleDataChanged} /> + + setShowAboutModal(false)} + />
); diff --git a/frontend/src/components/AboutModal.tsx b/frontend/src/components/AboutModal.tsx new file mode 100644 index 0000000..d356754 --- /dev/null +++ b/frontend/src/components/AboutModal.tsx @@ -0,0 +1,154 @@ +import React, { useState, useEffect } from 'react'; +import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; +import { VERSION } from '../config/version'; + +// Use the same API base URL as other API calls +const API_BASE_URL = process.env.NODE_ENV === 'production' + ? '/api' // Use nginx proxy in production + : 'http://localhost:8000'; // Direct backend connection in development + +interface AboutModalProps { + isOpen: boolean; + onClose: () => void; +} + +const AboutModal: React.FC = ({ isOpen, onClose }) => { + // Use body scroll lock when modal is open + useBodyScrollLock(isOpen); + + const [backendVersion, setBackendVersion] = useState('Loading...'); + + useEffect(() => { + if (isOpen) { + fetchBackendVersion(); + } + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, onClose]); + + const fetchBackendVersion = async () => { + try { + const response = await fetch(`${API_BASE_URL}/`); + const data = await response.json(); + setBackendVersion(data.version || 'Unknown'); + } catch (error) { + console.error('Error fetching backend version:', error); + setBackendVersion('Error loading'); + } + }; + + if (!isOpen) return null; + + return ( +
{ + // Close modal if clicking on backdrop + if (e.target === e.currentTarget) { + onClose(); + } + }} + > +
e.stopPropagation()} + > +
+
+

+ About {VERSION.name} +

+ +
+ +
+ {/* App Info */} +
+
+ + + +
+

+ A comprehensive grocery shopping tracker to manage your shopping events, products, and expenses. +

+
+ + {/* Version Information */} +
+

Version Information

+
+
+ Frontend: + v{VERSION.frontend} +
+
+ Backend: + v{backendVersion} +
+
+ Build Date: + {VERSION.buildDate} +
+
+
+ + {/* Features */} +
+

Key Features

+
    +
  • • Track shopping events and expenses
  • +
  • • Manage products, brands, and categories
  • +
  • • Mobile-responsive design
  • +
  • • Import/Export data functionality
  • +
  • • Real-time calculations and analytics
  • +
+
+ + {/* Technical Info */} +
+

Technology Stack

+
+
Frontend: React, TypeScript, Tailwind CSS
+
Backend: FastAPI, Python, SQLite
+
Mobile: Responsive web design
+
+
+
+ + {/* Close Button */} +
+ +
+
+
+
+ ); +}; + +export default AboutModal; \ No newline at end of file diff --git a/frontend/src/components/AddBrandModal.tsx b/frontend/src/components/AddBrandModal.tsx index f24a5f8..164f5e0 100644 --- a/frontend/src/components/AddBrandModal.tsx +++ b/frontend/src/components/AddBrandModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { brandApi } from '../services/api'; import { Brand } from '../types'; import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; @@ -40,27 +40,7 @@ const AddBrandModal: React.FC = ({ isOpen, onClose, onBrandA setError(''); }, [editBrand, isOpen]); - useEffect(() => { - if (!isOpen) return; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - onClose(); - } else if (event.key === 'Enter' && !event.shiftKey && !loading) { - event.preventDefault(); - if (formData.name.trim()) { - handleSubmit(event as any); - } - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [isOpen, formData, loading, onClose]); - - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault(); if (!formData.name.trim()) { setError('Please enter a brand name'); @@ -94,7 +74,31 @@ const AddBrandModal: React.FC = ({ isOpen, onClose, onBrandA } finally { setLoading(false); } - }; + }, [isEditMode, editBrand, formData.name, onBrandAdded, onClose]); + + // Keyboard event handling + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!isOpen) return; + + if (event.key === 'Escape') { + onClose(); + } else if (event.key === 'Enter' && !loading) { + event.preventDefault(); + if (formData.name.trim()) { + handleSubmit(event as any); + } + } + }; + + if (isOpen) { + document.addEventListener('keydown', handleKeyDown); + } + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, formData.name, loading, onClose, handleSubmit]); const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; diff --git a/frontend/src/components/AddGroceryCategoryModal.tsx b/frontend/src/components/AddGroceryCategoryModal.tsx index 665c4a3..711a1e9 100644 --- a/frontend/src/components/AddGroceryCategoryModal.tsx +++ b/frontend/src/components/AddGroceryCategoryModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { GroceryCategory, GroceryCategoryCreate } from '../types'; import { groceryCategoryApi } from '../services/api'; import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; @@ -28,25 +28,7 @@ const AddGroceryCategoryModal: React.FC = ({ categ } }, [category]); - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - onClose(); - } else if (event.key === 'Enter' && !event.shiftKey && !loading) { - event.preventDefault(); - if (formData.name.trim()) { - handleSubmit(event as any); - } - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [formData, loading, onClose]); - - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setMessage(''); @@ -70,7 +52,25 @@ const AddGroceryCategoryModal: React.FC = ({ categ } finally { setLoading(false); } - }; + }, [isEditMode, category, formData, onClose]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } else if (event.key === 'Enter' && !event.shiftKey && !loading) { + event.preventDefault(); + if (formData.name.trim()) { + handleSubmit(event as any); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [formData, loading, onClose, handleSubmit]); return (
= ({ isOpen, onClose, onShopAdde setError(''); }, [editShop, isOpen]); - useEffect(() => { - if (!isOpen) return; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - onClose(); - } else if (event.key === 'Enter' && !event.shiftKey && !loading) { - // Only trigger submit if not in a textarea and form is valid - const target = event.target as HTMLElement; - if (target.tagName !== 'TEXTAREA') { - event.preventDefault(); - if (formData.name.trim() && formData.city.trim()) { - handleSubmit(event as any); - } - } - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [isOpen, formData, loading, onClose]); - - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault(); if (!formData.name.trim() || !formData.city.trim()) { setError('Please fill in all required fields'); @@ -131,7 +107,7 @@ const AddShopModal: React.FC = ({ isOpen, onClose, onShopAdde let shopId: number; if (isEditMode && editShop) { - const updatedShop = await shopApi.update(editShop.id, shopData); + await shopApi.update(editShop.id, shopData); shopId = editShop.id; } else { const newShop = await shopApi.create(shopData); @@ -180,7 +156,28 @@ const AddShopModal: React.FC = ({ isOpen, onClose, onShopAdde } finally { setLoading(false); } - }; + }, [isEditMode, editShop, formData, onShopAdded, onClose]); + + // Keyboard event handling + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!isOpen) return; + + if (event.key === 'Escape') { + onClose(); + } else if (event.key === 'Enter' && !event.shiftKey && !loading) { + event.preventDefault(); + if (formData.name.trim() && formData.city.trim()) { + handleSubmit(event as any); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, formData, loading, onClose, handleSubmit]); const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; diff --git a/frontend/src/components/GroceryCategoryList.tsx b/frontend/src/components/GroceryCategoryList.tsx index cf3ff67..1537a12 100644 --- a/frontend/src/components/GroceryCategoryList.tsx +++ b/frontend/src/components/GroceryCategoryList.tsx @@ -1,5 +1,4 @@ import React, { useState, useEffect } from 'react'; -import { useSearchParams } from 'react-router-dom'; import { GroceryCategory } from '../types'; import { groceryCategoryApi } from '../services/api'; import AddGroceryCategoryModal from './AddGroceryCategoryModal'; diff --git a/frontend/src/components/ShopList.tsx b/frontend/src/components/ShopList.tsx index 60c0d15..ebbc984 100644 --- a/frontend/src/components/ShopList.tsx +++ b/frontend/src/components/ShopList.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Shop, BrandInShop } from '../types'; import { shopApi, brandInShopApi } from '../services/api'; @@ -21,6 +21,36 @@ const ShopList: React.FC = () => { const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 }); const [shopBrands, setShopBrands] = useState>({}); + const loadShopBrands = useCallback(async (shopId: number) => { + try { + const response = await brandInShopApi.getByShop(shopId); + setShopBrands(prev => ({ + ...prev, + [shopId]: response.data + })); + } catch (err) { + console.error('Error loading shop brands:', err); + } + }, []); + + const fetchShops = useCallback(async () => { + try { + setLoading(true); + const response = await shopApi.getAll(); + setShops(response.data); + + // Load brands for all shops + for (const shop of response.data) { + loadShopBrands(shop.id); + } + } catch (err) { + setError('Failed to fetch shops'); + console.error('Error fetching shops:', err); + } finally { + setLoading(false); + } + }, [loadShopBrands]); + useEffect(() => { fetchShops(); @@ -30,7 +60,7 @@ const ShopList: React.FC = () => { // Remove the parameter from URL setSearchParams({}); } - }, [searchParams, setSearchParams]); + }, [searchParams, setSearchParams, fetchShops]); // Handle clicking outside popup to close it useEffect(() => { @@ -51,36 +81,6 @@ const ShopList: React.FC = () => { }; }, [showBrandsPopup]); - const fetchShops = async () => { - try { - setLoading(true); - const response = await shopApi.getAll(); - setShops(response.data); - - // Load brands for all shops - for (const shop of response.data) { - loadShopBrands(shop.id); - } - } catch (err) { - setError('Failed to fetch shops'); - console.error('Error fetching shops:', err); - } finally { - setLoading(false); - } - }; - - const loadShopBrands = async (shopId: number) => { - try { - const response = await brandInShopApi.getByShop(shopId); - setShopBrands(prev => ({ - ...prev, - [shopId]: response.data - })); - } catch (err) { - console.error('Error loading shop brands:', err); - } - }; - const handleShopAdded = () => { fetchShops(); // Refresh the shops list }; @@ -123,7 +123,6 @@ const ShopList: React.FC = () => { const brands = shopBrands[shop.id] || []; if (brands.length === 0) return; - const rect = mouseEvent.currentTarget.getBoundingClientRect(); const popupWidth = 300; const popupHeight = 200; @@ -156,7 +155,6 @@ const ShopList: React.FC = () => { if (brands.length === 0) return; mouseEvent.stopPropagation(); - const rect = mouseEvent.currentTarget.getBoundingClientRect(); const popupWidth = 300; const popupHeight = 200; diff --git a/frontend/src/config/version.ts b/frontend/src/config/version.ts new file mode 100644 index 0000000..1d7a558 --- /dev/null +++ b/frontend/src/config/version.ts @@ -0,0 +1,7 @@ +import packageJson from '../../package.json'; + +export const VERSION = { + frontend: packageJson.version, + buildDate: new Date().toISOString().split('T')[0], // YYYY-MM-DD format + name: "Groceries Tracker" +}; \ No newline at end of file