diff --git a/frontend/features/verification/components/steps/MediaUploadStep.tsx b/frontend/features/verification/components/steps/MediaUploadStep.tsx new file mode 100644 index 0000000..d61b560 --- /dev/null +++ b/frontend/features/verification/components/steps/MediaUploadStep.tsx @@ -0,0 +1,448 @@ +'use client'; + +import React, { useCallback, useState, useRef, type DragEvent, type ChangeEvent } from 'react'; +import { Upload, FileImage, FileVideo, FileText, X, AlertCircle, CheckCircle2, Loader2, Eye, EyeOff } from 'lucide-react'; + +// ── Types ────────────────────────────────────────────────── +export interface MediaFile { + id: string; + name: string; + size: number; + type: string; + previewUrl?: string; + progress: number; + status: 'pending' | 'uploading' | 'done' | 'error'; + error?: string; +} + +export interface MediaUploadStepProps { + /** Max file size in bytes (default: 50 MB) */ + maxSize?: number; + /** Accepted MIME types (default: images, videos, PDFs) */ + accept?: Record; + /** Allow multiple files (default: false) */ + multiple?: boolean; + /** Called when files change */ + onFilesChange?: (files: MediaFile[]) => void; + /** External files to display (controlled mode) */ + files?: MediaFile[]; + /** Custom label text */ + label?: string; + /** Custom hint text */ + hint?: string; +} + +const DEFAULT_ACCEPT: Record = { + 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.heic'], + 'video/*': ['.mp4', '.mov', '.avi', '.webm', '.mkv'], + 'application/pdf': ['.pdf'], +}; + +const DEFAULT_MAX_SIZE = 50 * 1024 * 1024; // 50 MB + +// ── Helpers ──────────────────────────────────────────────── +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +function generateId(): string { + return `media-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +function getFileIcon(mimeType: string) { + if (mimeType.startsWith('image/')) return FileImage; + if (mimeType.startsWith('video/')) return FileVideo; + return FileText; +} + +function getFileCategory(mimeType: string): string { + if (mimeType.startsWith('image/')) return 'Image'; + if (mimeType.startsWith('video/')) return 'Video'; + if (mimeType === 'application/pdf') return 'PDF'; + return 'Document'; +} + +// ── Component ────────────────────────────────────────────── +export default function MediaUploadStep({ + maxSize = DEFAULT_MAX_SIZE, + accept = DEFAULT_ACCEPT, + multiple = false, + onFilesChange, + files: externalFiles, + label = 'Drag & drop files here, or click to browse', + hint = 'Supported: images, videos, PDFs (up to 50 MB each)', +}: MediaUploadStepProps) { + const [internalFiles, setInternalFiles] = useState([]); + const [dragActive, setDragActive] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + const dropZoneRef = useRef(null); + + // Use external files if controlled mode + const files = externalFiles ?? internalFiles; + const setFiles = useCallback( + (newFiles: MediaFile[] | ((prev: MediaFile[]) => MediaFile[])) => { + if (externalFiles !== undefined) { + // Controlled mode — call callback + const updated = typeof newFiles === 'function' ? newFiles(externalFiles) : newFiles; + onFilesChange?.(updated); + } else { + setInternalFiles(newFiles); + if (typeof newFiles !== 'function') { + onFilesChange?.(newFiles); + } + } + }, + [externalFiles, onFilesChange], + ); + + // ── File Processing ─────────────────────────────────── + const processFiles = useCallback( + async (fileList: FileList | File[]) => { + setError(null); + const incoming = Array.from(fileList); + + if (incoming.length === 0) return; + + // Validate each file + const validFiles: File[] = []; + for (const file of incoming) { + if (file.size > maxSize) { + setError(`"${file.name}" exceeds ${formatFileSize(maxSize)} limit`); + continue; + } + if (file.size === 0) { + setError(`"${file.name}" is empty`); + continue; + } + validFiles.push(file); + } + + if (validFiles.length === 0) return; + + // Create media file entries with previews + const newEntries: MediaFile[] = validFiles.map((file) => ({ + id: generateId(), + name: file.name, + size: file.size, + type: file.type || 'application/octet-stream', + previewUrl: file.type.startsWith('image/') || file.type.startsWith('video/') + ? URL.createObjectURL(file) + : undefined, + progress: 0, + status: 'pending' as const, + })); + + // Add files — if not multiple, replace + if (!multiple) { + // Revoke old preview URLs + files.forEach((f) => { + if (f.previewUrl) URL.revokeObjectURL(f.previewUrl); + }); + setFiles(newEntries.slice(0, 1)); + } else { + setFiles((prev) => [...prev, ...newEntries]); + } + + // Simulate upload progress (in real app this would be actual upload) + for (const entry of newEntries) { + await simulateUpload(entry.id); + } + }, + [maxSize, multiple, files, setFiles], + ); + + const simulateUpload = async (fileId: string) => { + const updateProgress = (progress: number) => { + setFiles((prev) => + prev.map((f) => + f.id === fileId + ? { ...f, progress, status: progress >= 100 ? 'done' : 'uploading' } + : f, + ), + ); + }; + + for (let p = 0; p <= 100; p += 20) { + await new Promise((r) => setTimeout(r, 150)); + updateProgress(p); + } + updateProgress(100); + }; + + // ── Drag Handlers ───────────────────────────────────── + const handleDragEnter = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(true); + }, []); + + const handleDragLeave = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + // Only set inactive if leaving the drop zone (not entering a child) + if (dropZoneRef.current && !dropZoneRef.current.contains(e.relatedTarget as Node)) { + setDragActive(false); + } + }, []); + + const handleDragOver = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDrop = useCallback( + (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + processFiles(e.dataTransfer.files); + } + }, + [processFiles], + ); + + // ── Click Handler ───────────────────────────────────── + const handleClick = useCallback(() => { + inputRef.current?.click(); + }, []); + + const handleInputChange = useCallback( + (e: ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + processFiles(e.target.files); + // Reset input so the same file can be re-selected + e.target.value = ''; + } + }, + [processFiles], + ); + + // ── Remove File ─────────────────────────────────────── + const handleRemove = useCallback( + (fileId: string) => { + setFiles((prev) => { + const file = prev.find((f) => f.id === fileId); + if (file?.previewUrl) URL.revokeObjectURL(file.previewUrl); + return prev.filter((f) => f.id !== fileId); + }); + }, + [setFiles], + ); + + // ── Keyboard Handler ────────────────────────────────── + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleClick(); + } + }, + [handleClick], + ); + + // ── Render ──────────────────────────────────────────── + const hasFiles = files.length > 0; + const showDropZone = multiple || !hasFiles; + + return ( +
+ {/* Drop Zone */} + {showDropZone && ( +
+ + +
+ +
+ +

+ {dragActive ? 'Drop files here…' : label} +

+

{hint}

+
+ )} + + {/* Error Banner */} + {error && ( +
+ +

{error}

+
+ )} + + {/* File List */} + {hasFiles && ( +
+ {files.map((file) => { + const Icon = getFileIcon(file.type); + const isImage = file.type.startsWith('image/'); + const isVideo = file.type.startsWith('video/'); + + return ( +
+ {/* Remove button */} + + +
+ {/* Preview Thumbnail */} + {file.previewUrl ? ( +
+ {isImage ? ( + {file.name} + ) : isVideo ? ( +
+ ) : ( +
+ +
+ )} + + {/* File Info */} +
+

+ {file.name} +

+
+ + {formatFileSize(file.size)} + + + {getFileCategory(file.type)} + +
+ + {/* Progress Bar */} + {file.status === 'uploading' && ( +
+
+ + Uploading… {file.progress}% +
+
+
+
+
+ )} + + {/* Done Badge */} + {file.status === 'done' && ( +
+ + Upload complete +
+ )} + + {/* Error Message */} + {file.status === 'error' && file.error && ( +

{file.error}

+ )} +
+
+
+ ); + })} +
+ )} + + {/* Empty State */} + {!hasFiles && !showDropZone && ( +
+ +

No files selected

+
+ )} +
+ ); +} diff --git a/frontend/features/verification/components/steps/__tests__/MediaUploadStep.test.tsx b/frontend/features/verification/components/steps/__tests__/MediaUploadStep.test.tsx new file mode 100644 index 0000000..a8a310d --- /dev/null +++ b/frontend/features/verification/components/steps/__tests__/MediaUploadStep.test.tsx @@ -0,0 +1,468 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import MediaUploadStep, { type MediaFile } from '../MediaUploadStep'; + +// ── Mock URL.createObjectURL ─────────────────────────── +const mockObjectURL = 'blob:mock-url'; +global.URL.createObjectURL = jest.fn(() => mockObjectURL); +global.URL.revokeObjectURL = jest.fn(); + +// ── Helpers ──────────────────────────────────────────── +function createFile(name: string, size: number, type: string): File { + const content = new Array(size).fill('a').join(''); + return new File([content], name, { type }); +} + +function createDropEvent(files: File[]): Partial { + return { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + dataTransfer: { + files: files as unknown as FileList, + items: [], + types: ['Files'], + clearData: jest.fn(), + getData: jest.fn(), + setData: jest.fn(), + setDragImage: jest.fn(), + dropEffect: 'none', + effectAllowed: 'none', + } as unknown as DataTransfer, + }; +} + +// ── Tests ────────────────────────────────────────────── +describe('MediaUploadStep', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + // ── Rendering ─────────────────────────────────────── + describe('Rendering', () => { + it('renders the drop zone with default label and hint', () => { + render(); + expect(screen.getByTestId('media-upload-step')).toBeInTheDocument(); + expect(screen.getByTestId('drop-zone')).toBeInTheDocument(); + expect(screen.getByText(/Drag & drop files here/i)).toBeInTheDocument(); + expect(screen.getByText(/Supported: images, videos, PDFs/i)).toBeInTheDocument(); + }); + + it('renders custom label and hint when provided', () => { + render( + , + ); + expect(screen.getByText('Upload your certificate')).toBeInTheDocument(); + expect(screen.getByText('Only PNG and PDF allowed')).toBeInTheDocument(); + }); + + it('renders the hidden file input', () => { + render(); + const input = screen.getByTestId('file-input'); + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute('type', 'file'); + }); + + it('drop zone is keyboard accessible', () => { + render(); + const dropZone = screen.getByTestId('drop-zone'); + expect(dropZone).toHaveAttribute('role', 'button'); + expect(dropZone).toHaveAttribute('tabIndex', '0'); + expect(dropZone).toHaveAttribute('aria-label', 'Upload media files'); + }); + }); + + // ── Click to Select ───────────────────────────────── + describe('Click to select', () => { + it('opens file dialog when drop zone is clicked', () => { + render(); + const input = screen.getByTestId('file-input') as HTMLInputElement; + const clickSpy = jest.spyOn(input, 'click'); + + fireEvent.click(screen.getByTestId('drop-zone')); + expect(clickSpy).toHaveBeenCalled(); + }); + + it('opens file dialog on Enter key', () => { + render(); + const input = screen.getByTestId('file-input') as HTMLInputElement; + const clickSpy = jest.spyOn(input, 'click'); + + fireEvent.keyDown(screen.getByTestId('drop-zone'), { key: 'Enter' }); + expect(clickSpy).toHaveBeenCalled(); + }); + + it('opens file dialog on Space key', () => { + render(); + const input = screen.getByTestId('file-input') as HTMLInputElement; + const clickSpy = jest.spyOn(input, 'click'); + + fireEvent.keyDown(screen.getByTestId('drop-zone'), { key: ' ' }); + expect(clickSpy).toHaveBeenCalled(); + }); + + it('processes a file selected via click', async () => { + render(); + const file = createFile('test-image.png', 1024, 'image/png'); + const input = screen.getByTestId('file-input'); + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + // Should show file in the list + expect(screen.getByText('test-image.png')).toBeInTheDocument(); + }); + }); + + // ── Drag and Drop ─────────────────────────────────── + describe('Drag and drop', () => { + it('shows active state on drag enter', () => { + render(); + const dropZone = screen.getByTestId('drop-zone'); + + fireEvent.dragEnter(dropZone, { preventDefault: jest.fn(), stopPropagation: jest.fn() }); + expect(screen.getByText('Drop files here…')).toBeInTheDocument(); + }); + + it('processes dropped files', async () => { + const onFilesChange = jest.fn(); + render(); + + const file = createFile('dropped.pdf', 2048, 'application/pdf'); + const dropZone = screen.getByTestId('drop-zone'); + + await act(async () => { + fireEvent.drop(dropZone, createDropEvent([file])); + }); + + expect(screen.getByText('dropped.pdf')).toBeInTheDocument(); + }); + + it('shows file name after successful drop', async () => { + render(); + const file = createFile('certificate.png', 5000, 'image/png'); + const dropZone = screen.getByTestId('drop-zone'); + + await act(async () => { + fireEvent.drop(dropZone, createDropEvent([file])); + }); + + expect(screen.getByText('certificate.png')).toBeInTheDocument(); + }); + }); + + // ── File Preview ──────────────────────────────────── + describe('File preview', () => { + it('displays image preview for image files', async () => { + render(); + const file = createFile('photo.jpg', 3000, 'image/jpeg'); + const input = screen.getByTestId('file-input'); + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + const img = screen.getByAlt('photo.jpg'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', mockObjectURL); + }); + + it('displays video preview for video files', async () => { + render(); + const file = createFile('clip.mp4', 10000, 'video/mp4'); + const input = screen.getByTestId('file-input'); + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + expect(screen.getByText('clip.mp4')).toBeInTheDocument(); + expect(screen.getByText('Video')).toBeInTheDocument(); + }); + + it('displays file size in human-readable format', async () => { + render(); + const file = createFile('doc.pdf', 2048000, 'application/pdf'); + const input = screen.getByTestId('file-input'); + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + expect(screen.getByText(/2.0 MB/)).toBeInTheDocument(); + }); + + it('displays file category badge', async () => { + render(); + const file = createFile('data.json', 500, 'application/json'); + const input = screen.getByTestId('file-input'); + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + expect(screen.getByText('Document')).toBeInTheDocument(); + }); + }); + + // ── File Removal ──────────────────────────────────── + describe('File removal', () => { + it('removes a file when X button is clicked', async () => { + render(); + const file = createFile('remove-me.png', 1000, 'image/png'); + const input = screen.getByTestId('file-input'); + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + expect(screen.getByText('remove-me.png')).toBeInTheDocument(); + + // Click remove + const removeBtn = screen.getByLabelText('Remove remove-me.png'); + await act(async () => { + fireEvent.click(removeBtn); + }); + + expect(screen.queryByText('remove-me.png')).not.toBeInTheDocument(); + }); + + it('cleans up object URL on removal', async () => { + render(); + const file = createFile('cleanup.png', 1000, 'image/png'); + const input = screen.getByTestId('file-input'); + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + const removeBtn = screen.getByLabelText('Remove cleanup.png'); + await act(async () => { + fireEvent.click(removeBtn); + }); + + expect(URL.revokeObjectURL).toHaveBeenCalledWith(mockObjectURL); + }); + }); + + // ── Upload Progress ───────────────────────────────── + describe('Upload progress', () => { + it('shows progress bar while uploading', async () => { + render(); + const file = createFile('uploading.pdf', 5000, 'application/pdf'); + const input = screen.getByTestId('file-input'); + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + // Progress bar should appear during upload simulation + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('shows "Upload complete" when done', async () => { + render(); + const file = createFile('done.pdf', 5000, 'application/pdf'); + const input = screen.getByTestId('file-input'); + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + // Fast-forward timers to complete upload + await act(async () => { + jest.advanceTimersByTime(2000); + }); + + await waitFor(() => { + expect(screen.getByText('Upload complete')).toBeInTheDocument(); + }); + }); + }); + + // ── Size Validation ───────────────────────────────── + describe('Size validation', () => { + it('rejects files exceeding maxSize', async () => { + render(); // 1000 byte limit + const file = createFile('too-big.pdf', 5000, 'application/pdf'); + const input = screen.getByTestId('file-input'); + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + expect(screen.getByTestId('error-banner')).toBeInTheDocument(); + expect(screen.getByText(/exceeds/)).toBeInTheDocument(); + }); + + it('rejects empty files', async () => { + render(); + const file = createFile('empty.txt', 0, 'text/plain'); + const input = screen.getByTestId('file-input'); + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + expect(screen.getByTestId('error-banner')).toBeInTheDocument(); + expect(screen.getByText(/empty/)).toBeInTheDocument(); + }); + }); + + // ── Multiple Files ────────────────────────────────── + describe('Multiple files', () => { + it('allows multiple files when multiple=true', async () => { + render(); + const file1 = createFile('first.png', 1000, 'image/png'); + const file2 = createFile('second.jpg', 2000, 'image/jpeg'); + const input = screen.getByTestId('file-input'); + + await act(async () => { + fireEvent.change(input, { target: { files: [file1, file2] } }); + }); + + expect(screen.getByText('first.png')).toBeInTheDocument(); + expect(screen.getByText('second.jpg')).toBeInTheDocument(); + }); + + it('replaces file when multiple=false (single mode)', async () => { + render(); + const input = screen.getByTestId('file-input'); + + // First file + const file1 = createFile('first.png', 1000, 'image/png'); + await act(async () => { + fireEvent.change(input, { target: { files: [file1] } }); + }); + expect(screen.getByText('first.png')).toBeInTheDocument(); + + // Second file should replace + const file2 = createFile('replacement.jpg', 2000, 'image/jpeg'); + await act(async () => { + fireEvent.change(input, { target: { files: [file2] } }); + }); + + expect(screen.queryByText('first.png')).not.toBeInTheDocument(); + expect(screen.getByText('replacement.jpg')).toBeInTheDocument(); + }); + }); + + // ── Controlled Mode ───────────────────────────────── + describe('Controlled mode', () => { + it('renders files from props', () => { + const controlledFiles: MediaFile[] = [ + { + id: 'test-1', + name: 'controlled.png', + size: 5000, + type: 'image/png', + previewUrl: 'blob:controlled', + progress: 100, + status: 'done', + }, + ]; + + render(); + + expect(screen.getByText('controlled.png')).toBeInTheDocument(); + expect(screen.getByText('Upload complete')).toBeInTheDocument(); + }); + + it('calls onFilesChange when controlled', async () => { + const onFilesChange = jest.fn(); + const controlledFiles: MediaFile[] = []; + + render(); + const input = screen.getByTestId('file-input'); + const file = createFile('new.png', 1000, 'image/png'); + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + expect(onFilesChange).toHaveBeenCalled(); + const calledWith = onFilesChange.mock.calls[0][0]; + expect(calledWith[0].name).toBe('new.png'); + }); + }); + + // ── Edge Cases ────────────────────────────────────── + describe('Edge cases', () => { + it('handles files with no type gracefully', async () => { + render(); + const file = createFile('notype', 1000, ''); + const input = screen.getByTestId('file-input'); + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + expect(screen.getByText('notype')).toBeInTheDocument(); + }); + + it('accepts custom accept types', async () => { + const customAccept = { 'text/plain': ['.txt'], 'text/csv': ['.csv'] }; + render(); + const dropZone = screen.getByTestId('drop-zone'); + expect(dropZone).toBeInTheDocument(); + }); + + it('drop zone is still visible in single mode after file added', () => { + // In single mode with no file, drop zone is visible + render(); + expect(screen.getByTestId('drop-zone')).toBeInTheDocument(); + }); + }); + + // ── Accessibility ─────────────────────────────────── + describe('Accessibility', () => { + it('error banner has role="alert"', async () => { + render(); + const file = createFile('big.pdf', 5000, 'application/pdf'); + const input = screen.getByTestId('file-input'); + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('file list has role="list"', async () => { + render(); + const file = createFile('a11y.png', 1000, 'image/png'); + const input = screen.getByTestId('file-input'); + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + expect(screen.getByRole('list')).toBeInTheDocument(); + expect(screen.getByRole('listitem')).toBeInTheDocument(); + }); + + it('progress bar has proper ARIA attributes', async () => { + render(); + const file = createFile('progress.pdf', 1000, 'application/pdf'); + const input = screen.getByTestId('file-input'); + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + const progressBar = screen.getByRole('progressbar'); + expect(progressBar).toHaveAttribute('aria-valuemin', '0'); + expect(progressBar).toHaveAttribute('aria-valuemax', '100'); + }); + }); +});