chore: UX design audit cleanup and receipt flow improvements #186

Merged
egullickson merged 25 commits from issue-162-ux-design-audit-cleanup into main 2026-02-14 03:50:23 +00:00
Showing only changes of commit 6751766b0a - Show all commits

View File

@@ -0,0 +1,272 @@
/**
* @ai-summary Full-screen dialog with upload and camera options for receipt input
* @ai-context Replaces direct camera launch with upload-first pattern; both paths feed OCR pipeline
*/
import React, { useRef, useState, useCallback } from 'react';
import {
Dialog,
Box,
Typography,
Button,
IconButton,
Alert,
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CameraAltIcon from '@mui/icons-material/CameraAlt';
import {
DEFAULT_ACCEPTED_FORMATS,
DEFAULT_MAX_FILE_SIZE,
} from '../../../shared/components/CameraCapture/types';
interface AddReceiptDialogProps {
open: boolean;
onClose: () => void;
onFileSelect: (file: File) => void;
onStartCamera: () => void;
}
export const AddReceiptDialog: React.FC<AddReceiptDialogProps> = ({
open,
onClose,
onFileSelect,
onStartCamera,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const validateFile = useCallback((file: File): string | null => {
const isValidType = DEFAULT_ACCEPTED_FORMATS.some((format) => {
if (format === 'image/heic' || format === 'image/heif') {
return (
file.type === 'image/heic' ||
file.type === 'image/heif' ||
file.name.toLowerCase().endsWith('.heic') ||
file.name.toLowerCase().endsWith('.heif')
);
}
return file.type === format;
});
if (!isValidType) {
return 'Invalid file type. Accepted formats: JPEG, PNG, HEIC';
}
if (file.size > DEFAULT_MAX_FILE_SIZE) {
return `File too large. Maximum size: ${(DEFAULT_MAX_FILE_SIZE / (1024 * 1024)).toFixed(0)}MB`;
}
return null;
}, []);
const handleFile = useCallback(
(file: File) => {
setError(null);
const validationError = validateFile(file);
if (validationError) {
setError(validationError);
return;
}
onFileSelect(file);
},
[validateFile, onFileSelect]
);
const handleInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleFile(file);
}
if (inputRef.current) {
inputRef.current.value = '';
}
},
[handleFile]
);
const handleDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
const file = event.dataTransfer.files[0];
if (file) {
handleFile(file);
}
},
[handleFile]
);
const handleClickUpload = useCallback(() => {
inputRef.current?.click();
}, []);
// Reset error state when dialog closes
const handleClose = useCallback(() => {
setError(null);
setIsDragging(false);
onClose();
}, [onClose]);
return (
<Dialog
open={open}
onClose={handleClose}
fullScreen
PaperProps={{
sx: { backgroundColor: 'background.default' },
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2,
py: 1.5,
borderBottom: 1,
borderColor: 'divider',
backgroundColor: 'background.paper',
}}
>
<Typography variant="h6">Add Receipt</Typography>
<IconButton
onClick={handleClose}
aria-label="Close"
sx={{ minWidth: 44, minHeight: 44 }}
>
<CloseIcon />
</IconButton>
</Box>
{/* Content */}
<Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 3,
gap: 3,
}}
>
{/* Drag-and-drop upload zone */}
<Box
onClick={handleClickUpload}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
sx={{
width: '100%',
maxWidth: 480,
py: 5,
px: 3,
border: 2,
borderStyle: 'dashed',
borderColor: isDragging ? 'primary.main' : 'divider',
borderRadius: 2,
backgroundColor: isDragging ? 'action.hover' : 'background.paper',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
cursor: 'pointer',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: 'primary.main',
backgroundColor: 'action.hover',
},
}}
>
<CloudUploadIcon
sx={{
fontSize: 56,
color: isDragging ? 'primary.main' : 'text.secondary',
}}
/>
<Typography
variant="body1"
color={isDragging ? 'primary.main' : 'text.primary'}
textAlign="center"
fontWeight={500}
>
{isDragging ? 'Drop image here' : 'Drag and drop an image, or tap to browse'}
</Typography>
<Typography variant="caption" color="text.secondary" textAlign="center">
JPEG, PNG, HEIC -- up to 10MB
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ maxWidth: 480, width: '100%' }}>
{error}
</Alert>
)}
{/* Divider with "or" */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
width: '100%',
maxWidth: 480,
gap: 2,
}}
>
<Box sx={{ flex: 1, height: '1px', backgroundColor: 'divider' }} />
<Typography variant="body2" color="text.secondary">
or
</Typography>
<Box sx={{ flex: 1, height: '1px', backgroundColor: 'divider' }} />
</Box>
{/* Take Photo button */}
<Button
variant="outlined"
startIcon={<CameraAltIcon />}
onClick={onStartCamera}
sx={{
minHeight: 56,
minWidth: 200,
maxWidth: 480,
width: '100%',
borderRadius: 2,
borderWidth: 2,
'&:hover': {
borderWidth: 2,
},
}}
>
Take Photo of Receipt
</Button>
</Box>
{/* Hidden file input */}
<input
ref={inputRef}
type="file"
accept={DEFAULT_ACCEPTED_FORMATS.join(',')}
onChange={handleInputChange}
style={{ display: 'none' }}
aria-label="Select receipt image"
/>
</Dialog>
);
};