css issues
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 14s
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 14s
This commit is contained in:
@@ -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<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 = !!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) {
|
||||
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<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) {
|
||||
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<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) {
|
||||
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<ShoppingListItem>) => {
|
||||
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 (
|
||||
<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 && (
|
||||
<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">
|
||||
<FlyerList flyers={flyers} onFlyerSelect={handleFlyerSelect} selectedFlyerId={selectedFlyer?.id || null} />
|
||||
{isReady && isDbConnected && (
|
||||
<BulkImporter
|
||||
onProcess={handleProcessFiles}
|
||||
isProcessing={isProcessing}
|
||||
/>
|
||||
)}
|
||||
<SystemCheck onReady={() => setIsReady(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;
|
||||
82
package-lock.json
generated
82
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -24,5 +24,9 @@ export default {
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
require('@tailwindcss/typography'),
|
||||
require('@tailwindcss/container-queries'),
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user