frontend phase 5 & backend phase 12
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
'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<UploadedDocInfo>;
|
||||
/** 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<DocumentUploadProps> = ({
|
||||
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<HTMLInputElement>(null);
|
||||
|
||||
const [state, setState] = useState<UploadState>('idle');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [errorKey, setErrorKey] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{hint ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{hint}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={acceptAttr}
|
||||
capture={capture}
|
||||
hidden
|
||||
disabled={disabled}
|
||||
onChange={onFileSelected}
|
||||
/>
|
||||
|
||||
{rejected ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-upload-state="rejected"
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStartWidth: 4,
|
||||
borderInlineStartColor: 'var(--bal-error)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="rejected" size={20} color="var(--bal-error)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('upload_rejected')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{rejectionReason ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{rejectionReason}
|
||||
</Typography>
|
||||
) : null}
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="upload"
|
||||
onClick={openPicker}
|
||||
disabled={disabled}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('upload_reupload')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
) : state === 'uploading' ? (
|
||||
<Paper elevation={0} data-upload-state="uploading" sx={uploadedSx}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="upload" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, flexGrow: 1, wordBreak: 'break-all' }}>
|
||||
{fileName}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{progress}%
|
||||
</Typography>
|
||||
</Stack>
|
||||
<LinearProgress variant="determinate" value={progress} sx={{ borderRadius: 1, height: 6 }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('upload_uploading')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : showUploaded ? (
|
||||
<Paper elevation={0} data-upload-state="success" sx={uploadedSx}>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
{previewUrl ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={previewUrl}
|
||||
alt=""
|
||||
sx={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 1.5, flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<AppIcon icon="document" size={28} color="var(--bal-primary)" />
|
||||
)}
|
||||
<Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, wordBreak: 'break-all' }}>
|
||||
{uploadedName}
|
||||
</Typography>
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="verified" size={16} color="var(--bal-success)" />
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-success)' }}>
|
||||
{t('upload_success')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<AppButton variant="text" color="primary" onClick={openPicker} disabled={disabled} sx={{ m: 0 }}>
|
||||
{t('upload_change')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : state === 'error' ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-upload-state="error"
|
||||
sx={{ ...uploadedSx, borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-error)' }}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="error" size={20} color="var(--bal-error)" />
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-error)', flexGrow: 1 }}>
|
||||
{t(errorKey ?? 'upload_error', { size: megabytes })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="refresh"
|
||||
onClick={openPicker}
|
||||
disabled={disabled}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('upload_retry')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
) : (
|
||||
<Box
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
data-upload-state="idle"
|
||||
onClick={disabled ? undefined : openPicker}
|
||||
onKeyDown={(event) => {
|
||||
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)' },
|
||||
}}
|
||||
>
|
||||
<AppIcon icon={capture ? 'camera' : 'upload'} size={28} color="var(--bal-primary)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{capture ? t('upload_capture') : t('upload_choose')}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('upload_size_hint', { size: megabytes })}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 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;
|
||||
Reference in New Issue
Block a user