893 lines
35 KiB
TypeScript
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; |