main page improvements
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 8m2s

This commit is contained in:
2025-12-10 12:42:07 -08:00
parent 0694a5501f
commit 8d12f744bc
9 changed files with 448 additions and 396 deletions

View File

@@ -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 (
<div className="flex items-center space-x-2 animate-pulse">
<div className="h-8 w-24 bg-gray-200 dark:bg-gray-700 rounded-md"></div>
<div className="h-10 w-10 bg-gray-200 dark:bg-gray-700 rounded-full"></div>
</div>
);
};

View File

@@ -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<BulkImporterProps> = ({ onProcess, isProcessing }) => {
const [isDragging, setIsDragging] = useState(false);
export const BulkImporter: React.FC<BulkImporterProps> = ({ onFilesChange, isProcessing }) => {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
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<HTMLInputElement>) => {
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<HTMLLabelElement>) => {
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<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (!isProcessing && e.dataTransfer.files) {
handleFiles(e.dataTransfer.files);
}
}, [isProcessing, onProcess]);
const { isDragging, dropzoneProps } = useDragAndDrop<HTMLLabelElement>({ 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 (
<div className="flex flex-col items-center justify-center w-full">
<div className="w-full">
<label
htmlFor="bulk-file-upload"
onDragEnter={handleDragEnter}
onDragOver={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
{...dropzoneProps}
className={`flex flex-col items-center justify-center w-full h-48 border-2 ${borderColor} ${bgColor} border-dashed rounded-lg transition-colors duration-300 ${isProcessing ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700'}`}
>
<div className="flex flex-col items-center justify-center pt-5 pb-6 text-center">
@@ -71,12 +85,33 @@ export const BulkImporter: React.FC<BulkImporterProps> = ({ onProcess, isProcess
</>
)}
</div>
{/* The `hidden` class was making the label non-interactive in tests.
It's better to make it visually hidden but still accessible.
We use `sr-only` (screen-reader only) classes for this.
*/}
<input id="bulk-file-upload" type="file" className="absolute w-px h-px p-0 -m-px overflow-hidden clip-rect-0 whitespace-nowrap border-0" accept="image/png, image/jpeg, image/webp, application/pdf" onChange={handleFileChange} disabled={isProcessing} multiple />
</label>
{selectedFiles.length > 0 && (
<div className="mt-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{selectedFiles.map((file, index) => (
<div key={index} className="relative group border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{previewUrls[index] ? (
<img src={previewUrls[index]} alt={file.name} className="h-32 w-full object-cover" />
) : (
<div className="h-32 w-full flex items-center justify-center bg-gray-100 dark:bg-gray-700">
<DocumentTextIcon className="w-12 h-12 text-gray-400" />
</div>
)}
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white text-xs p-1 truncate">{file.name}</div>
<button
type="button"
onClick={() => removeFile(index)}
className="absolute top-1 right-1 bg-red-600 text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={`Remove ${file.name}`}
>
<XMarkIcon className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
);
};

View File

@@ -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 = () => (
<div data-testid="loading-spinner" className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
);
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<FlyerUploaderProps> = ({ onProcessingComplete }) => {
const [processingState, setProcessingState] = useState<ProcessingState>('idle');
const [statusMessage, setStatusMessage] = useState<string>('Select a flyer (PDF or image) to begin.');
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [jobId, setJobId] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const navigate = useNavigate();
// Use a ref to manage the polling timeout to prevent memory leaks
const pollingTimeoutRef = useRef<number | null>(null);
// State for the ProcessingStatus component
const [processingStages, setProcessingStages] = useState<ProcessingStage[]>([]);
const [estimatedTime, setEstimatedTime] = useState(0);
const [currentFile, setCurrentFile] = useState<string | null>(null);
// This effect handles the polling logic
useEffect(() => {
if (processingState !== 'polling' || !jobId) {
@@ -42,9 +44,11 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ 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<FlyerUploaderProps> = ({ 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<FlyerUploaderProps> = ({ 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(`<a href="/flyers/${errorData.flyerId}" class="text-blue-500 underline">Flyer #${errorData.flyerId}</a>`);
} else {
setErrorMessage(errorData.message || `Upload failed with status ${startResponse.status}`);
@@ -116,7 +122,6 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ 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<FlyerUploaderProps> = ({ 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<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
if (processingState === 'idle') setIsDragging(true);
}, [processingState]);
const handleDragLeave = useCallback((e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent<HTMLLabelElement>) => {
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<HTMLLabelElement>({ 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 (
<div className="max-w-4xl mx-auto">
<ProcessingStatus stages={processingStages} estimatedTime={estimatedTime} currentFile={currentFile} />
<div className="mt-4 text-center">
{errorMessage && (
<div className="text-red-600 dark:text-red-400 font-semibold p-4 bg-red-100 dark:bg-red-900/30 rounded-md">
<p>{errorMessage}</p>
{statusMessage && <p dangerouslySetInnerHTML={{ __html: statusMessage }}></p>}
</div>
)}
{processingState === 'polling' && (
<button
onClick={handleCancelPolling}
className="mt-4 text-sm text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 underline transition-colors"
title="The flyer will continue to process in the background."
>
Stop Watching Progress
</button>
)}
{(processingState === 'error' || processingState === 'completed') && (
<button
onClick={handleCancelPolling} // This function resets the state, so it works for "Upload Another"
className="mt-4 text-sm bg-brand-secondary hover:bg-brand-dark text-white font-bold py-2 px-4 rounded-lg"
>
Upload Another Flyer
</button>
)}
</div>
</div>
);
}
return (
<div className="max-w-xl mx-auto p-6 bg-white rounded-lg shadow-md">
<div className="max-w-xl mx-auto p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md">
<h2 className="text-2xl font-bold mb-4 text-center">Upload New Flyer</h2>
<div className="flex flex-col items-center space-y-4">
{/* File Input */}
<label
htmlFor="flyer-upload"
className={`w-full text-center px-4 py-6 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${bgColor} ${
isProcessing ? 'bg-gray-200 border-gray-300 text-gray-500 cursor-not-allowed' : `${borderColor} hover:border-blue-500 hover:bg-blue-50`
}`}
onDragEnter={handleDragEnter}
onDragOver={handleDragEnter} // onDragOver is needed to make onDrop work
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`w-full text-center px-4 py-6 border-2 border-dashed rounded-lg cursor-pointer transition-colors duration-200 ease-in-out ${bgColor} ${borderColor} hover:border-brand-primary/70 dark:hover:border-brand-primary/50`}
{...dropzoneProps}
>
<span className="text-lg font-medium">
{isProcessing ? 'Processing...' : 'Click to select a file'}
</span>
<span className="text-lg font-medium">Click to select a file</span>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">or drag and drop a PDF or image</p>
<input
id="flyer-upload"
type="file"
className="hidden"
onChange={handleFileChange}
disabled={isProcessing}
accept=".pdf,image/*"
/>
</label>
{/* Status Display */}
<div className="w-full text-center p-4 bg-gray-100 rounded-md min-h-20 flex flex-col justify-center items-center">
{isProcessing && <LoadingSpinner />}
{errorMessage ? (
<div className="text-red-600 font-semibold">
<p>{errorMessage}</p>
{/* Render status message as HTML if it contains a link */}
<p dangerouslySetInnerHTML={{ __html: statusMessage }}></p>
</div>
) : (
<p className="text-gray-700">{statusMessage}</p>
)}
{processingState === 'polling' && (
<button
onClick={handleCancelPolling}
className="mt-4 text-sm text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 underline transition-colors"
title="The flyer will continue to process in the background."
>
Stop Watching Progress
</button>
)}
</div>
</div>
</div>
);

View File

@@ -67,108 +67,11 @@ export const ProcessingStatus: React.FC<ProcessingStatusProps> = ({ 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 = (
<ul className="space-y-3">
{stages.map((stage, index) => {
const isCritical = stage.critical ?? true;
return (
<li key={index} data-testid={`stage-item-${index}`}>
<div className="flex items-center space-x-3">
<div className="shrink-0" data-testid={`stage-icon-${index}`}>
<StageIcon status={stage.status} isCritical={isCritical} />
</div>
<span className={`text-sm ${getStatusTextColor(stage.status, isCritical)}`} data-testid={`stage-text-${index}`}>
{stage.name}
{!isCritical && <span className="text-gray-400 dark:text-gray-500 text-xs italic"> (optional)</span>}
<span className="text-gray-400 dark:text-gray-500 ml-1">{stage.detail}</span>
</span>
</div>
</li>
);
})}
</ul>
);
return (
<div className="p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 min-h-[400px] flex flex-col justify-center">
<h2 className="text-xl font-bold text-gray-800 dark:text-white mb-6 text-center">
Processing Steps for: <br/>
<span className="font-normal text-base text-gray-600 dark:text-gray-300 truncate mt-1 block max-w-sm">{currentFile}</span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full max-w-4xl mx-auto">
{/* Left Column: Spinners and Progress Bars */}
<div className="flex flex-col justify-center items-center space-y-4">
<div className="w-24 h-24 text-brand-primary">
<LoadingSpinner />
</div>
{/* Overall Progress */}
{bulkFileCount && (
<div className="w-full">
<p className="text-sm text-center text-gray-500 dark:text-gray-400 mb-1">
File {bulkFileCount.current} of {bulkFileCount.total}
</p>
<div className="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
<div
className="bg-brand-primary h-2.5 rounded-full"
style={{ width: `${bulkProgress || 0}%`, transition: 'width 0.5s ease-in-out' }}
></div>
</div>
</div>
)}
{/* PDF Conversion Progress */}
{pageProgress && pageProgress.total > 1 && (
<div className="w-full">
<p className="text-xs text-left text-gray-500 dark:text-gray-400 mb-1">
Converting PDF: Page {pageProgress.current} of {pageProgress.total}
</p>
<div className="w-full bg-gray-200 rounded-full h-1.5 dark:bg-gray-700">
<div
className="bg-blue-500 h-1.5 rounded-full"
style={{ width: `${(pageProgress.current / pageProgress.total) * 100}%`, transition: 'width 0.2s ease-in-out' }}
></div>
</div>
</div>
)}
{/* Item Extraction Progress */}
{extractionStage && extractionStage.progress && (
<div className="w-full">
<p className="text-xs text-left text-gray-500 dark:text-gray-400 mb-1">
Analyzing page {extractionStage.progress.current} of {extractionStage.progress.total}
</p>
<div className="w-full bg-gray-200 rounded-full h-1.5 dark:bg-gray-700">
<div
className="bg-purple-500 h-1.5 rounded-full"
style={{ width: `${(extractionStage.progress.current / extractionStage.progress.total) * 100}%`, transition: 'width 0.5s ease-out' }}
></div>
</div>
</div>
)}
</div>
{/* Right Column: Checklist */}
<div className="flex items-center">
<div className="w-full">
{stageList}
</div>
</div>
</div>
</div>
);
}
// 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 (
<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 items-center min-h-[400px]">
<div className="p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 min-h-[400px] flex flex-col justify-center items-center">
<h2 className="text-xl font-bold mb-2 text-gray-800 dark:text-white">{title}</h2>
<p className="text-gray-500 dark:text-gray-400 mb-6 font-semibold text-brand-primary truncate max-w-full px-4">
{subTitle}
@@ -188,6 +91,21 @@ export const ProcessingStatus: React.FC<ProcessingStatusProps> = ({ stages, esti
</div>
)}
{/* Overall Bulk Progress */}
{bulkFileCount && (
<div className="w-full max-w-sm mb-6">
<p className="text-sm text-center text-gray-500 dark:text-gray-400 mb-1">
Overall Progress: File {bulkFileCount.current} of {bulkFileCount.total}
</p>
<div className="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
<div
className="bg-brand-primary h-2.5 rounded-full"
style={{ width: `${bulkProgress || 0}%`, transition: 'width 0.5s ease-in-out' }}
></div>
</div>
</div>
)}
<div className="w-full max-w-sm text-left">
<ul className="space-y-3">
{stages.map((stage, index) => {

View File

@@ -20,7 +20,7 @@ export interface DataContextType {
const DataContext = createContext<DataContextType | undefined>(undefined);
export const DataProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { user } = useAuth();
const { user, isLoading: isAuthLoading } = useAuth();
const [flyers, setFlyers] = useState<Flyer[]>([]);
const [masterItems, setMasterItems] = useState<MasterGroceryItem[]>([]);
const [watchedItems, setWatchedItems] = useState<MasterGroceryItem[]>([]);
@@ -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,

View File

@@ -0,0 +1,60 @@
// src/hooks/useDragAndDrop.ts
import { useState, useCallback } from 'react';
interface UseDragAndDropOptions<T extends HTMLElement> {
/**
* 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 = <T extends HTMLElement>({ onFilesDropped, disabled = false }: UseDragAndDropOptions<T>) => {
const [isDragging, setIsDragging] = useState(false);
const handleDragEnter = useCallback((e: React.DragEvent<T>) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
setIsDragging(true);
}
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent<T>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent<T>) => {
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,
},
};
};

View File

@@ -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<AuthViewProps> = ({ 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<AuthResponse, [string, string, boolean]>(apiClient.loginUser);
const { execute: executeRegister, loading: registerLoading } = useApi<AuthResponse, [string, string, string, string]>(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 (
<div className="p-8">
<h2 className="text-2xl font-bold text-gray-800 dark:text-white mb-1">Reset Password</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">Enter your email to receive a password reset link.</p>
<form data-testid="reset-password-form" onSubmit={handlePasswordResetRequest} className="space-y-4">
<div>
<label htmlFor="resetEmail" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Email Address</label>
<input id="resetEmail" type="email" value={authEmail} onChange={e => 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} />
</div>
<div className="pt-2">
<button type="submit" disabled={passwordResetLoading} className="w-full bg-brand-primary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center shadow-sm transition-colors">
{passwordResetLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Send Reset Link'}
</button>
</div>
</form>
<div className="text-center mt-4">
<button onClick={() => { setIsForgotPassword(false); }} className="text-sm font-medium text-brand-primary hover:text-brand-dark dark:hover:text-brand-light underline">
Back to Sign In
</button>
</div>
</div>
);
}
return (
<div className="p-8">
<h2 className="text-2xl font-bold text-gray-800 dark:text-white mb-1">{isRegistering ? 'Create an Account' : 'Sign In'}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">{isRegistering ? 'to get started.' : 'to access your account.'}</p>
{isRegistering && (
<div className="space-y-4 mb-4">
<div>
<label htmlFor="authFullName" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Full Name</label>
<input id="authFullName" type="text" value={authFullName} onChange={e => 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" />
</div>
</div>
)}
<form data-testid="auth-form" onSubmit={handleAuthSubmit} className="space-y-4">
<div>
<label htmlFor="authEmail" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Email Address</label>
<input id="authEmail" type="email" value={authEmail} onChange={e => 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} />
</div>
<div>
<label htmlFor="authPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Password</label>
<PasswordInput id="authPassword" value={authPassword} onChange={e => setAuthPassword(e.target.value)} required className="mt-1" disabled={loginLoading || registerLoading} showStrength={isRegistering} />
</div>
{!isRegistering && (
<div className="flex items-center justify-between text-sm">
<div className="flex items-center">
<input id="remember-me" name="remember-me" type="checkbox" checked={rememberMe} onChange={(e) => setRememberMe(e.target.checked)} className="h-4 w-4 text-brand-primary border-gray-300 rounded focus:ring-brand-secondary" />
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">Remember me</label>
</div>
<button type="button" onClick={() => setIsForgotPassword(true)} className="font-medium text-brand-primary hover:text-brand-dark dark:hover:text-brand-light underline">
Forgot password?
</button>
</div>
)}
<div className="pt-2">
<button type="submit" disabled={loginLoading || registerLoading} className="w-full bg-brand-primary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center shadow-sm transition-colors">
{(loginLoading || registerLoading) ? <div className="w-5 h-5"><LoadingSpinner /></div> : (isRegistering ? 'Register' : 'Sign In')}
</button>
</div>
</form>
<div className="mt-4">
<button onClick={() => { setIsRegistering(!isRegistering); }} className="w-full text-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700">
{isRegistering ? 'Already have an account? Sign In' : "Don't have an account? Register"}
</button>
</div>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center" aria-hidden="true"><div className="w-full border-t border-gray-200 dark:border-gray-700" /></div>
<div className="relative flex justify-center"><span className="bg-white dark:bg-gray-800 px-3 text-sm text-gray-500 dark:text-gray-400">Or continue with</span></div>
</div>
<div className="space-y-3">
<button type="button" onClick={() => handleOAuthSignIn('google')} disabled={loginLoading || registerLoading} className="w-full flex items-center justify-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed">
{(loginLoading || registerLoading) ? <div className="w-5 h-5 mr-3"><LoadingSpinner /></div> : <GoogleIcon className="w-5 h-5 mr-3" />}
Sign In with Google
</button>
<button type="button" onClick={() => handleOAuthSignIn('github')} disabled={loginLoading || registerLoading} className="w-full flex items-center justify-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed">
{(loginLoading || registerLoading) ? <div className="w-5 h-5 mr-3"><LoadingSpinner /></div> : <GithubIcon className="w-5 h-5 mr-3" />}
Sign In with GitHub
</button>
</div>
</div>
);
};

View File

@@ -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<ProfileManagerProps> = ({ 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<ProfileManagerProps> = ({ isOpen, onClose,
const [fullName, setFullName] = useState(profile?.full_name || '');
const [avatarUrl, setAvatarUrl] = useState(profile?.avatar_url || '');
const [address, setAddress] = useState<Partial<Address>>({});
const [initialAddress, setInitialAddress] = useState<Partial<Address>>({}); // Store initial address for comparison
const { execute: updateProfile, loading: profileLoading } = useApi<Profile, [Partial<Profile>]>(apiClient.updateUserProfile);
const { execute: updateAddress, loading: addressLoading } = useApi<Address, [Partial<Address>]>(apiClient.updateUserAddress);
const { execute: geocode, loading: isGeocoding } = useApi<{ lat: number; lng: number }, [string]>(apiClient.geocodeAddress);
@@ -61,18 +58,6 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ 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<AuthResponse, [string, string, boolean]>(apiClient.loginUser);
const { execute: executeRegister, loading: registerLoading } = useApi<AuthResponse, [string, string, string, string]>(apiClient.registerUser);
const { execute: executePasswordReset, loading: passwordResetLoading } = useApi<{ message: string }, [string]>(apiClient.requestPasswordReset);
// New hook to fetch address details
const { execute: fetchAddress } = useApi<Address, [number]>(apiClient.getUserAddress);
@@ -80,6 +65,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ 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<ProfileManagerProps> = ({ 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<ProfileManagerProps> = ({ 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<ProfileManagerProps> = ({ 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<ProfileManagerProps> = ({ isOpen, onClose,
</button>
{authStatus === 'SIGNED_OUT' ? (
isForgotPassword ? (
<div className="p-8">
<h2 className="text-2xl font-bold text-gray-800 dark:text-white mb-1">Reset Password</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">Enter your email to receive a password reset link.</p> <form data-testid="reset-password-form" onSubmit={handlePasswordResetRequest} className="space-y-4">
<div>
<label htmlFor="resetEmail" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Email Address</label>
<input id="resetEmail" type="email" value={authEmail} onChange={e => 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} />
</div>
<div className="pt-2">
<button type="submit" disabled={passwordResetLoading} className="w-full bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center">
{passwordResetLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Send Reset Link'}
</button>
</div>
</form>
<div className="text-center mt-4">
<button onClick={() => { setIsForgotPassword(false); }} className="text-sm font-medium text-brand-primary hover:underline">
Back to Sign In
</button>
</div>
</div>
) : (
<div className="p-8">
<h2 className="text-2xl font-bold text-gray-800 dark:text-white mb-1">{isRegistering ? 'Create an Account' : 'Sign In'}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">{isRegistering ? 'to get started.' : 'to access your account.'}</p>
{/* When registering, show optional fields for full name and avatar URL */}
{isRegistering && (
<div className="space-y-4 mb-4">
<div>
<label htmlFor="authFullName" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Full Name</label>
<input id="authFullName" type="text" value={authFullName} onChange={e => 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" />
</div>
<div>
<label htmlFor="authAvatarUrl" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Avatar URL</label>
<input id="authAvatarUrl" type="url" value={authAvatarUrl} onChange={e => 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" />
</div>
</div>
)} <form data-testid="auth-form" onSubmit={handleAuthSubmit} className="space-y-4">
<div>
<label htmlFor="authEmail" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Email Address</label>
<input id="authEmail" type="email" value={authEmail} onChange={e => 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} />
</div>
<div>
<label htmlFor="authPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Password</label>
<PasswordInput
id="authPassword"
value={authPassword}
onChange={e => setAuthPassword(e.target.value)}
required
className="mt-1"
disabled={loginLoading || registerLoading}
showStrength={isRegistering}
/>
</div>
{!isRegistering && (
<div className="flex items-center justify-between text-sm">
<div className="flex items-center">
<input id="remember-me" name="remember-me" type="checkbox" checked={rememberMe} onChange={(e) => setRememberMe(e.target.checked)} className="h-4 w-4 text-brand-primary border-gray-300 rounded focus:ring-brand-secondary" />
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
Remember me
</label>
</div>
<button type="button" onClick={() => setIsForgotPassword(true)} className="font-medium text-brand-primary hover:underline">
Forgot password?
</button>
</div>
)}
<div className="pt-2">
<button type="submit" disabled={loginLoading || registerLoading} className="w-full bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center">
{(loginLoading || registerLoading) ? <div className="w-5 h-5"><LoadingSpinner /></div> : (isRegistering ? 'Register' : 'Sign In')}
</button>
</div>
</form>
<div className="text-center mt-4">
<button onClick={() => { setIsRegistering(!isRegistering); }} className="text-sm font-medium text-brand-primary hover:underline">
{isRegistering ? 'Already have an account? Sign In' : "Don't have an account? Register"}
</button>
</div>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-300 dark:border-gray-600" />
</div>
<div className="relative flex justify-center">
<span className="bg-white dark:bg-gray-800 px-2 text-sm text-gray-500 dark:text-gray-400">Or continue with</span>
</div>
</div>
<div className="space-y-3">
<button type="button" onClick={() => handleOAuthSignIn('google')} disabled={loginLoading || registerLoading} className="w-full flex items-center justify-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed">
{(loginLoading || registerLoading) ? <div className="w-5 h-5 mr-3"><LoadingSpinner /></div> : <GoogleIcon className="w-5 h-5 mr-3" />}
Sign In with Google
</button>
<button type="button" onClick={() => handleOAuthSignIn('github')} disabled={loginLoading || registerLoading} className="w-full flex items-center justify-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed">
{(loginLoading || registerLoading) ? <div className="w-5 h-5 mr-3"><LoadingSpinner /></div> : <GithubIcon className="w-5 h-5 mr-3" />}
Sign In with GitHub
</button>
</div>
</div>
)
<AuthView onLoginSuccess={onLoginSuccess} onClose={onClose} />
) : (
// Wrap the authenticated view in a React Fragment to return a single element
<>
@@ -443,18 +321,18 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
<h2 className="text-2xl font-bold text-gray-800 dark:text-white mb-1">My Account</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">Manage your profile, preferences, and security.</p>
<div className="border-b border-gray-200 dark:border-gray-700 mb-6">
<nav className="-mb-px flex space-x-6" aria-label="Tabs">
<button onClick={() => setActiveTab('profile')} className={`whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm ${activeTab === 'profile' ? 'border-brand-primary text-brand-primary' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
<div className="mb-6">
<nav className="flex space-x-2" aria-label="Tabs">
<button onClick={() => setActiveTab('profile')} className={`whitespace-nowrap py-2 px-4 rounded-md font-medium text-sm transition-colors ${activeTab === 'profile' ? 'bg-brand-primary/10 text-brand-primary dark:bg-brand-primary/20' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700/50 hover:text-gray-700 dark:hover:text-gray-300'}`}>
Profile
</button>
<button onClick={() => setActiveTab('security')} className={`whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm ${activeTab === 'security' ? 'border-brand-primary text-brand-primary' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
<button onClick={() => setActiveTab('security')} className={`whitespace-nowrap py-2 px-4 rounded-md font-medium text-sm transition-colors ${activeTab === 'security' ? 'bg-brand-primary/10 text-brand-primary dark:bg-brand-primary/20' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700/50 hover:text-gray-700 dark:hover:text-gray-300'}`}>
Security
</button>
<button onClick={() => setActiveTab('data')} className={`whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm ${activeTab === 'data' ? 'border-brand-primary text-brand-primary' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
<button onClick={() => setActiveTab('data')} className={`whitespace-nowrap py-2 px-4 rounded-md font-medium text-sm transition-colors ${activeTab === 'data' ? 'bg-brand-primary/10 text-brand-primary dark:bg-brand-primary/20' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700/50 hover:text-gray-700 dark:hover:text-gray-300'}`}>
Data & Privacy
</button>
<button onClick={() => setActiveTab('preferences')} className={`whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm ${activeTab === 'preferences' ? 'border-brand-primary text-brand-primary' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>
<button onClick={() => setActiveTab('preferences')} className={`whitespace-nowrap py-2 px-4 rounded-md font-medium text-sm transition-colors ${activeTab === 'preferences' ? 'bg-brand-primary/10 text-brand-primary dark:bg-brand-primary/20' : 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700/50 hover:text-gray-700 dark:hover:text-gray-300'}`}>
Preferences
</button>
</nav>
@@ -479,7 +357,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
</div>
)}
<div className="pt-2">
<button type="submit" disabled={profileLoading || addressLoading} className="w-full bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center">
<button type="submit" disabled={profileLoading || addressLoading} className="w-full bg-brand-primary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center shadow-sm transition-colors">
{(profileLoading || addressLoading) ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Save Profile'}
</button>
</div>
@@ -512,7 +390,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
/>
</div>
<div className="pt-2">
<button type="submit" disabled={passwordLoading} className="w-full bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center">
<button type="submit" disabled={passwordLoading} className="w-full bg-brand-primary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center shadow-sm transition-colors">
{passwordLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Update Password'}
</button>
</div>
@@ -539,7 +417,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
<div>
<h3 className="text-md font-semibold text-gray-800 dark:text-white">Export Your Data</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Download a JSON file of your profile, watched items, and shopping lists.</p>
<button onClick={handleExportData} disabled={exportLoading} className="mt-3 w-full sm:w-auto bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-bold py-2 px-4 rounded-lg flex items-center justify-center">
<button onClick={handleExportData} disabled={exportLoading} className="mt-3 w-full sm:w-auto bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold py-2 px-4 rounded-lg flex items-center justify-center border border-gray-300 dark:border-gray-600 shadow-sm transition-colors">
{exportLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Export My Data'}
</button>
</div>
@@ -551,7 +429,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
<p className="text-sm text-red-700 dark:text-red-400 mt-1">This action is permanent and cannot be undone. All your data will be erased.</p>
{!isConfirmingDelete ? (
<button type="button" onClick={() => setIsConfirmingDelete(true)} className="mt-3 w-full sm:w-auto bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg">
<button type="button" onClick={() => setIsConfirmingDelete(true)} className="mt-3 w-full sm:w-auto bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg shadow-sm transition-colors">
Delete My Account
</button>
) : (
@@ -565,10 +443,10 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
placeholder="Enter your password"
/>
<div className="flex flex-col sm:flex-row sm:space-x-2 space-y-2 sm:space-y-0">
<button type="button" onClick={() => setIsConfirmingDelete(false)} className="flex-1 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-800 dark:text-white font-bold py-2 px-4 rounded-lg">
<button type="button" onClick={() => setIsConfirmingDelete(false)} className="flex-1 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-800 dark:text-white font-bold py-2 px-4 rounded-lg shadow-sm transition-colors">
Cancel
</button>
<button type="submit" disabled={deleteLoading || !passwordForDelete} className="flex-1 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white font-bold py-2 px-4 rounded-lg flex justify-center">
<button type="submit" disabled={deleteLoading || !passwordForDelete} className="flex-1 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white font-bold py-2 px-4 rounded-lg flex justify-center shadow-sm transition-colors">
{deleteLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Delete Account Permanently'}
</button>
</div>

View File

@@ -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';
/**