Files
baya-monorepo/client/src/components/DocumentUpload/DocumentUpload.tsx
T
2026-07-27 22:27:04 +03:30

300 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 Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AppButton from '../common/AppButton';
import AppIcon from '../common/AppIcon';
import SurfaceCard from '../common/SurfaceCard';
import AccentCard from '../common/AccentCard';
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}
/>
{state === 'uploading' ? (
<SurfaceCard padding="sm" data-upload-state="uploading" sx={UPLOAD_PANEL_SX}>
{/* A re-upload of a rejected doc must show LIVE progress, not the frozen rejected card — the
reason stays visible above the bar so context isn't lost while the new attempt runs. */}
{rejected && rejectionReason ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{rejectionReason}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon="upload" size={20} color="var(--bal-primary)" />
<Typography variant="body2" sx={{ fontWeight: 500, flexGrow: 1, wordBreak: 'break-all' }}>
{fileName}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{progress}%
</Typography>
</Stack>
<LinearProgress variant="determinate" value={progress} sx={{ borderRadius: 'var(--bal-radius-sm)', height: 6 }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('upload_uploading')}
</Typography>
</SurfaceCard>
) : showUploaded ? (
<SurfaceCard padding="sm" data-upload-state="success" sx={UPLOAD_PANEL_SX}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
{previewUrl ? (
<Box
component="img"
src={previewUrl}
alt=""
sx={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 'var(--bal-radius-sm)', 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: 500, 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}>
{t('upload_change')}
</AppButton>
</Stack>
</SurfaceCard>
) : rejected ? (
<AccentCard tone="error" padding="sm" data-upload-state="rejected" sx={UPLOAD_PANEL_SX}>
<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={{ alignSelf: 'flex-start' }}
>
{t('upload_reupload')}
</AppButton>
</AccentCard>
) : state === 'error' ? (
<AccentCard tone="error" padding="sm" data-upload-state="error" sx={UPLOAD_PANEL_SX}>
<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={{ alignSelf: 'flex-start' }}
>
{t('upload_retry')}
</AppButton>
</AccentCard>
) : (
<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: 'var(--bal-radius-md)',
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: 700 }}>
{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 layout on top of SurfaceCard/AccentCard for the uploading / success / error / rejected states.
const UPLOAD_PANEL_SX = {
display: 'flex',
flexDirection: 'column',
gap: 1,
} as const;
export default DocumentUpload;