From 8d12f744bca18398170e128173e9f26548c5f982 Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Wed, 10 Dec 2025 12:42:07 -0800 Subject: [PATCH] main page improvements --- src/components/UserMenuSkeleton.tsx | 15 ++ src/features/flyer/BulkImporter.tsx | 107 +++++--- src/features/flyer/FlyerUploader.tsx | 142 +++++----- src/features/flyer/ProcessingStatus.tsx | 116 ++------ src/hooks/useData.tsx | 10 +- src/hooks/useDragAndDrop.ts | 60 +++++ src/pages/admin/components/AuthView.tsx | 142 ++++++++++ src/pages/admin/components/ProfileManager.tsx | 250 +++++------------- src/services/aiService.server.ts | 2 +- 9 files changed, 448 insertions(+), 396 deletions(-) create mode 100644 src/components/UserMenuSkeleton.tsx create mode 100644 src/hooks/useDragAndDrop.ts create mode 100644 src/pages/admin/components/AuthView.tsx diff --git a/src/components/UserMenuSkeleton.tsx b/src/components/UserMenuSkeleton.tsx new file mode 100644 index 00000000..c8d38ae3 --- /dev/null +++ b/src/components/UserMenuSkeleton.tsx @@ -0,0 +1,15 @@ +// src/components/UserMenuSkeleton.tsx +import React from 'react'; + +/** + * A simple skeleton loader to be displayed in the header while + * the user's authentication status is being determined. + */ +export const UserMenuSkeleton: React.FC = () => { + return ( +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/features/flyer/BulkImporter.tsx b/src/features/flyer/BulkImporter.tsx index e30c3779..b64cfecb 100644 --- a/src/features/flyer/BulkImporter.tsx +++ b/src/features/flyer/BulkImporter.tsx @@ -1,59 +1,73 @@ // src/features/flyer/BulkImporter.tsx -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useState, useEffect } from 'react'; import { UploadIcon } from '../../components/icons/UploadIcon'; +import { useDragAndDrop } from '../../hooks/useDragAndDrop'; +import { XMarkIcon } from '../../components/icons/XMarkIcon'; +import { DocumentTextIcon } from '../../components/icons/DocumentTextIcon'; interface BulkImporterProps { - onProcess: (files: FileList) => void; + onFilesChange: (files: File[]) => void; isProcessing: boolean; } -export const BulkImporter: React.FC = ({ onProcess, isProcessing }) => { - const [isDragging, setIsDragging] = useState(false); +export const BulkImporter: React.FC = ({ onFilesChange, isProcessing }) => { + const [selectedFiles, setSelectedFiles] = useState([]); + const [previewUrls, setPreviewUrls] = useState([]); - const handleFiles = (files: FileList | null) => { + // Effect to update parent component when files change + useEffect(() => { + onFilesChange(selectedFiles); + }, [selectedFiles, onFilesChange]); + + // Effect to create and revoke object URLs for image previews + useEffect(() => { + const newUrls = selectedFiles.map(file => + file.type.startsWith('image/') ? URL.createObjectURL(file) : '' + ); + setPreviewUrls(newUrls); + + // Cleanup function to revoke URLs when component unmounts or files change + return () => { + newUrls.forEach(url => { + if (url) URL.revokeObjectURL(url); + }); + }; + }, [selectedFiles]); + + const handleFiles = useCallback((files: FileList) => { if (files && files.length > 0 && !isProcessing) { - onProcess(files); + // Prevent duplicates by checking file names and sizes + const newFiles = Array.from(files).filter(newFile => + !selectedFiles.some(existingFile => + existingFile.name === newFile.name && existingFile.size === newFile.size + ) + ); + setSelectedFiles(prev => [...prev, ...newFiles]); } - }; + }, [isProcessing, selectedFiles]); const handleFileChange = (e: React.ChangeEvent) => { - handleFiles(e.target.files); + if (e.target.files) { + handleFiles(e.target.files); + } // Reset input value to allow selecting the same file again e.target.value = ''; }; - const handleDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (!isProcessing) setIsDragging(true); - }, [isProcessing]); + const removeFile = (index: number) => { + setSelectedFiles(prev => prev.filter((_, i) => i !== index)); + }; - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - }, []); - - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - if (!isProcessing && e.dataTransfer.files) { - handleFiles(e.dataTransfer.files); - } - }, [isProcessing, onProcess]); + const { isDragging, dropzoneProps } = useDragAndDrop({ onFilesDropped: handleFiles, disabled: isProcessing }); const borderColor = isDragging ? 'border-brand-primary' : 'border-gray-300 dark:border-gray-600'; const bgColor = isDragging ? 'bg-brand-light/50 dark:bg-brand-dark/20' : 'bg-gray-50 dark:bg-gray-800'; return ( -
+
+ + {selectedFiles.length > 0 && ( +
+ {selectedFiles.map((file, index) => ( +
+ {previewUrls[index] ? ( + {file.name} + ) : ( +
+ +
+ )} +
{file.name}
+ +
+ ))} +
+ )}
); }; \ No newline at end of file diff --git a/src/features/flyer/FlyerUploader.tsx b/src/features/flyer/FlyerUploader.tsx index 47c87db7..2615f765 100644 --- a/src/features/flyer/FlyerUploader.tsx +++ b/src/features/flyer/FlyerUploader.tsx @@ -4,11 +4,9 @@ import { useNavigate } from 'react-router-dom'; import { uploadAndProcessFlyer, getJobStatus } from '../../services/aiApiClient'; import { generateFileChecksum } from '../../utils/checksum'; // Assuming you have this utility import { logger } from '../../services/logger.client'; - -// A simple loading spinner component (you can replace this with your own) -const LoadingSpinner = () => ( -
-); +import { ProcessingStatus } from './ProcessingStatus'; +import type { ProcessingStage } from '../../types'; +import { useDragAndDrop } from '../../hooks/useDragAndDrop'; type ProcessingState = 'idle' | 'uploading' | 'polling' | 'completed' | 'error'; @@ -18,15 +16,19 @@ interface FlyerUploaderProps { export const FlyerUploader: React.FC = ({ onProcessingComplete }) => { const [processingState, setProcessingState] = useState('idle'); - const [statusMessage, setStatusMessage] = useState('Select a flyer (PDF or image) to begin.'); + const [statusMessage, setStatusMessage] = useState(null); const [jobId, setJobId] = useState(null); const [errorMessage, setErrorMessage] = useState(null); - const [isDragging, setIsDragging] = useState(false); const navigate = useNavigate(); // Use a ref to manage the polling timeout to prevent memory leaks const pollingTimeoutRef = useRef(null); + // State for the ProcessingStatus component + const [processingStages, setProcessingStages] = useState([]); + const [estimatedTime, setEstimatedTime] = useState(0); + const [currentFile, setCurrentFile] = useState(null); + // This effect handles the polling logic useEffect(() => { if (processingState !== 'polling' || !jobId) { @@ -42,9 +44,11 @@ export const FlyerUploader: React.FC = ({ onProcessingComple const job = await statusResponse.json(); - // Update the UI with the latest progress message from the worker - if (job.progress && job.progress.message) { - setStatusMessage(job.progress.message); + // Update the UI with the latest progress details from the worker + if (job.progress) { + setProcessingStages(job.progress.stages || []); + setEstimatedTime(job.progress.estimatedTimeRemaining || 0); + setStatusMessage(job.progress.message || null); } switch (job.state) { @@ -92,10 +96,12 @@ export const FlyerUploader: React.FC = ({ onProcessingComple }, [processingState, jobId, onProcessingComplete, navigate]); const processFile = useCallback(async (file: File) => { - setProcessingState('uploading'); setErrorMessage(null); setStatusMessage('Calculating file checksum...'); + setProcessingState('uploading'); + setErrorMessage(null); + setCurrentFile(file.name); try { - const checksum = await generateFileChecksum(file); + const checksum = await generateFileChecksum(file); // Checksum calculation can be part of the initial stages setStatusMessage('Uploading file...'); const startResponse = await uploadAndProcessFlyer(file, checksum); @@ -105,7 +111,7 @@ export const FlyerUploader: React.FC = ({ onProcessingComple // Handle specific errors like duplicates if (startResponse.status === 409 && errorData.flyerId) { setErrorMessage(`This flyer has already been processed. You can view it here:`); - // Provide a link to the existing flyer + // Provide a link to the existing flyer in the status message setStatusMessage(`Flyer #${errorData.flyerId}`); } else { setErrorMessage(errorData.message || `Upload failed with status ${startResponse.status}`); @@ -116,7 +122,6 @@ export const FlyerUploader: React.FC = ({ onProcessingComple const { jobId: newJobId } = await startResponse.json(); setJobId(newJobId); - setStatusMessage('File accepted. Waiting for processing to start...'); setProcessingState('polling'); } catch (error) { @@ -145,88 +150,81 @@ export const FlyerUploader: React.FC = ({ onProcessingComple // which clears the polling timeout. setProcessingState('idle'); setJobId(null); - setStatusMessage('Select a flyer (PDF or image) to begin.'); setErrorMessage(null); + setCurrentFile(null); + setProcessingStages([]); + setEstimatedTime(0); logger.info('User cancelled polling for job ID:', jobId); }, [jobId]); - // --- Drag and Drop Handlers --- - const handleDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (processingState === 'idle') setIsDragging(true); - }, [processingState]); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - }, []); - - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - if (processingState === 'idle' && e.dataTransfer.files && e.dataTransfer.files.length > 0) { - processFile(e.dataTransfer.files[0]); + // --- Drag and Drop Hook --- + const onFilesDropped = useCallback((files: FileList) => { + if (files && files.length > 0) { + processFile(files[0]); // This uploader only handles the first file. } - }, [processingState, processFile]); + }, [processFile]); const isProcessing = processingState === 'uploading' || processingState === 'polling'; - const borderColor = isDragging ? 'border-blue-500' : 'border-gray-400'; - const bgColor = isDragging ? 'bg-blue-50' : ''; + const { isDragging, dropzoneProps } = useDragAndDrop({ onFilesDropped, disabled: isProcessing }); + + const borderColor = isDragging ? 'border-brand-primary' : 'border-gray-400 dark:border-gray-600'; + const bgColor = isDragging ? 'bg-brand-light/50 dark:bg-brand-dark/20' : 'bg-gray-50/50 dark:bg-gray-800/20'; + + // If processing, show the detailed status component. Otherwise, show the uploader. + if (isProcessing || processingState === 'completed' || processingState === 'error') { + return ( +
+ +
+ {errorMessage && ( +
+

{errorMessage}

+ {statusMessage &&

} +
+ )} + {processingState === 'polling' && ( + + )} + {(processingState === 'error' || processingState === 'completed') && ( + + )} +
+
+ ); + } return ( -
+

Upload New Flyer

{/* File Input */} - - {/* Status Display */} -
- {isProcessing && } - {errorMessage ? ( -
-

{errorMessage}

- {/* Render status message as HTML if it contains a link */} -

-
- ) : ( -

{statusMessage}

- )} - {processingState === 'polling' && ( - - )} -
); diff --git a/src/features/flyer/ProcessingStatus.tsx b/src/features/flyer/ProcessingStatus.tsx index b1092911..cd20031a 100644 --- a/src/features/flyer/ProcessingStatus.tsx +++ b/src/features/flyer/ProcessingStatus.tsx @@ -67,108 +67,11 @@ export const ProcessingStatus: React.FC = ({ stages, esti } } - // Render new layout for bulk processing - if (currentFile) { - const extractionStage = stages.find(s => s.name === 'Extracting All Items from Flyer' && s.status === 'in-progress' && s.progress); - - const stageList = ( -
    - {stages.map((stage, index) => { - const isCritical = stage.critical ?? true; - return ( -
  • -
    -
    - -
    - - {stage.name} - {!isCritical && (optional)} - {stage.detail} - -
    -
  • - ); - })} -
- ); - - return ( -
-

- Processing Steps for:
- {currentFile} -

-
- {/* Left Column: Spinners and Progress Bars */} -
-
- -
- - {/* Overall Progress */} - {bulkFileCount && ( -
-

- File {bulkFileCount.current} of {bulkFileCount.total} -

-
-
-
-
- )} - - {/* PDF Conversion Progress */} - {pageProgress && pageProgress.total > 1 && ( -
-

- Converting PDF: Page {pageProgress.current} of {pageProgress.total} -

-
-
-
-
- )} - - {/* Item Extraction Progress */} - {extractionStage && extractionStage.progress && ( -
-

- Analyzing page {extractionStage.progress.current} of {extractionStage.progress.total} -

-
-
-
-
- )} -
- - {/* Right Column: Checklist */} -
-
- {stageList} -
-
-
-
- ); - } - - // Original layout for single file processing - const title = 'Processing Your Flyer...'; + const title = currentFile ? `Processing: ${currentFile}` : 'Processing Your Flyer...'; const subTitle = `Estimated time remaining: ${Math.floor(timeRemaining / 60)}m ${timeRemaining % 60}s`; return ( -
+

{title}

{subTitle} @@ -188,6 +91,21 @@ export const ProcessingStatus: React.FC = ({ stages, esti

)} + {/* Overall Bulk Progress */} + {bulkFileCount && ( +
+

+ Overall Progress: File {bulkFileCount.current} of {bulkFileCount.total} +

+
+
+
+
+ )} +
    {stages.map((stage, index) => { diff --git a/src/hooks/useData.tsx b/src/hooks/useData.tsx index cc51fd40..fa8ec8cb 100644 --- a/src/hooks/useData.tsx +++ b/src/hooks/useData.tsx @@ -20,7 +20,7 @@ export interface DataContextType { const DataContext = createContext(undefined); export const DataProvider: React.FC<{ children: ReactNode }> = ({ children }) => { - const { user } = useAuth(); + const { user, isLoading: isAuthLoading } = useAuth(); const [flyers, setFlyers] = useState([]); const [masterItems, setMasterItems] = useState([]); const [watchedItems, setWatchedItems] = useState([]); @@ -42,6 +42,12 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ children }) => useEffect(() => { const fetchData = async () => { + // Do not fetch any data until the authentication status has been determined. + // This prevents a race condition on page load where `user` is initially null. + if (isAuthLoading) { + return; + } + setIsLoading(true); setError(null); try { @@ -79,7 +85,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ children }) => }; fetchData(); - }, [user]); // This effect now correctly re-runs only when the user's auth state changes. + }, [user, isAuthLoading]); // Add isAuthLoading to the dependency array const value = { flyers, diff --git a/src/hooks/useDragAndDrop.ts b/src/hooks/useDragAndDrop.ts new file mode 100644 index 00000000..2dbe4058 --- /dev/null +++ b/src/hooks/useDragAndDrop.ts @@ -0,0 +1,60 @@ +// src/hooks/useDragAndDrop.ts +import { useState, useCallback } from 'react'; + +interface UseDragAndDropOptions { + /** + * Callback function that is triggered when files are dropped onto the element. + * @param files The FileList object from the drop event. + */ + onFilesDropped: (files: FileList) => void; + /** + * A boolean to disable the drag-and-drop functionality. + */ + disabled?: boolean; +} + +/** + * A reusable hook to manage drag-and-drop functionality for a UI element. + * + * @param options - Configuration for the hook, including the onDrop callback and disabled state. + * @returns An object containing the `isDragging` state and props to be spread onto the dropzone element. + */ +export const useDragAndDrop = ({ onFilesDropped, disabled = false }: UseDragAndDropOptions) => { + const [isDragging, setIsDragging] = useState(false); + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!disabled) { + setIsDragging(true); + } + }, [disabled]); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + if (!disabled && e.dataTransfer.files && e.dataTransfer.files.length > 0) { + onFilesDropped(e.dataTransfer.files); + } + }, [disabled, onFilesDropped]); + + // onDragOver must also be handled to allow onDrop to fire. + const handleDragOver = handleDragEnter; + + return { + isDragging, + dropzoneProps: { + onDragEnter: handleDragEnter, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + onDragOver: handleDragOver, + }, + }; +}; \ No newline at end of file diff --git a/src/pages/admin/components/AuthView.tsx b/src/pages/admin/components/AuthView.tsx new file mode 100644 index 00000000..1928e616 --- /dev/null +++ b/src/pages/admin/components/AuthView.tsx @@ -0,0 +1,142 @@ +// src/pages/admin/components/AuthView.tsx +import React, { useState } from 'react'; +import type { User } from '../../../types'; +import { useApi } from '../../../hooks/useApi'; +import * as apiClient from '../../../services/apiClient'; +import { notifySuccess, notifyError } from '../../../services/notificationService'; +import { LoadingSpinner } from '../../../components/LoadingSpinner'; +import { GoogleIcon } from '../../../components/icons/GoogleIcon'; +import { GithubIcon } from '../../../components/icons/GithubIcon'; +import { PasswordInput } from './PasswordInput'; + +interface AuthResponse { + user: User; + token: string; +} + +interface AuthViewProps { + onLoginSuccess: (user: User, token: string, rememberMe: boolean) => void; + onClose: () => void; +} + +export const AuthView: React.FC = ({ onLoginSuccess, onClose }) => { + const [isRegistering, setIsRegistering] = useState(false); + const [authEmail, setAuthEmail] = useState(''); + const [authPassword, setAuthPassword] = useState(''); + const [authFullName, setAuthFullName] = useState(''); + const [isForgotPassword, setIsForgotPassword] = useState(false); + const [rememberMe, setRememberMe] = useState(false); + + const { execute: executeLogin, loading: loginLoading } = useApi(apiClient.loginUser); + const { execute: executeRegister, loading: registerLoading } = useApi(apiClient.registerUser); + const { execute: executePasswordReset, loading: passwordResetLoading } = useApi<{ message: string }, [string]>(apiClient.requestPasswordReset); + + const handleAuthSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const authResult = isRegistering + ? await executeRegister(authEmail, authPassword, authFullName, '') + : await executeLogin(authEmail, authPassword, rememberMe); + + if (authResult) { + onLoginSuccess(authResult.user, authResult.token, rememberMe); + onClose(); + } + }; + + const handlePasswordResetRequest = async (e: React.FormEvent) => { + e.preventDefault(); + const result = await executePasswordReset(authEmail); + if (result) { + notifySuccess(result.message); + } + }; + + const handleOAuthSignIn = (provider: 'google' | 'github') => { + window.location.href = '/api/auth/' + provider; + }; + + if (isForgotPassword) { + return ( +
    +

    Reset Password

    +

    Enter your email to receive a password reset link.

    +
    +
    + + setAuthEmail(e.target.value)} required className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" disabled={passwordResetLoading} /> +
    +
    + +
    +
    +
    + +
    +
    + ); + } + + return ( +
    +

    {isRegistering ? 'Create an Account' : 'Sign In'}

    +

    {isRegistering ? 'to get started.' : 'to access your account.'}

    + {isRegistering && ( +
    +
    + + setAuthFullName(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" placeholder="Optional" /> +
    +
    + )} +
    +
    + + setAuthEmail(e.target.value)} required className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm" disabled={loginLoading || registerLoading} /> +
    +
    + + setAuthPassword(e.target.value)} required className="mt-1" disabled={loginLoading || registerLoading} showStrength={isRegistering} /> +
    + {!isRegistering && ( +
    +
    + setRememberMe(e.target.checked)} className="h-4 w-4 text-brand-primary border-gray-300 rounded focus:ring-brand-secondary" /> + +
    + +
    + )} +
    + +
    +
    +
    + +
    +
    + +
    + + +
    +
    + ); +}; \ No newline at end of file diff --git a/src/pages/admin/components/ProfileManager.tsx b/src/pages/admin/components/ProfileManager.tsx index b3417a82..fd02c6dc 100644 --- a/src/pages/admin/components/ProfileManager.tsx +++ b/src/pages/admin/components/ProfileManager.tsx @@ -16,6 +16,7 @@ import { AddressForm } from './AddressForm'; import { MapView } from '../../../components/MapView'; import { useDebounce } from '../../../hooks/useDebounce'; import type { AuthStatus } from '../../../hooks/useAuth'; +import { AuthView } from './AuthView'; interface ProfileManagerProps { isOpen: boolean; @@ -28,11 +29,6 @@ interface ProfileManagerProps { onLoginSuccess: (user: User, token: string, rememberMe: boolean) => void; // Add login handler } -interface AuthResponse { - user: User; - token: string; -} - export const ProfileManager: React.FC = ({ isOpen, onClose, user, authStatus, profile, onProfileUpdate, onSignOut, onLoginSuccess }) => { // This line had a type error due to syntax issues below. const [activeTab, setActiveTab] = useState('profile'); @@ -40,6 +36,7 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, const [fullName, setFullName] = useState(profile?.full_name || ''); const [avatarUrl, setAvatarUrl] = useState(profile?.avatar_url || ''); const [address, setAddress] = useState>({}); + const [initialAddress, setInitialAddress] = useState>({}); // Store initial address for comparison const { execute: updateProfile, loading: profileLoading } = useApi]>(apiClient.updateUserProfile); const { execute: updateAddress, loading: addressLoading } = useApi]>(apiClient.updateUserAddress); const { execute: geocode, loading: isGeocoding } = useApi<{ lat: number; lng: number }, [string]>(apiClient.geocodeAddress); @@ -61,18 +58,6 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, const [isConfirmingDelete, setIsConfirmingDelete] = useState(false); const [passwordForDelete, setPasswordForDelete] = useState(''); - // Login/Register State - const [isRegistering, setIsRegistering] = useState(false); - const [authEmail, setAuthEmail] = useState(''); - const [authPassword, setAuthPassword] = useState(''); - const [authFullName, setAuthFullName] = useState(''); // State for full name - const [authAvatarUrl, setAuthAvatarUrl] = useState(''); // State for avatar URL - const [isForgotPassword, setIsForgotPassword] = useState(false); - const [rememberMe, setRememberMe] = useState(false); - const { execute: executeLogin, loading: loginLoading } = useApi(apiClient.loginUser); - const { execute: executeRegister, loading: registerLoading } = useApi(apiClient.registerUser); - const { execute: executePasswordReset, loading: passwordResetLoading } = useApi<{ message: string }, [string]>(apiClient.requestPasswordReset); - // New hook to fetch address details const { execute: fetchAddress } = useApi(apiClient.getUserAddress); @@ -80,6 +65,7 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, const fetchedAddress = await fetchAddress(addressId); if (fetchedAddress) { setAddress(fetchedAddress); + setInitialAddress(fetchedAddress); // Set initial address on fetch } }, [fetchAddress]); @@ -95,19 +81,14 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, } else { // Reset address form if user has no address setAddress({}); + setInitialAddress({}); } setActiveTab('profile'); setIsConfirmingDelete(false); setPasswordForDelete(''); - setAuthEmail(''); - setAuthPassword(''); - setAuthFullName(''); - setAuthAvatarUrl(''); - setIsRegistering(false); - setIsForgotPassword(false); - setRememberMe(false); // Reset on open } else { setAddress({}); + setInitialAddress({}); } }, [isOpen, profile, handleAddressFetch]); // Depend on isOpen and profile @@ -118,33 +99,56 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, return; } - try { - // Use Promise.allSettled to ensure both API calls complete, regardless of individual success or failure. - // This prevents a race condition where a successful call could trigger a state update before a failed call's error is handled. - const profileUpdatePromise = updateProfile({ - full_name: fullName, - avatar_url: avatarUrl, - }); - const addressUpdatePromise = updateAddress(address); + // Determine if profile or address data has changed + const profileDataChanged = fullName !== profile?.full_name || avatarUrl !== profile?.avatar_url; + const addressDataChanged = JSON.stringify(address) !== JSON.stringify(initialAddress); - const [profileResult, addressResult] = await Promise.allSettled([ - profileUpdatePromise, - addressUpdatePromise - ]); - - // Only proceed if both operations were successful. The useApi hook handles individual error notifications. - if (profileResult.status === 'fulfilled' && addressResult.status === 'fulfilled') { - if (profileResult.value) { // The value can be null if useApi fails internally but doesn't throw - onProfileUpdate(profileResult.value); - } - notifySuccess('Profile and address updated successfully!'); + if (!profileDataChanged && !addressDataChanged) { + notifySuccess("No changes to save."); onClose(); - } + return; + } + const promises = []; + if (profileDataChanged) { + promises.push(updateProfile({ full_name: fullName, avatar_url: avatarUrl })); + } + if (addressDataChanged) { + promises.push(updateAddress(address)); + } + + try { + const results = await Promise.allSettled(promises); + let allSucceeded = true; + let updatedProfileData: Profile | null = null; + + results.forEach((result, index) => { + if (result.status === 'rejected') { + allSucceeded = false; + // Error is already handled by useApi hook, but we log it for good measure. + logger.error('A profile save operation failed:', { error: result.reason }); + } else if (result.status === 'fulfilled') { + // If this was the profile update promise, capture its result. + // We assume the profile promise is always first if it exists. + if (profileDataChanged && index === 0 && result.value) { + updatedProfileData = result.value as Profile; + onProfileUpdate(updatedProfileData); + } + } + }); + + if (allSucceeded) { + notifySuccess('Profile updated successfully!'); + onClose(); + } else { + // If some succeeded, we still might want to give feedback. + if (updatedProfileData) { + notifySuccess('Profile details updated, but address failed to save.'); + } + // The modal remains open for the user to correct the error. + } } catch (error) { - // Although the useApi hook is designed to handle errors, we log here - // as a safeguard to catch any unexpected issues during profile save. - logger.error('An unexpected error occurred in handleProfileSave:', { error }); + logger.error('An unexpected error occurred in handleProfileSave:', { error }); } }; @@ -273,36 +277,6 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, } }; - const handleAuthSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - const authResult = isRegistering - ? await executeRegister(authEmail, authPassword, authFullName, authAvatarUrl) - : await executeLogin(authEmail, authPassword, rememberMe); - - if (authResult) { - onLoginSuccess(authResult.user, authResult.token, rememberMe); - onClose(); // Close modal on success - } - }; - - const handlePasswordResetRequest = async (e: React.FormEvent) => { - e.preventDefault(); - const result = await executePasswordReset(authEmail); - if (result) { - const { message } = result; - notifySuccess(message); - } - }; - - const handleOAuthSignIn = (provider: 'google' | 'github') => { - // Redirect to the backend OAuth initiation route - // The backend will then handle the redirect to the OAuth provider - // and eventually redirect back to the frontend with a token. - window.location.href = '/api/auth/' + provider; - // The loading state is now managed by the hooks, but for a full-page redirect, - // we can manually set a loading state if desired, though it's often not necessary. - }; - if (!isOpen) return null; @@ -339,103 +313,7 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, {authStatus === 'SIGNED_OUT' ? ( - isForgotPassword ? ( -
    -

    Reset Password

    -

    Enter your email to receive a password reset link.

    -
    - - setAuthEmail(e.target.value)} required className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" disabled={passwordResetLoading} /> -
    -
    - -
    -
    -
    - -
    -
    - ) : ( -
    -

    {isRegistering ? 'Create an Account' : 'Sign In'}

    -

    {isRegistering ? 'to get started.' : 'to access your account.'}

    - {/* When registering, show optional fields for full name and avatar URL */} - {isRegistering && ( -
    -
    - - setAuthFullName(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" placeholder="Optional" /> -
    -
    - - setAuthAvatarUrl(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" placeholder="Optional, e.g., http://example.com/avatar.png" /> -
    -
    - )}
    -
    - - setAuthEmail(e.target.value)} required className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm" disabled={loginLoading || registerLoading} /> -
    -
    - - setAuthPassword(e.target.value)} - required - className="mt-1" - disabled={loginLoading || registerLoading} - showStrength={isRegistering} - /> -
    - {!isRegistering && ( -
    -
    - setRememberMe(e.target.checked)} className="h-4 w-4 text-brand-primary border-gray-300 rounded focus:ring-brand-secondary" /> - -
    - -
    - )} -
    - -
    -
    -
    - -
    -
    - -
    - - -
    -
    - ) + ) : ( // Wrap the authenticated view in a React Fragment to return a single element <> @@ -443,18 +321,18 @@ export const ProfileManager: React.FC = ({ isOpen, onClose,

    My Account

    Manage your profile, preferences, and security.

    -
    - @@ -479,7 +357,7 @@ export const ProfileManager: React.FC = ({ isOpen, onClose,
    )}
    -
    @@ -512,7 +390,7 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, />
    -
    @@ -539,7 +417,7 @@ export const ProfileManager: React.FC = ({ isOpen, onClose,

    Export Your Data

    Download a JSON file of your profile, watched items, and shopping lists.

    -
    @@ -551,7 +429,7 @@ export const ProfileManager: React.FC = ({ isOpen, onClose,

    This action is permanent and cannot be undone. All your data will be erased.

    {!isConfirmingDelete ? ( - ) : ( @@ -565,10 +443,10 @@ export const ProfileManager: React.FC = ({ isOpen, onClose, placeholder="Enter your password" />
    - -
    diff --git a/src/services/aiService.server.ts b/src/services/aiService.server.ts index 4a7d8d04..40fa4b16 100644 --- a/src/services/aiService.server.ts +++ b/src/services/aiService.server.ts @@ -8,7 +8,7 @@ import { GoogleGenAI } from '@google/genai'; import fsPromises from 'node:fs/promises'; import { logger } from './logger.server'; -import pRateLimit from 'p-ratelimit'; +import { pRateLimit } from 'p-ratelimit'; import type { FlyerItem, MasterGroceryItem, ExtractedFlyerItem } from '../types'; /**