'use client'; import { ChangeEvent, FunctionComponent, useEffect, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; import Box from '@mui/material/Box'; import LinearProgress from '@mui/material/LinearProgress'; import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import AppButton from '../common/AppButton'; import AppIcon from '../common/AppIcon'; import { ACCEPTED_DOCUMENT_TYPES, MAX_DOCUMENT_SIZE_BYTES } from '@/services/verification/constants'; /** The stored document as the uploader displays it — a name + optional size, never bytes. */ export interface UploadedDocInfo { name: string; sizeBytes?: number; } type UploadState = 'idle' | 'uploading' | 'success' | 'error'; export interface DocumentUploadProps { /** Field label (already translated). */ label: string; /** Optional helper text under the label. */ hint?: string; /** Accepted MIME types. Defaults to jpg/png/pdf (the b6 object-storage limits). */ accept?: readonly string[]; /** Max file size in bytes. Defaults to 5 MB. */ maxSizeBytes?: number; /** Mobile camera hint: `environment` (rear — ID card) or `user` (front — selfie). */ capture?: 'user' | 'environment'; disabled?: boolean; /** * The async upload action — receives the file + a progress reporter (0–100) and resolves with the * stored doc. For a server step this calls the verification seam; for a local capture (B4 identity) it * validates + resolves locally without a round-trip. */ onUpload: (file: File, onProgress: (percent: number) => void) => Promise; /** Called after a successful upload with the resolved metadata. */ onUploaded?: (doc: UploadedDocInfo) => void; /** A previously-uploaded document (drives the "already uploaded ✓" state from server metadata). */ existingDoc?: UploadedDocInfo | null; /** When the step was rejected — renders the reason and a re-upload affordance (never a dead end). */ rejected?: boolean; rejectionReason?: string; } /** * Reusable document uploader for every verification document step (national-ID card, license, education * cert, criminal record). Owns the full state machine — idle → validating → uploading (progress %) → * success (✓ + file name / local image preview) → error (retry) — with client-side type/size validation * before any upload and a re-upload affordance on reject. Returns the server's stored **metadata** only; * a local image preview never becomes the source of the "uploaded" truth. Its own chrome strings come * from the `verification` namespace; the caller passes the field `label`/`hint`. * @component DocumentUpload */ const DocumentUpload: FunctionComponent = ({ label, hint, accept = ACCEPTED_DOCUMENT_TYPES, maxSizeBytes = MAX_DOCUMENT_SIZE_BYTES, capture, disabled = false, onUpload, onUploaded, existingDoc = null, rejected = false, rejectionReason, }) => { const t = useTranslations('verification'); const inputRef = useRef(null); const [state, setState] = useState('idle'); const [progress, setProgress] = useState(0); const [errorKey, setErrorKey] = useState(null); const [fileName, setFileName] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); // A local image preview is an object URL — revoke it when it changes or the component unmounts. useEffect(() => { return () => { if (previewUrl) URL.revokeObjectURL(previewUrl); }; }, [previewUrl]); const openPicker = () => inputRef.current?.click(); const onFileSelected = async (event: ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ''; // allow re-picking the same file after an error if (!file) return; if (!accept.includes(file.type)) { setState('error'); setErrorKey('upload_bad_type'); return; } if (file.size > maxSizeBytes) { setState('error'); setErrorKey('upload_too_large'); return; } setErrorKey(null); setFileName(file.name); if (file.type.startsWith('image/')) { setPreviewUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(file); }); } setProgress(0); setState('uploading'); try { const doc = await onUpload(file, setProgress); setFileName(doc.name); setState('success'); onUploaded?.(doc); } catch { // 401/403/5xx are already toasted by the fetch layer; show a retryable inline error here. setState('error'); setErrorKey('upload_error'); } }; const megabytes = Math.round(maxSizeBytes / (1024 * 1024)); const acceptAttr = accept.join(','); // The "already uploaded" resting state is driven by server metadata (existingDoc) or a just-completed // upload — never by retained bytes. const showUploaded = state === 'success' || (state === 'idle' && existingDoc != null); const uploadedName = fileName ?? existingDoc?.name ?? ''; return ( {label} {hint ? ( {hint} ) : null} {rejected ? ( {t('upload_rejected')} {rejectionReason ? ( {rejectionReason} ) : null} {t('upload_reupload')} ) : state === 'uploading' ? ( {fileName} {progress}% {t('upload_uploading')} ) : showUploaded ? ( {previewUrl ? ( ) : ( )} {uploadedName} {t('upload_success')} {t('upload_change')} ) : state === 'error' ? ( {t(errorKey ?? 'upload_error', { size: megabytes })} {t('upload_retry')} ) : ( { if (!disabled && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); openPicker(); } }} sx={{ p: 3, borderRadius: 2, border: '1px dashed', borderColor: 'divider', textAlign: 'center', cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.6 : 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.75, transition: 'border-color 120ms', '&:hover': disabled ? undefined : { borderColor: 'var(--bal-primary)' }, }} > {capture ? t('upload_capture') : t('upload_choose')} {t('upload_size_hint', { size: megabytes })} )} ); }; // Shared card styling for the uploading / success / error states. const uploadedSx = { p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', display: 'flex', flexDirection: 'column', gap: 1, } as const; export default DocumentUpload;