Files
flyer-crawler.projectium.com/src/App.tsx
Torben Sorensen df0108fa4d
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 4m19s
fix /gflyer route - background processing is looking good
2025-12-03 09:08:54 -08:00

791 lines
34 KiB
TypeScript

// 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<User | null>(null); // Moved user state to the top
// --- Data Fetching ---
const [flyers, setFlyers] = useState<Flyer[]>([]);
const [masterItems, setMasterItems] = useState<MasterGroceryItem[]>([]);
const [watchedItems, setWatchedItems] = useState<MasterGroceryItem[]>([]);
const [shoppingLists, setShoppingLists] = useState<ShoppingList[]>([]); // 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<Flyer | null>(null);
const [flyerItems, setFlyerItems] = useState<FlyerItem[]>([]);
// Local state for watched items and shopping lists to allow for optimistic updates
const [localWatchedItems, setLocalWatchedItems] = useState<MasterGroceryItem[]>([]);
const [localShoppingLists, setLocalShoppingLists] = useState<ShoppingList[]>([]);
const [activeDeals, setActiveDeals] = useState<DealItem[]>([]);
const [activeDealsLoading, setActiveDealsLoading] = useState(false);
const [totalActiveItems, setTotalActiveItems] = useState(0);
const [error, setError] = useState<string | null>(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<Profile | null>(null);
const [authStatus, setAuthStatus] = useState<AuthStatus>('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<number | null>(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<ShoppingListItem>) => {
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 (
<div className="bg-gray-100 dark:bg-gray-950 min-h-screen font-sans text-gray-800 dark:text-gray-200">
{/* Toaster component for displaying notifications. It's placed at the top level. */}
<Toaster position="top-center" reverseOrder={false} />
{/* Add CSS variables for toast theming based on dark mode */}
<style>{`
:root {
--toast-bg: ${isDarkMode ? '#4B5563' : '#FFFFFF'};
--toast-color: ${isDarkMode ? '#F9FAFB' : '#1F2937'};
}
`}</style>
<Header
isDarkMode={isDarkMode} // Still pass for display, but toggling happens in ProfileManager
// toggleDarkMode removed
unitSystem={unitSystem} // Still pass for display
// toggleUnitSystem removed
profile={profile}
authStatus={authStatus}
user={user}
onOpenProfile={() => setIsProfileManagerOpen(true)}
onOpenVoiceAssistant={() => setIsVoiceAssistantOpen(true)}
onSignOut={handleSignOut}
/>
{/* The ProfileManager is now always available to be opened, handling both login and profile management. */}
{isProfileManagerOpen && (
<ProfileManager
isOpen={isProfileManagerOpen}
onClose={() => setIsProfileManagerOpen(false)}
user={user}
authStatus={authStatus}
profile={profile}
onProfileUpdate={setProfile}
onLoginSuccess={handleLoginSuccess}
onSignOut={handleSignOut} // Pass the signOut handler
/>
)}
{user && (
<VoiceAssistant
isOpen={isVoiceAssistantOpen}
onClose={() => setIsVoiceAssistantOpen(false)}
/>
)}
{/* "What's New" modal, shown automatically on new versions */}
{appVersion && commitMessage && (
<WhatsNewModal
isOpen={isWhatsNewOpen}
onClose={() => setIsWhatsNewOpen(false)}
version={appVersion}
commitMessage={commitMessage}
/>
)}
{selectedFlyer && (
<FlyerCorrectionTool
isOpen={isCorrectionToolOpen}
onClose={() => setIsCorrectionToolOpen(false)}
imageUrl={selectedFlyer.image_url}
onDataExtracted={handleDataExtractedFromCorrection}
/>
)}
<Routes>
<Route path="/flyers/:flyerId" element={<HomePage />} />
<Route path="/" element={
<main className="max-w-screen-2xl mx-auto py-4 px-2.5 sm:py-6 lg:py-8">
{/* This banner will only appear for users who have interacted with the app but are not logged in. */}
{authStatus === 'ANONYMOUS' && (
<div className="max-w-5xl mx-auto mb-6 px-4 lg:px-0">
<AnonymousUserBanner onOpenProfile={() => setIsProfileManagerOpen(true)} />
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 items-start">
<div className="lg:col-span-1 flex flex-col space-y-6">
<FlyerList flyers={flyers} onFlyerSelect={handleFlyerSelect} selectedFlyerId={selectedFlyer?.flyer_id || null} />
<FlyerUploader onProcessingComplete={refetchFlyers} />
</div>
<div className="lg:col-span-2 flex flex-col space-y-6">
{error && <ErrorDisplay message={error} />} {/* This was a duplicate, fixed. */}
{selectedFlyer ? (
<>
<FlyerDisplay
imageUrl={selectedFlyer.image_url}
store={selectedFlyer.store}
validFrom={selectedFlyer.valid_from}
validTo={selectedFlyer.valid_to}
storeAddress={selectedFlyer.store_address}
onOpenCorrectionTool={() => setIsCorrectionToolOpen(true)}
/>
{hasData && (
<>
<ExtractedDataTable
items={flyerItems}
totalActiveItems={totalActiveItems}
watchedItems={localWatchedItems}
masterItems={masterItems}
unitSystem={unitSystem}
user={user}
onAddItem={handleAddWatchedItem}
shoppingLists={localShoppingLists}
activeListId={activeListId}
onAddItemToList={(masterItemId: number) => handleAddShoppingListItem(activeListId!, { masterItemId })} />
<AnalysisPanel flyerItems={flyerItems} store={selectedFlyer.store} />
</>
)}
</>
) : (
<div className="text-center p-8 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 h-full flex flex-col justify-center min-h-[400px]">
<h2 className="text-xl font-semibold text-gray-700 dark:text-gray-200">Welcome to Flyer Crawler!</h2>
<p className="mt-2 text-gray-500 dark:text-gray-400">Upload a new grocery flyer to begin, or select a previously processed flyer from the list on the left.</p>
</div>
)}
</div>
<div className="lg:col-span-1 flex-col space-y-6">
{(
<>
<ShoppingListComponent
user={user}
lists={localShoppingLists}
activeListId={activeListId}
onSelectList={setActiveListId}
onCreateList={handleCreateList}
onDeleteList={handleDeleteList} // This was a duplicate, fixed.
onAddItem={(item: { masterItemId?: number; customItemName?: string }) => handleAddShoppingListItem(activeListId!, item)}
onUpdateItem={handleUpdateShoppingListItem}
onRemoveItem={handleRemoveShoppingListItem}
/>
<WatchedItemsList
items={localWatchedItems}
onAddItem={handleAddWatchedItem}
onRemoveItem={handleRemoveWatchedItem}
user={user}
activeListId={activeListId}
onAddItemToList={(masterItemId) => handleAddShoppingListItem(activeListId!, { masterItemId })}
/>
<PriceChart
deals={activeDeals}
isLoading={activeDealsLoading}
unitSystem={unitSystem}
user={user}
/>
<PriceHistoryChart watchedItems={localWatchedItems} />
<Leaderboard />
<ActivityLog user={user} onLogClick={handleActivityLogClick} />
</>
)}
</div>
</div>
</main>
} />
<Route element={<AdminRoute profile={profile} />}>
<Route path="/admin" element={<AdminPage />} />
<Route path="/admin/corrections" element={<CorrectionsPage />} />
<Route path="/admin/stats" element={<AdminStatsPage />} />
<Route path="/admin/voice-lab" element={<VoiceLabPage />} />
</Route>
<Route path="/reset-password/:token" element={<ResetPasswordPage />} />
</Routes>
{/* Display the build version number at the bottom-left of the screen */}
{appVersion && (
<div className="fixed bottom-2 left-3 z-50 flex items-center space-x-2">
<a
href={commitUrl || '#'}
target="_blank"
rel="noopener noreferrer"
title="View commit details on Gitea"
className="text-xs text-gray-400 dark:text-gray-600 bg-gray-100 dark:bg-gray-950 px-2 py-1 rounded hover:text-brand-primary dark:hover:text-brand-primary transition-colors"
>
Version: {appVersion}
</a>
<button onClick={() => setIsWhatsNewOpen(true)} title="Show what's new in this version">
<QuestionMarkCircleIcon className="w-5 h-5 text-gray-400 dark:text-gray-600 hover:text-brand-primary dark:hover:text-brand-primary transition-colors" />
</button>
</div>
)}
</div>
);
}
/**
* 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;