main page improvements
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 8m2s
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 8m2s
This commit is contained in:
15
src/components/UserMenuSkeleton.tsx
Normal file
15
src/components/UserMenuSkeleton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
60
src/hooks/useDragAndDrop.ts
Normal file
60
src/hooks/useDragAndDrop.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
142
src/pages/admin/components/AuthView.tsx
Normal file
142
src/pages/admin/components/AuthView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user