diff --git a/components/App.tsx b/components/App.tsx deleted file mode 100644 index 512ae01..0000000 --- a/components/App.tsx +++ /dev/null @@ -1,855 +0,0 @@ -import React, { useState, useCallback, useEffect } from 'react'; -import { FlyerDisplay } from './FlyerDisplay'; -import { ExtractedDataTable } from './ExtractedDataTable'; -import { AnalysisPanel } from './AnalysisPanel'; -import { PriceChart } from './PriceChart'; -import { ErrorDisplay } from './ErrorDisplay'; -import { Header } from './Header'; -import { logger } from '../services/logger'; -import { isImageAFlyer, extractCoreDataFromImage, extractAddressFromImage, extractLogoFromImage } from '../services/geminiService'; -import type { FlyerItem, Flyer, MasterGroceryItem, DealItem, ProcessingStage, StageStatus, Profile, ShoppingList, ShoppingListItem } from '../types'; -import { BulkImporter } from './BulkImporter'; -import { PriceHistoryChart } from './PriceHistoryChart'; -import { supabase, uploadFlyerImage, createFlyerRecord, saveFlyerItems, getFlyers, getFlyerItems, findFlyerByChecksum, getWatchedItems, addWatchedItem, getAllMasterItems, getFlyerItemsForFlyers, countFlyerItemsForFlyers, getUserProfile, updateUserPreferences, removeWatchedItem, getShoppingLists, createShoppingList, addShoppingListItem, updateShoppingListItem, removeShoppingListItem, deleteShoppingList, uploadLogoAndUpdateStore } from '../services/supabaseClient'; -import { FlyerList } from './FlyerList'; -import { recordProcessingTime, getAverageProcessingTime } from '../utils/processingTimer'; -import { ProcessingStatus } from './ProcessingStatus'; -import { generateFileChecksum } from '../utils/checksum'; -import { convertPdfToImageFiles } from '../utils/pdfConverter'; -import { BulkImportSummary } from './BulkImportSummary'; -import { WatchedItemsList } from './WatchedItemsList'; -import { withTimeout } from '../utils/timeout'; -import { Session } from '@supabase/supabase-js'; -import { ProfileManager } from './ProfileManager'; -import { ShoppingListComponent } from './ShoppingList'; -import { SystemCheck } from './SystemCheck'; -import { VoiceAssistant } from './VoiceAssistant'; - -function App() { - const [flyers, setFlyers] = useState([]); - const [selectedFlyer, setSelectedFlyer] = useState(null); - const [flyerItems, setFlyerItems] = useState([]); - const [watchedItems, setWatchedItems] = useState([]); - const [masterItems, setMasterItems] = useState([]); - const [activeDeals, setActiveDeals] = useState([]); - const [activeDealsLoading, setActiveDealsLoading] = useState(false); - const [totalActiveItems, setTotalActiveItems] = useState(0); - - const [isProcessing, setIsProcessing] = useState(false); - const [error, setError] = useState(null); - - const [processingProgress, setProcessingProgress] = useState(0); - const [currentFile, setCurrentFile] = useState(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 = !!supabase; - const [isReady, setIsReady] = useState(false); - const [isDarkMode, setIsDarkMode] = useState(false); - const [unitSystem, setUnitSystem] = useState<'metric' | 'imperial'>('imperial'); - const [session, setSession] = useState(null); - const [profile, setProfile] = useState(null); - const [isProfileManagerOpen, setIsProfileManagerOpen] = useState(false); - const [isVoiceAssistantOpen, setIsVoiceAssistantOpen] = useState(false); - - const [processingStages, setProcessingStages] = useState([]); - const [estimatedTime, setEstimatedTime] = useState(0); - const [pageProgress, setPageProgress] = useState<{current: number, total: number} | null>(null); - - const [shoppingLists, setShoppingLists] = useState([]); - const [activeListId, setActiveListId] = useState(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) { - 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) { - // 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) { - const errorMessage = e instanceof Error ? e.message : String(e); - setError(errorMessage); - } - }, []); - - const fetchWatchedItems = useCallback(async (userId: string | undefined) => { - if (!supabase || !userId) { - setWatchedItems([]); - return; - } - try { - const items = await getWatchedItems(userId); - setWatchedItems(items); - } catch (e) { - const errorMessage = e instanceof Error ? e.message : String(e); - setError(`Could not fetch watched items: ${errorMessage}`); - } - }, []); - - 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) { - const errorMessage = e instanceof Error ? e.message : String(e); - setError(`Could not fetch shopping lists: ${errorMessage}`); - } - }, [activeListId]); - - const fetchMasterItems = useCallback(async () => { - if (!supabase) return; - try { - const items = await getAllMasterItems(); - setMasterItems(items); - } catch (e) { - const errorMessage = e instanceof Error ? e.message : String(e); - setError(`Could not fetch master item list: ${errorMessage}`); - } - }, []); - - // Effect to handle authentication state changes. - useEffect(() => { - if (!isDbConnected || !supabase) return; - - // This logic runs for real Supabase authentication. - // It fetches user-specific data when a session is established. - 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); - } - fetchRealUserSessionData(session); - }); - - return () => subscription.unsubscribe(); - }, [isDbConnected, fetchWatchedItems, fetchShoppingLists]); - - // Fetch global data once the database connection is ready. - useEffect(() => { - if (isReady && isDbConnected) { - fetchFlyers(); - fetchMasterItems(); - } - }, [isDbConnected, isReady, fetchFlyers, fetchMasterItems]); - - // Resets the main view when processing starts or finishes. - - 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) { - const errorMessage = e instanceof Error ? e.message : String(e); - setError(errorMessage); - } - }, []); - - 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') 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, 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) { - const errorMessage = e instanceof Error ? e.message : String(e); - logger.error("Failed to calculate total active items:", { error: errorMessage }); - setTotalActiveItems(0); - } - }; - - calculateTotalActiveItems(); - }, [flyers, isDbConnected, isReady]); - - const processFiles = async (files: File[], checksum: string, originalFileName: string, updateStage?: (index: number, updates: Partial) => 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) { - const errorMessage = e instanceof Error ? e.message : String(e); - logger.warn("Non-critical step failed: Address extraction.", { error: errorMessage }); - 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) { - const errorMessage = e instanceof Error ? e.message : String(e); - logger.warn("Non-critical step failed: Logo extraction.", { error: errorMessage }); - 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) => { - 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) => updateStage(idx + currentStageIndex, updates); - - await processFiles(filesToProcess, checksum, originalFile.name, processFilesUpdateStage); - summary.processed.push(originalFile.name); - } catch (e) { - const errorMessage = e instanceof Error ? e.message : String(e); - logger.error(`Failed to process ${originalFile.name}:`, { error: errorMessage }); - summary.errors.push({ fileName: originalFile.name, message: errorMessage }); - 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) 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) { - const errorMessage = e instanceof Error ? e.message : String(e); - setError(`Could not add watched item: ${errorMessage}`); - await fetchWatchedItems(session?.user?.id); - } - }, [session, fetchWatchedItems]); - - const handleRemoveWatchedItem = useCallback(async (masterItemId: number) => { - if (!supabase || !session) return; - try { - await removeWatchedItem(session.user.id, masterItemId); - setWatchedItems(prevItems => prevItems.filter(item => item.id !== masterItemId)); - } catch (e) { - const errorMessage = e instanceof Error ? e.message : String(e); - setError(`Could not remove watched item: ${errorMessage}`); - } - }, [session]); - - // --- Shopping List Handlers --- - const handleCreateList = useCallback(async (name: string) => { - if (!session) return; - try { - const newList = await createShoppingList(session.user.id, name); - setShoppingLists(prev => [...prev, newList]); - setActiveListId(newList.id); - } catch (e) { - const errorMessage = e instanceof Error ? e.message : String(e); - setError(`Could not create list: ${errorMessage}`); - } - }, [session]); - - const handleDeleteList = useCallback(async (listId: number) => { - if (!session) 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) { - const errorMessage = e instanceof Error ? e.message : String(e); - setError(`Could not delete list: ${errorMessage}`); - } - }, [session, shoppingLists, activeListId]); - - const handleAddShoppingListItem = useCallback(async (listId: number, item: { masterItemId?: number, customItemName?: string }) => { - if (!session) 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) { - const errorMessage = e instanceof Error ? e.message : String(e); - setError(`Could not add item to list: ${errorMessage}`); - } - }, [session]); - - const handleUpdateShoppingListItem = useCallback(async (itemId: number, updates: Partial) => { - if (!session || !activeListId) 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) { - const errorMessage = e instanceof Error ? e.message : String(e); - setError(`Could not update list item: ${errorMessage}`); - } - }, [session, activeListId]); - - const handleRemoveShoppingListItem = useCallback(async (itemId: number) => { - if (!session || !activeListId) 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) { - const errorMessage = e instanceof Error ? e.message : String(e); - setError(`Could not remove list item: ${errorMessage}`); - } - }, [session, activeListId]); - - const handleSignOut = () => { - if (supabase) { - supabase.auth.signOut(); - } - setSession(null); - }; - - - const hasData = flyerItems.length > 0; - - return ( -
-
setIsProfileManagerOpen(true)} - onOpenVoiceAssistant={() => setIsVoiceAssistantOpen(true)} - onSignOut={handleSignOut} - /> - {session && profile && ( - setIsProfileManagerOpen(false)} - session={session} - profile={profile} - onProfileUpdate={(updatedProfile) => setProfile(updatedProfile)} - /> - )} - {session && ( - setIsVoiceAssistantOpen(false)} - /> - )} -
-
- -
- - {isReady && isDbConnected && ( - - )} - setIsReady(true)} /> -
- -
- - - {isProcessing ? ( - - ) : selectedFlyer ? ( - <> - - {hasData && ( - <> - handleAddShoppingListItem(activeListId!, { masterItemId })} - /> - - - )} - - ) : importSummary ? ( - setImportSummary(null)} /> - ) : ( -
-

Welcome to Flyer Crawler!

-

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

-
- )} -
- -
- {isDbConnected && ( - <> - handleAddShoppingListItem(activeListId!, item)} - onUpdateItem={handleUpdateShoppingListItem} - onRemoveItem={handleRemoveShoppingListItem} - /> - handleAddShoppingListItem(activeListId!, { masterItemId })} - /> - - - - )} -
-
-
-
- ); -} - -export default App; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 109757e..58b918b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@google/genai": "^1.29.0", "@supabase/supabase-js": "^2.81.1", + "@tailwindcss/forms": "^0.5.10", "pdfjs-dist": "^5.4.394", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -18,7 +19,9 @@ "supabase": "^2.58.5" }, "devDependencies": { + "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/postcss": "^4.1.17", + "@tailwindcss/typography": "^0.5.19", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@types/node": "^24.10.1", @@ -2021,6 +2024,28 @@ "node": ">=20.0.0" } }, + "node_modules/@tailwindcss/container-queries": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/container-queries/-/container-queries-0.1.1.tgz", + "integrity": "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.2.0" + } + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", + "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==", + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, "node_modules/@tailwindcss/node": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", @@ -2292,6 +2317,19 @@ "tailwindcss": "4.1.17" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -3639,6 +3677,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/cssstyle": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.3.tgz", @@ -6523,6 +6574,15 @@ "node": ">=4" } }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -6989,6 +7049,20 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -8057,7 +8131,6 @@ "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "dev": true, "license": "MIT" }, "node_modules/tapable": { @@ -8428,6 +8501,13 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", diff --git a/package.json b/package.json index dc54cbd..800fe3b 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "@google/genai": "^1.29.0", "@supabase/supabase-js": "^2.81.1", + "@tailwindcss/forms": "^0.5.10", "pdfjs-dist": "^5.4.394", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -21,7 +22,9 @@ "supabase": "^2.58.5" }, "devDependencies": { + "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/postcss": "^4.1.17", + "@tailwindcss/typography": "^0.5.19", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@types/node": "^24.10.1", diff --git a/tailwind.config.js b/tailwind.config.js index 162d925..c5c3691 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -24,5 +24,9 @@ export default { } }, }, - plugins: [], + plugins: [ + require('@tailwindcss/forms'), + require('@tailwindcss/typography'), + require('@tailwindcss/container-queries'), + ], } \ No newline at end of file