// src/App.tsx import React, { useState, useCallback, useEffect } from 'react'; import { Routes, Route, useParams, useNavigate } from 'react-router-dom'; import { Toaster } from 'react-hot-toast'; import { FlyerDisplay } from './features/flyer/FlyerDisplay'; import { ExtractedDataTable } from './features/flyer/ExtractedDataTable'; import { AnalysisPanel } from './features/flyer/AnalysisPanel'; import { PriceChart } from './features/charts/PriceChart'; import * as pdfjsLib from 'pdfjs-dist'; import { ErrorDisplay } from './components/ErrorDisplay'; import { Header } from './components/Header'; import { logger } from './services/logger'; import * as aiApiClient from './services/aiApiClient'; import type { FlyerItem, Flyer, MasterGroceryItem, DealItem, ProcessingStage, StageStatus, Profile, ShoppingList, ShoppingListItem, User, UserProfile } from './types'; import { BulkImporter } from './features/flyer/BulkImporter'; import { PriceHistoryChart } from './features/charts/PriceHistoryChart'; import * as apiClient from './services/apiClient'; import { FlyerList } from './features/flyer/FlyerList'; import { recordProcessingTime, getAverageProcessingTime } from './utils/processingTimer'; import { ProfileManager } from './pages/admin/components/ProfileManager'; import { ShoppingListComponent } from './features/shopping/ShoppingList'; import { FlyerUploader } from './features/flyer/FlyerUploader'; import { VoiceAssistant } from './features/voice-assistant/VoiceAssistant'; import { AdminPage } from './pages/admin/AdminPage'; import { AdminRoute } from './components/AdminRoute'; import { CorrectionsPage } from './pages/admin/CorrectionsPage'; import { ActivityLog, ActivityLogClickHandler } from './pages/admin/ActivityLog'; import { WatchedItemsList } from './features/shopping/WatchedItemsList'; import { AdminStatsPage } from './pages/admin/AdminStatsPage'; import { ResetPasswordPage } from './pages/ResetPasswordPage'; import { AnonymousUserBanner } from './pages/admin/components/AnonymousUserBanner'; import { VoiceLabPage } from './pages/VoiceLabPage'; import { WhatsNewModal } from './components/WhatsNewModal'; import { FlyerCorrectionTool } from './components/FlyerCorrectionTool'; import { QuestionMarkCircleIcon } from './components/icons/QuestionMarkCircleIcon'; import Leaderboard from './components/Leaderboard'; /** * Defines the possible authentication states for a user session. * - `SIGNED_OUT`: No user is active. The session is fresh or the user has explicitly signed out. * - `ANONYMOUS`: The user has started interacting with the app (e.g., by uploading a flyer) * but has not logged in or created an account. This state allows for temporary, * session-based data access without full authentication. This is a planned feature. * - `AUTHENTICATED`: The user has successfully logged in, and their identity is confirmed * via a valid JWT. */ type AuthStatus = 'SIGNED_OUT' | 'ANONYMOUS' | 'AUTHENTICATED'; // pdf.js worker configuration // This is crucial for allowing pdf.js to process PDFs in a separate thread, preventing the UI from freezing. // We need to explicitly tell pdf.js where to load its worker script from. // By importing pdfjs-dist, we can host the worker locally, which is more reliable. pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.mjs', import.meta.url).toString(); function App() { const [user, setUser] = useState(null); // Moved user state to the top // --- Data Fetching --- const [flyers, setFlyers] = useState([]); const [masterItems, setMasterItems] = useState([]); const [watchedItems, setWatchedItems] = useState([]); const [shoppingLists, setShoppingLists] = useState([]); // This was a duplicate, fixed. const refetchFlyers = useCallback(async () => { try { const flyersRes = await apiClient.fetchFlyers(); setFlyers(await flyersRes.json()); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } }, []); useEffect(() => { const fetchData = async () => { const [masterItemsRes, watchedItemsRes, shoppingListsRes] = await Promise.all([ apiClient.fetchMasterItems(), user ? apiClient.fetchWatchedItems() : Promise.resolve(new Response(JSON.stringify([]))), user ? apiClient.fetchShoppingLists() : Promise.resolve(new Response(JSON.stringify([]))), ]); setMasterItems(await masterItemsRes.json()); setWatchedItems(await watchedItemsRes.json()); setShoppingLists(await shoppingListsRes.json()); }; refetchFlyers(); // Initial fetch fetchData(); }, [user, refetchFlyers]); const [selectedFlyer, setSelectedFlyer] = useState(null); const [flyerItems, setFlyerItems] = useState([]); // Local state for watched items and shopping lists to allow for optimistic updates const [localWatchedItems, setLocalWatchedItems] = useState([]); const [localShoppingLists, setLocalShoppingLists] = useState([]); const [activeDeals, setActiveDeals] = useState([]); const [activeDealsLoading, setActiveDealsLoading] = useState(false); const [totalActiveItems, setTotalActiveItems] = useState(0); const [error, setError] = useState(null); const [importSummary, setImportSummary] = useState<{ processed: string[]; skipped: string[]; errors: { fileName: string; message: string }[]; } | null>(null); const [isDarkMode, setIsDarkMode] = useState(false); const [unitSystem, setUnitSystem] = useState<'metric' | 'imperial'>('imperial'); const [profile, setProfile] = useState(null); const [authStatus, setAuthStatus] = useState('SIGNED_OUT'); const [isProfileManagerOpen, setIsProfileManagerOpen] = useState(false); // This will now control the login modal as well const [isWhatsNewOpen, setIsWhatsNewOpen] = useState(false); const [isVoiceAssistantOpen, setIsVoiceAssistantOpen] = useState(false); const [isCorrectionToolOpen, setIsCorrectionToolOpen] = useState(false); const handleDataExtractedFromCorrection = (type: 'store_name' | 'dates', value: string) => { if (!selectedFlyer) return; // This is a simplified update. A real implementation would involve // making another API call to update the flyer record in the database. // For now, we just update the local state for immediate visual feedback. const updatedFlyer = { ...selectedFlyer }; if (type === 'store_name') { updatedFlyer.store = { ...updatedFlyer.store!, name: value }; } else if (type === 'dates') { // A more robust solution would parse the date string properly. } setSelectedFlyer(updatedFlyer); }; const [estimatedTime, setEstimatedTime] = useState(0); const [activeListId, setActiveListId] = useState(null); // --- State Synchronization and Error Handling --- useEffect(() => { setLocalWatchedItems(watchedItems); }, [watchedItems]); useEffect(() => { setLocalShoppingLists(shoppingLists); }, [shoppingLists]); // Effect to set initial theme based on user profile, local storage, or system preference useEffect(() => { if (profile && profile.preferences?.darkMode !== undefined) { // Preference from DB const dbDarkMode = profile.preferences.darkMode; setIsDarkMode(dbDarkMode); document.documentElement.classList.toggle('dark', dbDarkMode); } else { // Fallback to local storage or system preference const savedMode = localStorage.getItem('darkMode'); const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const initialDarkMode = savedMode !== null ? savedMode === 'true' : prefersDark; setIsDarkMode(initialDarkMode); document.documentElement.classList.toggle('dark', initialDarkMode); } }, [profile]); // Effect to set initial unit system based on user profile or local storage useEffect(() => { if (profile && profile.preferences?.unitSystem) { setUnitSystem(profile.preferences.unitSystem); } else { const savedSystem = localStorage.getItem('unitSystem') as 'metric' | 'imperial' | null; if (savedSystem) { setUnitSystem(savedSystem); } } }, [profile]); // This is the login handler that will be passed to the ProfileManager component. const handleLoginSuccess = async (loggedInUser: User, token: string) => { setError(null); // Immediately store the token so subsequent API calls in this function are authenticated. localStorage.setItem('authToken', token); try { // Fetch all essential user data *before* setting the final authenticated state. // This ensures the app doesn't enter an inconsistent state if one of these calls fails. const [profileResponse, watchedResponse] = await Promise.all([ apiClient.getAuthenticatedUserProfile(), apiClient.fetchWatchedItems(), // We still fetch here to get immediate data after login ]); const userProfile = await profileResponse.json(); const watchedData = await watchedResponse.json(); // Now that all data is successfully fetched, update the application state. setUser(loggedInUser); // Set user first setProfile(userProfile); setAuthStatus('AUTHENTICATED'); setLocalWatchedItems(watchedData); // The fetchShoppingLists function will be triggered by the useEffect below // now that the user state has been set. logger.info('Login and data fetch successful', { user: loggedInUser }); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); logger.error('Failed to fetch user data after login. Rolling back.', { error: errorMessage }); setError(`Login succeeded, but failed to fetch your data: ${errorMessage}`); handleSignOut(); // Log the user out to prevent an inconsistent state. } }; // Effect to check for an existing token on initial app load. useEffect(() => { const checkAuthToken = async () => { const token = localStorage.getItem('authToken'); if (token) { logger.info('Found auth token in local storage. Validating...'); try { const response = await apiClient.getAuthenticatedUserProfile(); const userProfile = await response.json(); // The user object is nested within the UserProfile object. setUser(userProfile.user); setProfile(userProfile); setAuthStatus('AUTHENTICATED'); logger.info('Token validated successfully.', { user: userProfile.user }); } catch (e) { logger.warn('Auth token validation failed. Clearing token.', { error: e }); localStorage.removeItem('authToken'); setUser(null); setAuthStatus('SIGNED_OUT'); } } else { logger.info('No auth token found. User is signed out.'); setAuthStatus('SIGNED_OUT'); } }; checkAuthToken(); }, []); // Runs only once on mount. Intentionally empty. // Effect to handle the token from Google OAuth redirect useEffect(() => { const urlParams = new URLSearchParams(window.location.search); const googleToken = urlParams.get('googleAuthToken'); if (googleToken) { logger.info('Received Google Auth token from URL. Authenticating...'); // The token is already a valid access token from our server. // We can use it to fetch the user profile and complete the login flow. localStorage.setItem('authToken', googleToken); apiClient.getAuthenticatedUserProfile().then(response => response.json()) .then((userProfile: UserProfile) => { handleLoginSuccess(userProfile.user, googleToken); }) .catch(err => logger.error('Failed to log in with Google token', { error: err })); // Clean the token from the URL window.history.replaceState({}, document.title, "/"); } const githubToken = urlParams.get('githubAuthToken'); if (githubToken) { logger.info('Received GitHub Auth token from URL. Authenticating...'); // The token is already a valid access token from our server. // We can use it to fetch the user profile and complete the login flow. localStorage.setItem('authToken', githubToken); apiClient.getAuthenticatedUserProfile().then(response => response.json()) // This returns a UserProfile .then((userProfile: UserProfile) => { handleLoginSuccess(userProfile.user, githubToken); }) .catch(err => { logger.error('Failed to log in with GitHub token', { error: err }); // Optionally, redirect to a page with an error message // navigate('/login?error=github_auth_failed'); }); // Clean the token from the URL window.history.replaceState({}, document.title, "/"); } }, [handleLoginSuccess]); const resetState = useCallback(() => { setSelectedFlyer(null); setFlyerItems([]); }, []); const handleFlyerSelect = useCallback(async (flyer: Flyer) => { setSelectedFlyer(flyer); setError(null); setFlyerItems([]); // Clear previous items try { const response = await apiClient.fetchFlyerItems(flyer.flyer_id); const items = await response.json(); setFlyerItems(items); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); setError(errorMessage); } }, []); useEffect(() => { if (!selectedFlyer && flyers.length > 0) { handleFlyerSelect(flyers[0]); } }, [flyers, selectedFlyer, handleFlyerSelect]); // New effect to handle routing to a specific flyer ID from the URL useEffect(() => { const urlParams = new URLSearchParams(window.location.search); const flyerIdFromUrl = urlParams.get('flyerId'); // Or parse from path if using /flyers/:id if (flyerIdFromUrl && flyers.length > 0) { const flyerId = parseInt(flyerIdFromUrl, 10); const flyerToSelect = flyers.find(f => f.flyer_id === flyerId); if (flyerToSelect && flyerToSelect.flyer_id !== selectedFlyer?.flyer_id) { handleFlyerSelect(flyerToSelect); } } }, [flyers, handleFlyerSelect, selectedFlyer]); useEffect(() => { const findActiveDeals = async () => { if (flyers.length === 0 || localWatchedItems.length === 0) { setActiveDeals([]); return; } setActiveDealsLoading(true); try { const today = new Date(); today.setHours(0, 0, 0, 0); const validFlyers = flyers.filter((flyer) => { if (!flyer.valid_from || !flyer.valid_to) return false; try { const from = new Date(`${flyer.valid_from}T00:00:00`); const to = new Date(`${flyer.valid_to}T00:00:00`); return today >= from && today <= to; } catch (e) { logger.error("Error parsing flyer date", { error: e }); return false; } }); if (validFlyers.length === 0) { setActiveDeals([]); return; } const validFlyerIds = validFlyers.map(f => f.flyer_id); const response = await apiClient.fetchFlyerItemsForFlyers(validFlyerIds); const allItems: FlyerItem[] = await response.json(); const watchedItemIds = new Set(localWatchedItems.map((item: MasterGroceryItem) => item.master_grocery_item_id)); const dealItemsRaw = allItems.filter(item => item.master_item_id && watchedItemIds.has(item.master_item_id) ); // This seems correct as it's comparing with master_item_id const flyerIdToStoreName = new Map(validFlyers.map((f: Flyer) => [f.flyer_id, f.store?.name || 'Unknown Store'])); const deals: DealItem[] = dealItemsRaw.map(item => ({ item: item.item, price_display: item.price_display, price_in_cents: item.price_in_cents, quantity: item.quantity, storeName: (flyerIdToStoreName.get(item.flyer_id!) || 'Unknown Store') as string, master_item_name: item.master_item_name, unit_price: item.unit_price, })); setActiveDeals(deals); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not fetch active deals: ${errorMessage}`); } finally { setActiveDealsLoading(false); } }; findActiveDeals(); }, [flyers, localWatchedItems]); useEffect(() => { const calculateTotalActiveItems = async () => { if (flyers.length === 0) { setTotalActiveItems(0); return; } try { const today = new Date(); today.setHours(0, 0, 0, 0); const validFlyers = flyers.filter((flyer) => { if (!flyer.valid_from || !flyer.valid_to) return false; try { const from = new Date(`${flyer.valid_from}T00:00:00`); const to = new Date(`${flyer.valid_to}T00:00:00`); return today >= from && today <= to; } catch (e) { logger.error("Error parsing flyer date", { error: e }); return false; } }); if (validFlyers.length === 0) { setTotalActiveItems(0); return; } const validFlyerIds = validFlyers.map(f => f.flyer_id); const response = await apiClient.countFlyerItemsForFlyers(validFlyerIds); const { count: totalCount } = await response.json(); setTotalActiveItems(totalCount); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); logger.error("Failed to calculate total active items:", { error: errorMessage }); setTotalActiveItems(0); } }; calculateTotalActiveItems(); }, [flyers]); const handleAddWatchedItem = useCallback(async (itemName: string, category: string) => { if (!user) return; try { const updatedOrNewItem = await (await apiClient.addWatchedItem(itemName, category)).json(); setLocalWatchedItems((prevItems: MasterGroceryItem[]) => { // Check if the item already exists in the state by its correct ID property. const itemExists = prevItems.some((item: MasterGroceryItem) => item.master_grocery_item_id === updatedOrNewItem.master_grocery_item_id); if (!itemExists) { const newItems = [...prevItems, updatedOrNewItem]; // This was correct, but the check above was wrong. return newItems.sort((a,b) => a.name.localeCompare(b.name)); } return prevItems; // Item already existed in list }); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not add watched item: ${errorMessage}`); // Re-fetch to sync state on error } }, [user]); const handleRemoveWatchedItem = useCallback(async (masterItemId: number) => { if (!user) return; try { const response = await apiClient.removeWatchedItem(masterItemId); // API call is correct if (!response.ok) throw new Error('Failed to remove item'); setLocalWatchedItems(prevItems => prevItems.filter((item: MasterGroceryItem) => item.master_grocery_item_id !== masterItemId)); // State update must use correct property } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not remove watched item: ${errorMessage}`); } }, [user]); // --- Shopping List Handlers --- const handleCreateList = useCallback(async (name: string) => { if (!user) return; try { const response = await apiClient.createShoppingList(name); const newList = await response.json(); setShoppingLists(prev => [...prev, newList]); setActiveListId(newList.shopping_list_id); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not create list: ${errorMessage}`); } }, [user]); // Changed dependency from `session` to `user` const handleDeleteList = useCallback(async (listId: number) => { if (!user) return; try { const response = await apiClient.deleteShoppingList(listId); if (!response.ok) throw new Error('Failed to delete list'); const newLists = localShoppingLists.filter(l => l.shopping_list_id !== listId); setLocalShoppingLists(newLists); if (activeListId === listId) { setActiveListId(newLists.length > 0 ? newLists[0].shopping_list_id : null); } } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not delete list: ${errorMessage}`); } }, [user, localShoppingLists, activeListId]); const handleAddShoppingListItem = useCallback(async (listId: number, item: { masterItemId?: number, customItemName?: string }) => { if (!user) return; try { const response = await apiClient.addShoppingListItem(listId, item); const newItem = await response.json(); setShoppingLists(prevLists => prevLists.map(list => { if (list.shopping_list_id === listId) { // Avoid adding duplicates to the state if it's already there // Check if the item already exists in the list by its correct ID property. const itemExists = list.items.some(i => i.shopping_list_item_id === newItem.shopping_list_item_id); if (itemExists) return list; return { ...list, items: [...list.items, newItem] }; } return list; })); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not add item to list: ${errorMessage}`); } }, [user, activeListId]); // Added activeListId to dependencies const handleUpdateShoppingListItem = useCallback(async (itemId: number, updates: Partial) => { if (!user || !activeListId) return; try { const response = await apiClient.updateShoppingListItem(itemId, updates); const updatedItem = await response.json(); setShoppingLists(prevLists => prevLists.map(list => { if (list.shopping_list_id === activeListId) { return { ...list, items: list.items.map(i => i.shopping_list_item_id === itemId ? updatedItem : i) }; } return list; })); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not update list item: ${errorMessage}`); } }, [user, activeListId]); // Changed dependency from `session` to `user` const handleRemoveShoppingListItem = useCallback(async (itemId: number) => { if (!user || !activeListId) return; try { const response = await apiClient.removeShoppingListItem(itemId); if (!response.ok) throw new Error('Failed to remove item'); setLocalShoppingLists(prevLists => prevLists.map(list => { if (list.shopping_list_id === activeListId) { return { ...list, items: list.items.filter(i => i.shopping_list_item_id !== itemId) }; } return list; })); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); setError(`Could not remove list item: ${errorMessage}`); } }, [user, activeListId]); // Changed dependency from `session` to `user` const handleSignOut = () => { localStorage.removeItem('authToken'); // Remove the JWT token setUser(null); // Clear the user state setProfile(null); // Clear the profile state setAuthStatus('SIGNED_OUT'); // Update auth status }; const handleActivityLogClick: ActivityLogClickHandler = (log) => { // Thanks to the discriminated union, if the action is 'list_shared', TypeScript knows 'details.shopping_list_id' is a number. if (log.action === 'list_shared') { const listId = log.details.shopping_list_id; if (localShoppingLists.some(list => list.shopping_list_id === listId)) { setActiveListId(listId); } } // Future functionality for other clickable log types can be added here. // For example, clicking a recipe could open a recipe detail modal. }; const hasData = flyerItems.length > 0; // Read the application version injected at build time. // This will only be available in the production build, not during local development. const appVersion = import.meta.env.VITE_APP_VERSION; const commitMessage = import.meta.env.VITE_APP_COMMIT_MESSAGE; const commitUrl = import.meta.env.VITE_APP_COMMIT_URL; useEffect(() => { if (appVersion) { logger.info(`Application version: ${appVersion}`); const lastSeenVersion = localStorage.getItem('lastSeenVersion'); // If the current version is new, show the "What's New" modal. if (appVersion !== lastSeenVersion) { setIsWhatsNewOpen(true); localStorage.setItem('lastSeenVersion', appVersion); } } }, [appVersion]); return (
{/* Toaster component for displaying notifications. It's placed at the top level. */} {/* Add CSS variables for toast theming based on dark mode */}
setIsProfileManagerOpen(true)} onOpenVoiceAssistant={() => setIsVoiceAssistantOpen(true)} onSignOut={handleSignOut} /> {/* The ProfileManager is now always available to be opened, handling both login and profile management. */} {isProfileManagerOpen && ( setIsProfileManagerOpen(false)} user={user} authStatus={authStatus} profile={profile} onProfileUpdate={setProfile} onLoginSuccess={handleLoginSuccess} onSignOut={handleSignOut} // Pass the signOut handler /> )} {user && ( setIsVoiceAssistantOpen(false)} /> )} {/* "What's New" modal, shown automatically on new versions */} {appVersion && commitMessage && ( setIsWhatsNewOpen(false)} version={appVersion} commitMessage={commitMessage} /> )} {selectedFlyer && ( setIsCorrectionToolOpen(false)} imageUrl={selectedFlyer.image_url} onDataExtracted={handleDataExtractedFromCorrection} /> )} } /> {/* This banner will only appear for users who have interacted with the app but are not logged in. */} {authStatus === 'ANONYMOUS' && (
setIsProfileManagerOpen(true)} />
)}
{error && } {/* This was a duplicate, fixed. */} {selectedFlyer ? ( <> setIsCorrectionToolOpen(true)} /> {hasData && ( <> handleAddShoppingListItem(activeListId!, { masterItemId })} /> )} ) : (

Welcome to Flyer Crawler!

Upload a new grocery flyer to begin, or select a previously processed flyer from the list on the left.

)}
{( <> handleAddShoppingListItem(activeListId!, item)} onUpdateItem={handleUpdateShoppingListItem} onRemoveItem={handleRemoveShoppingListItem} /> handleAddShoppingListItem(activeListId!, { masterItemId })} /> )}
} /> }> } /> } /> } /> } /> } />
{/* Display the build version number at the bottom-left of the screen */} {appVersion && (
Version: {appVersion}
)}
); } /** * A wrapper component to handle the logic for the /flyers/:flyerId route. * It extracts the flyerId from the URL and triggers the selection in the parent App component. */ const HomePage: React.FC = () => { const { flyerId } = useParams<{ flyerId: string }>(); const navigate = useNavigate(); useEffect(() => { // This component's purpose is to set the selected flyer based on the URL. // The actual rendering is handled by the main App component's state. // After mounting, we can navigate back to the root path, as the selection // will have been triggered by the main App's useEffect hook that watches the URL. // This is a common pattern for using URL params to drive state in a parent component. if (flyerId) { // The main App component will see this URL and select the flyer. // We can then navigate to the root to clean up the URL, while the selection remains. // A small timeout can ensure the parent component has time to react. setTimeout(() => navigate('/', { replace: true }), 100); } }, [flyerId, navigate]); // This component doesn't render anything itself; it's just a controller. return null; }; export default App;