Files
flyer-crawler.projectium.com/App.tsx

893 lines
35 KiB
TypeScript

import React, { useState, useCallback, useEffect } from 'react';
import { FlyerDisplay } from './components/FlyerDisplay';
import { ExtractedDataTable } from './components/ExtractedDataTable';
import { AnalysisPanel } from './components/AnalysisPanel';
import { PriceChart } from './components/PriceChart';
import { ErrorDisplay } from './components/ErrorDisplay';
import { Header } from './components/Header';
import { isImageAFlyer, extractCoreDataFromImage, extractAddressFromImage, extractLogoFromImage } from './services/geminiService';
import type { FlyerItem, Flyer, MasterGroceryItem, DealItem, ProcessingStage, StageStatus, Store, Profile, ShoppingList, ShoppingListItem } from './types';
import { BulkImporter } from './components/BulkImporter';
import { PriceHistoryChart } from './components/PriceHistoryChart';
import { supabase, uploadFlyerImage, createFlyerRecord, saveFlyerItems, getFlyers, getFlyerItems, initializeSupabase, findFlyerByChecksum, getWatchedItems, addWatchedItem, getAllMasterItems, getFlyerItemsForFlyers, countFlyerItemsForFlyers, getUserProfile, updateUserPreferences, removeWatchedItem, getShoppingLists, createShoppingList, addShoppingListItem, updateShoppingListItem, removeShoppingListItem, deleteShoppingList, uploadLogoAndUpdateStore } from './services/supabaseClient';
import { FlyerList } from './components/FlyerList';
import { recordProcessingTime, getAverageProcessingTime } from './utils/processingTimer';
import { ProcessingStatus } from './components/ProcessingStatus';
import { SupabaseConnector } from './components/SupabaseConnector';
import { generateFileChecksum } from './utils/checksum';
import { convertPdfToImageFiles } from './utils/pdfConverter';
import { BulkImportSummary } from './components/BulkImportSummary';
import { WatchedItemsList } from './components/WatchedItemsList';
import { withTimeout } from './utils/timeout';
import { Session } from '@supabase/supabase-js';
import { ProfileManager } from './components/ProfileManager';
import { ShoppingListComponent } from './components/ShoppingList';
import { SystemCheck } from './components/SystemCheck';
import { LoginPage } from './components/LoginPage';
import { VoiceAssistant } from './components/VoiceAssistant';
function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isFakeAuth, setIsFakeAuth] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [flyers, setFlyers] = useState<Flyer[]>([]);
const [selectedFlyer, setSelectedFlyer] = useState<Flyer | null>(null);
const [flyerItems, setFlyerItems] = useState<FlyerItem[]>([]);
const [watchedItems, setWatchedItems] = useState<MasterGroceryItem[]>([]);
const [masterItems, setMasterItems] = useState<MasterGroceryItem[]>([]);
const [activeDeals, setActiveDeals] = useState<DealItem[]>([]);
const [activeDealsLoading, setActiveDealsLoading] = useState(false);
const [totalActiveItems, setTotalActiveItems] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [processingProgress, setProcessingProgress] = useState(0);
const [currentFile, setCurrentFile] = useState<string | null>(null);
const [fileCount, setFileCount] = useState<{current: number, total: number} | null>(null);
const [importSummary, setImportSummary] = useState<{
processed: string[];
skipped: string[];
errors: { fileName: string; message: string }[];
} | null>(null);
const [isDbConnected, setIsDbConnected] = useState(!!supabase);
const [isReady, setIsReady] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
const [unitSystem, setUnitSystem] = useState<'metric' | 'imperial'>('imperial');
const [session, setSession] = useState<Session | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [isProfileManagerOpen, setIsProfileManagerOpen] = useState(false);
const [isVoiceAssistantOpen, setIsVoiceAssistantOpen] = useState(false);
const [processingStages, setProcessingStages] = useState<ProcessingStage[]>([]);
const [estimatedTime, setEstimatedTime] = useState(0);
const [pageProgress, setPageProgress] = useState<{current: number, total: number} | null>(null);
const [shoppingLists, setShoppingLists] = useState<ShoppingList[]>([]);
const [activeListId, setActiveListId] = useState<number | null>(null);
// 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]);
const toggleDarkMode = async () => {
const newMode = !isDarkMode;
setIsDarkMode(newMode);
document.documentElement.classList.toggle('dark', newMode);
if (session && !isFakeAuth) {
const newPreferences = { ...profile?.preferences, darkMode: newMode };
setProfile(p => p ? {...p, preferences: newPreferences} : null);
await updateUserPreferences(session.user.id, newPreferences);
} else {
localStorage.setItem('darkMode', String(newMode));
}
};
const toggleUnitSystem = async () => {
const newSystem = unitSystem === 'metric' ? 'imperial' : 'metric';
setUnitSystem(newSystem);
if (session && !isFakeAuth) {
// FIX: Explicitly type `newPreferences` to prevent TypeScript from incorrectly widening `newSystem` to a generic `string`.
// This ensures compatibility with the `Profile` type definition.
const newPreferences: Profile['preferences'] = { ...profile?.preferences, unitSystem: newSystem };
setProfile(p => p ? {...p, preferences: newPreferences} : null);
await updateUserPreferences(session.user.id, newPreferences);
} else {
localStorage.setItem('unitSystem', newSystem);
}
};
const fetchFlyers = useCallback(async () => {
if (!supabase) return;
try {
const allFlyers = await getFlyers();
setFlyers(allFlyers);
} catch(e: any) {
setError(e.message);
}
}, []);
const fetchWatchedItems = useCallback(async (userId: string | undefined) => {
if (!supabase || !userId) {
setWatchedItems([]);
return;
}
try {
const items = await getWatchedItems(userId);
setWatchedItems(items);
} catch (e: any) {
setError(`Could not fetch watched items: ${e.message}`);
}
}, []);
const fetchShoppingLists = useCallback(async (userId: string | undefined) => {
if (!supabase || !userId) {
setShoppingLists([]);
setActiveListId(null);
return;
}
try {
const lists = await getShoppingLists(userId);
setShoppingLists(lists);
if (lists.length > 0 && !activeListId) {
setActiveListId(lists[0].id);
} else if (lists.length === 0) {
setActiveListId(null);
}
} catch (e: any) {
setError(`Could not fetch shopping lists: ${e.message}`);
}
}, [activeListId]);
const fetchMasterItems = useCallback(async () => {
if (!supabase) return;
try {
const items = await getAllMasterItems();
setMasterItems(items);
} catch (e: any) {
setError(`Could not fetch master item list: ${e.message}`);
}
}, []);
useEffect(() => {
if (!supabase) {
const storedUrl = localStorage.getItem('supabaseUrl');
const storedKey = localStorage.getItem('supabaseAnonKey');
if (storedUrl && storedKey) {
initializeSupabase(storedUrl, storedKey);
setIsDbConnected(true);
}
} else {
setIsDbConnected(true);
}
}, []);
// Effect to handle authentication state changes.
useEffect(() => {
if (!isDbConnected || !supabase) return;
// If using fake auth, set up a mock session and state.
// We don't listen to Supabase auth changes in this mode.
if (isFakeAuth) {
const mockSession = {
user: { id: 'test-user-123', email: 'test@test.com' },
} as unknown as Session;
setSession(mockSession);
setProfile({ id: 'test-user-123' });
// User-specific data is empty for the fake user.
setWatchedItems([]);
setShoppingLists([]);
return; // Early return to avoid setting up the real auth listener.
}
// This logic ONLY runs for real Supabase authentication.
const fetchRealUserSessionData = async (session: Session | null) => {
setSession(session);
if (session) {
const userProfile = await getUserProfile(session.user.id);
setProfile(userProfile);
fetchWatchedItems(session.user.id);
fetchShoppingLists(session.user.id);
} else {
setProfile(null);
setWatchedItems([]);
setShoppingLists([]);
}
};
supabase.auth.getSession().then(({ data: { session } }) => {
fetchRealUserSessionData(session);
});
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
if (_event === "SIGNED_OUT") {
setIsProfileManagerOpen(false);
// On sign out, always de-authenticate, regardless of fake auth status.
setIsAuthenticated(false);
setIsFakeAuth(false);
}
fetchRealUserSessionData(session);
});
return () => subscription.unsubscribe();
}, [isDbConnected, fetchWatchedItems, fetchShoppingLists, isFakeAuth]);
useEffect(() => {
if (isDbConnected && isReady) {
fetchFlyers();
fetchMasterItems();
}
}, [isDbConnected, isReady, fetchFlyers, fetchMasterItems]);
const resetState = useCallback(() => {
setSelectedFlyer(null);
setFlyerItems([]);
setError(null);
setProcessingProgress(0);
setProcessingStages([]);
setImportSummary(null);
setCurrentFile(null);
setPageProgress(null);
setFileCount(null);
}, []);
const handleFlyerSelect = useCallback(async (flyer: Flyer) => {
setSelectedFlyer(flyer);
setError(null);
setFlyerItems([]); // Clear previous items
if (!supabase) return;
try {
const items = await getFlyerItems(flyer.id);
setFlyerItems(items);
} catch (e: any) {
setError(e.message);
}
}, []);
useEffect(() => {
if (!isProcessing && !selectedFlyer && flyers.length > 0) {
handleFlyerSelect(flyers[0]);
}
}, [flyers, selectedFlyer, handleFlyerSelect, isProcessing]);
useEffect(() => {
const findActiveDeals = async () => {
if (!isDbConnected || !isReady || flyers.length === 0 || watchedItems.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) {
console.error("Error parsing flyer date", e);
return false;
}
});
if (validFlyers.length === 0) {
setActiveDeals([]);
return;
}
const validFlyerIds = validFlyers.map(f => f.id);
const allItems = await getFlyerItemsForFlyers(validFlyerIds);
const watchedItemIds = new Set(watchedItems.map(item => item.id));
const dealItemsRaw = allItems.filter(item =>
item.master_item_id && watchedItemIds.has(item.master_item_id)
);
const flyerIdToStoreName = new Map(validFlyers.map(f => [f.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',
master_item_name: item.master_item_name,
unit_price: item.unit_price,
}));
setActiveDeals(deals);
} catch (e: any) {
setError(`Could not fetch active deals: ${e.message}`);
} finally {
setActiveDealsLoading(false);
}
};
findActiveDeals();
}, [flyers, watchedItems, isDbConnected, isReady]);
useEffect(() => {
const calculateTotalActiveItems = async () => {
if (!isDbConnected || !isReady || 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) {
console.error("Error parsing flyer date", e);
return false;
}
});
if (validFlyers.length === 0) {
setTotalActiveItems(0);
return;
}
const validFlyerIds = validFlyers.map(f => f.id);
const totalCount = await countFlyerItemsForFlyers(validFlyerIds);
setTotalActiveItems(totalCount);
} catch (e: any) {
console.error("Failed to calculate total active items:", e.message);
setTotalActiveItems(0);
}
};
calculateTotalActiveItems();
}, [flyers, isDbConnected, isReady]);
const processFiles = async (files: File[], checksum: string, originalFileName: string, updateStage?: (index: number, updates: Partial<ProcessingStage>) => void) => {
let stageIndex = 0;
// Stage: Validating Flyer
updateStage?.(stageIndex, { status: 'in-progress' });
const isFlyer = await withTimeout(isImageAFlyer(files[0]), 15000);
if (!isFlyer) {
throw new Error("The uploaded image does not appear to be a grocery flyer.");
}
updateStage?.(stageIndex++, { status: 'completed' }); // stageIndex is now 1
const pageCount = files.length;
const coreDataTimeout = 60000 * pageCount;
const nonCriticalTimeout = 30000;
// Granular stages for core data extraction
const storeInfoStageIndex = stageIndex; // Stage 1: Extracting Store Name & Sale Dates
const itemExtractionStageIndex = stageIndex + 1; // Stage 2: Extracting All Items from Flyer
// Mark both stages as in-progress for the single AI call
updateStage?.(storeInfoStageIndex, { status: 'in-progress' });
updateStage?.(itemExtractionStageIndex, { status: 'in-progress', detail: pageCount > 1 ? `(${pageCount} pages)` : undefined });
let progressInterval: number | undefined;
let extractedData;
try {
if (pageCount > 1) {
let currentPage = 0;
const intervalTime = 2500;
// Attach progress bar to the item extraction stage
progressInterval = window.setInterval(() => {
currentPage++;
if (currentPage <= pageCount) {
updateStage?.(itemExtractionStageIndex, { progress: { current: currentPage, total: pageCount } });
} else {
clearInterval(progressInterval);
}
}, intervalTime);
}
extractedData = await withTimeout(extractCoreDataFromImage(files, masterItems), coreDataTimeout);
// Mark both stages as completed after the AI call finishes
updateStage?.(storeInfoStageIndex, { status: 'completed' });
updateStage?.(itemExtractionStageIndex, { status: 'completed', progress: null });
} finally {
if (progressInterval) {
clearInterval(progressInterval);
}
}
const { store_name, valid_from, valid_to, items: extractedItems } = extractedData;
stageIndex += 2; // Increment by 2 for the stages we just completed. stageIndex is now 3
// Stage: Extracting Store Address
let storeAddress: string | null = null;
try {
updateStage?.(stageIndex, { status: 'in-progress' });
storeAddress = await withTimeout(extractAddressFromImage(files[0]), nonCriticalTimeout);
updateStage?.(stageIndex++, { status: 'completed' }); // stageIndex is now 4
} catch (e: any) {
console.warn("Non-critical step failed: Address extraction.", e.message);
updateStage?.(stageIndex++, { status: 'error', detail: '(Skipped)' }); // stageIndex is now 4
}
// Stage: Extracting Store Logo
let storeLogoBase64: string | null = null;
try {
updateStage?.(stageIndex, { status: 'in-progress' });
const logoData = await withTimeout(extractLogoFromImage(files.slice(0, 1)), nonCriticalTimeout);
storeLogoBase64 = logoData.store_logo_base_64;
updateStage?.(stageIndex++, { status: 'completed' }); // stageIndex is now 5
} catch (e: any) {
console.warn("Non-critical step failed: Logo extraction.", e.message);
updateStage?.(stageIndex++, { status: 'error', detail: '(Skipped)' }); // stageIndex is now 5
}
if (!supabase) {
throw new Error("Cannot process flyer: Supabase client not initialized.");
}
// Stage: Uploading Flyer Image
updateStage?.(stageIndex, { status: 'in-progress' });
const imageUrl = await withTimeout(uploadFlyerImage(files[0]), 30000);
updateStage?.(stageIndex++, { status: 'completed' }); // stageIndex is now 6
// Stage: Creating Database Record
updateStage?.(stageIndex, { status: 'in-progress' });
const newFlyer = await withTimeout(createFlyerRecord(originalFileName, imageUrl, checksum, store_name, valid_from, valid_to, storeAddress), 10000);
if (!newFlyer) {
throw new Error("Could not create a record for the new flyer.");
}
// Upload logo if extracted and if the store doesn't have one already.
// This is a non-critical, fire-and-forget task.
if (storeLogoBase64 && newFlyer.store_id && !newFlyer.store?.logo_url) {
uploadLogoAndUpdateStore(newFlyer.store_id, storeLogoBase64);
}
updateStage?.(stageIndex++, { status: 'completed' }); // stageIndex is now 7
// Stage: Saving Items to Database
updateStage?.(stageIndex, { status: 'in-progress' });
const savedItems = await withTimeout(saveFlyerItems(extractedItems, newFlyer.id), 20000);
updateStage?.(stageIndex, { status: 'completed' });
return { newFlyer, items: savedItems };
};
const setupProcessingStages = (isPdf: boolean) => {
const pendingStatus: StageStatus = 'pending';
const isDbAvailable = !!supabase;
const baseStages: ProcessingStage[] = [
...(isDbAvailable ? [{ name: 'Checking for Duplicates', status: pendingStatus, critical: true }] : []),
{ name: 'Validating Flyer', status: pendingStatus, critical: true },
{ name: 'Extracting Store Name & Sale Dates', status: pendingStatus, critical: true },
{ name: 'Extracting All Items from Flyer', status: pendingStatus, critical: true },
{ name: 'Extracting Store Address', status: pendingStatus, critical: false },
{ name: 'Extracting Store Logo', status: pendingStatus, critical: false },
...(isDbAvailable ? [
{ name: 'Uploading Flyer Image', status: pendingStatus, critical: true },
{ name: 'Creating Database Record', status: pendingStatus, critical: true },
{ name: 'Saving Items to Database', status: pendingStatus, critical: true },
] : []),
];
if (isPdf) {
return [
{ name: 'Analyzing PDF', status: pendingStatus, critical: true },
{ name: 'Converting PDF to Images', status: pendingStatus, critical: true },
...baseStages
];
}
return baseStages;
};
const handleProcessFiles = useCallback(async (files: FileList) => {
if (files.length === 0) return;
resetState();
setIsProcessing(true);
setProcessingProgress(0);
setError(null);
if (!supabase) {
setError("A database connection is required to process flyers.");
setIsProcessing(false);
return;
}
const summary = {
processed: [] as string[],
skipped: [] as string[],
errors: [] as { fileName: string; message: string }[],
};
const avgTime = getAverageProcessingTime();
setEstimatedTime(avgTime * files.length);
for (let i = 0; i < files.length; i++) {
const originalFile = files[i];
setCurrentFile(originalFile.name);
setFileCount({ current: i + 1, total: files.length });
setPageProgress(null);
const isPdf = originalFile.type === 'application/pdf';
setProcessingStages(setupProcessingStages(isPdf));
const updateStage = (index: number, updates: Partial<ProcessingStage>) => {
setProcessingStages(prev =>
prev.map((stage, j) => (j === index ? { ...stage, ...updates } : stage))
);
};
let currentStageIndex = 0;
const startTime = Date.now();
try {
let filesToProcess: File[];
let checksum = '';
if (isPdf) {
updateStage(currentStageIndex, { status: 'in-progress' });
const onPdfProgress = (currentPage: number, totalPages: number) => {
setPageProgress({ current: currentPage, total: totalPages });
};
const { imageFiles, pageCount } = await convertPdfToImageFiles(originalFile, onPdfProgress);
filesToProcess = imageFiles;
setPageProgress(null);
updateStage(currentStageIndex++, { status: 'completed', detail: `(${pageCount} pages)` });
updateStage(currentStageIndex++, { status: 'completed' });
} else {
filesToProcess = [originalFile];
}
if (supabase) {
updateStage(currentStageIndex, { status: 'in-progress' });
checksum = await generateFileChecksum(originalFile);
const existing = await findFlyerByChecksum(checksum);
if (existing) {
console.log(`Skipping duplicate file: ${originalFile.name}`);
summary.skipped.push(originalFile.name);
updateStage(currentStageIndex, { status: 'completed', detail: '(Duplicate)' });
setProcessingProgress(((i + 1) / files.length) * 100);
continue;
}
updateStage(currentStageIndex++, { status: 'completed' });
}
const processFilesUpdateStage = (idx: number, updates: Partial<ProcessingStage>) => updateStage(idx + currentStageIndex, updates);
await processFiles(filesToProcess, checksum, originalFile.name, processFilesUpdateStage);
summary.processed.push(originalFile.name);
} catch (e: any) {
console.error(`Failed to process ${originalFile.name}:`, e);
summary.errors.push({ fileName: originalFile.name, message: e.message });
setProcessingStages(prev => prev.map(stage => {
if (stage.status === 'in-progress' && (stage.critical ?? true)) {
return {...stage, status: 'error'};
}
return stage;
}));
await new Promise(resolve => setTimeout(resolve, 2000));
} finally {
const duration = (Date.now() - startTime) / 1000;
recordProcessingTime(duration);
}
setProcessingProgress(((i + 1) / files.length) * 100);
}
await fetchFlyers();
await fetchMasterItems();
setImportSummary(summary);
setIsProcessing(false);
setCurrentFile(null);
setPageProgress(null);
setFileCount(null);
}, [resetState, fetchFlyers, masterItems, fetchMasterItems]);
const handleAddWatchedItem = useCallback(async (itemName: string, category: string) => {
if (!supabase || !session || isFakeAuth) return;
try {
const updatedOrNewItem = await addWatchedItem(session.user.id, itemName, category);
setWatchedItems(prevItems => {
const itemExists = prevItems.some(item => item.id === updatedOrNewItem.id);
if (!itemExists) {
const newItems = [...prevItems, updatedOrNewItem];
return newItems.sort((a,b) => a.name.localeCompare(b.name));
}
return prevItems; // Item already existed in list
});
} catch (e: any) {
setError(`Could not add watched item: ${e.message}`);
await fetchWatchedItems(session?.user?.id);
}
}, [session, fetchWatchedItems, isFakeAuth]);
const handleRemoveWatchedItem = useCallback(async (masterItemId: number) => {
if (!supabase || !session || isFakeAuth) return;
try {
await removeWatchedItem(session.user.id, masterItemId);
setWatchedItems(prevItems => prevItems.filter(item => item.id !== masterItemId));
} catch (e: any) {
setError(`Could not remove watched item: ${e.message}`);
}
}, [session, isFakeAuth]);
// --- Shopping List Handlers ---
const handleCreateList = useCallback(async (name: string) => {
if (!session || isFakeAuth) return;
try {
const newList = await createShoppingList(session.user.id, name);
setShoppingLists(prev => [...prev, newList]);
setActiveListId(newList.id);
} catch (e: any) {
setError(`Could not create list: ${e.message}`);
}
}, [session, isFakeAuth]);
const handleDeleteList = useCallback(async (listId: number) => {
if (!session || isFakeAuth) return;
try {
await deleteShoppingList(listId);
const newLists = shoppingLists.filter(l => l.id !== listId);
setShoppingLists(newLists);
if (activeListId === listId) {
setActiveListId(newLists.length > 0 ? newLists[0].id : null);
}
} catch (e: any) {
setError(`Could not delete list: ${e.message}`);
}
}, [session, shoppingLists, activeListId, isFakeAuth]);
const handleAddShoppingListItem = useCallback(async (listId: number, item: { masterItemId?: number, customItemName?: string }) => {
if (!session || isFakeAuth) return;
try {
const newItem = await addShoppingListItem(listId, item);
setShoppingLists(prevLists => prevLists.map(list => {
if (list.id === listId) {
// Avoid adding duplicates to the state if it's already there
const itemExists = list.items.some(i => i.id === newItem.id);
if (itemExists) return list;
return { ...list, items: [...list.items, newItem] };
}
return list;
}));
} catch (e: any) {
setError(`Could not add item to list: ${e.message}`);
}
}, [session, isFakeAuth]);
const handleUpdateShoppingListItem = useCallback(async (itemId: number, updates: Partial<ShoppingListItem>) => {
if (!session || !activeListId || isFakeAuth) return;
try {
const updatedItem = await updateShoppingListItem(itemId, updates);
setShoppingLists(prevLists => prevLists.map(list => {
if (list.id === activeListId) {
return { ...list, items: list.items.map(i => i.id === itemId ? updatedItem : i) };
}
return list;
}));
} catch (e: any) {
setError(`Could not update list item: ${e.message}`);
}
}, [session, activeListId, isFakeAuth]);
const handleRemoveShoppingListItem = useCallback(async (itemId: number) => {
if (!session || !activeListId || isFakeAuth) return;
try {
await removeShoppingListItem(itemId);
setShoppingLists(prevLists => prevLists.map(list => {
if (list.id === activeListId) {
return { ...list, items: list.items.filter(i => i.id !== itemId) };
}
return list;
}));
} catch (e: any) {
setError(`Could not remove list item: ${e.message}`);
}
}, [session, activeListId, isFakeAuth]);
const handleFakeLogin = (email: string, pass: string) => {
if (email === 'test@test.com' && pass === 'pass123') {
setIsAuthenticated(true);
setIsFakeAuth(true); // Mark that we are using fake auth
setLoginError(null);
} else {
setLoginError('Invalid credentials');
}
};
const handleSignOut = () => {
if (supabase && !isFakeAuth) {
supabase.auth.signOut();
}
// For fake auth, signing out just resets local state.
// The onAuthStateChange listener will handle real sign-outs.
setIsAuthenticated(false);
setIsFakeAuth(false);
setSession(null);
};
const hasData = flyerItems.length > 0;
if (!isAuthenticated) {
return <LoginPage onLogin={handleFakeLogin} error={loginError} />;
}
return (
<div className="bg-gray-100 dark:bg-gray-950 min-h-screen font-sans text-gray-800 dark:text-gray-200">
<Header
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
unitSystem={unitSystem}
toggleUnitSystem={toggleUnitSystem}
session={session}
onOpenProfile={() => setIsProfileManagerOpen(true)}
onOpenVoiceAssistant={() => setIsVoiceAssistantOpen(true)}
onSignOut={handleSignOut}
/>
{session && profile && !isFakeAuth && (
<ProfileManager
isOpen={isProfileManagerOpen}
onClose={() => setIsProfileManagerOpen(false)}
session={session}
profile={profile}
onProfileUpdate={(updatedProfile) => setProfile(updatedProfile)}
/>
)}
{session && (
<VoiceAssistant
isOpen={isVoiceAssistantOpen}
onClose={() => setIsVoiceAssistantOpen(false)}
/>
)}
<main className="max-w-screen-2xl mx-auto py-4 px-2.5 sm:py-6 lg:py-8">
<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">
{isDbConnected ? (
<>
<FlyerList flyers={flyers} onFlyerSelect={handleFlyerSelect} selectedFlyerId={selectedFlyer?.id || null} />
{isReady && (
<BulkImporter
onProcess={handleProcessFiles}
isProcessing={isProcessing}
/>
)}
<SystemCheck onReady={() => setIsReady(true)} />
</>
) : (
<SupabaseConnector onSuccess={() => setIsDbConnected(true)} />
)}
</div>
<div className="lg:col-span-2 flex flex-col space-y-6">
<ErrorDisplay message={error} />
{isProcessing ? (
<ProcessingStatus
stages={processingStages}
estimatedTime={estimatedTime}
currentFile={currentFile}
pageProgress={pageProgress}
bulkProgress={processingProgress}
bulkFileCount={fileCount}
/>
) : selectedFlyer ? (
<>
<FlyerDisplay
imageUrl={selectedFlyer.image_url}
store={selectedFlyer.store}
validFrom={selectedFlyer.valid_from}
validTo={selectedFlyer.valid_to}
storeAddress={selectedFlyer.store_address}
/>
{hasData && (
<>
<ExtractedDataTable
items={flyerItems}
totalActiveItems={totalActiveItems}
watchedItems={watchedItems}
masterItems={masterItems}
unitSystem={unitSystem}
session={session}
onAddItem={handleAddWatchedItem}
shoppingLists={shoppingLists}
activeListId={activeListId}
onAddItemToList={(masterItemId) => handleAddShoppingListItem(activeListId!, { masterItemId })}
/>
<AnalysisPanel flyerItems={flyerItems} store={selectedFlyer.store} />
</>
)}
</>
) : importSummary ? (
<BulkImportSummary summary={importSummary} onDismiss={() => setImportSummary(null)} />
) : (
<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">
{isDbConnected && (
<>
<ShoppingListComponent
session={session}
lists={shoppingLists}
activeListId={activeListId}
onSelectList={setActiveListId}
onCreateList={handleCreateList}
onDeleteList={handleDeleteList}
onAddItem={(item) => handleAddShoppingListItem(activeListId!, item)}
onUpdateItem={handleUpdateShoppingListItem}
onRemoveItem={handleRemoveShoppingListItem}
/>
<WatchedItemsList
items={watchedItems}
onAddItem={handleAddWatchedItem}
onRemoveItem={handleRemoveWatchedItem}
session={session}
activeListId={activeListId}
onAddItemToList={(masterItemId) => handleAddShoppingListItem(activeListId!, { masterItemId })}
/>
<PriceChart
deals={activeDeals}
isLoading={activeDealsLoading}
unitSystem={unitSystem}
session={session}
/>
<PriceHistoryChart watchedItems={watchedItems} />
</>
)}
</div>
</div>
</main>
</div>
);
}
export default App;