All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 4m19s
791 lines
34 KiB
TypeScript
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; |