Files
baya-monorepo/client/src/components/DocumentUpload/DocumentUpload.tsx
T
2026-07-09 03:05:14 +03:30

314 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 (0100) 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;