314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
'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;
|