frontend phase 5 & backend phase 12

This commit is contained in:
hamid
2026-07-09 03:05:14 +03:30
parent 465f75c29e
commit dc64472631
98 changed files with 11847 additions and 136 deletions
@@ -0,0 +1,79 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
// next-intl echoes keys (and ignores interpolation params) so we assert on the state keys.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
import DocumentUpload from './DocumentUpload';
function renderUpload(props: Partial<React.ComponentProps<typeof DocumentUpload>> = {}) {
const onUpload = props.onUpload ?? jest.fn().mockResolvedValue({ name: 'license.pdf' });
const utils = render(
<ThemeProvider>
<DocumentUpload label="License" onUpload={onUpload} {...props} />
</ThemeProvider>,
);
const input = utils.container.querySelector('input[type="file"]') as HTMLInputElement;
return { ...utils, input, onUpload };
}
function selectFile(input: HTMLInputElement, file: File) {
Object.defineProperty(input, 'files', { value: [file], configurable: true });
fireEvent.change(input);
}
describe('<DocumentUpload/> component', () => {
it('renders the field label and the idle choose zone', () => {
const { container } = renderUpload();
expect(screen.getByText('License')).toBeInTheDocument();
expect(container.querySelector('[data-upload-state="idle"]')).toBeInTheDocument();
});
it('rejects a disallowed file type before uploading', () => {
const { input, onUpload, container } = renderUpload();
selectFile(input, new File(['x'], 'note.txt', { type: 'text/plain' }));
expect(onUpload).not.toHaveBeenCalled();
expect(container.querySelector('[data-upload-state="error"]')).toBeInTheDocument();
expect(screen.getByText('upload_bad_type')).toBeInTheDocument();
});
it('rejects a file over the size cap before uploading', () => {
const { input, onUpload } = renderUpload({ maxSizeBytes: 10 });
const big = new File([new Uint8Array(50)], 'card.png', { type: 'image/png' });
selectFile(input, big);
expect(onUpload).not.toHaveBeenCalled();
expect(screen.getByText('upload_too_large')).toBeInTheDocument();
});
it('uploads a valid file and shows the success state', async () => {
const onUploaded = jest.fn();
const { input, onUpload } = renderUpload({ onUploaded });
selectFile(input, new File(['%PDF'], 'license.pdf', { type: 'application/pdf' }));
await waitFor(() => expect(screen.getByText('upload_success')).toBeInTheDocument());
expect(onUpload).toHaveBeenCalledTimes(1);
expect(onUploaded).toHaveBeenCalledWith({ name: 'license.pdf' });
});
it('shows a retryable error when the upload fails', async () => {
const onUpload = jest.fn().mockRejectedValue(new Error('boom'));
const { input, container } = renderUpload({ onUpload });
selectFile(input, new File(['%PDF'], 'license.pdf', { type: 'application/pdf' }));
await waitFor(() => expect(container.querySelector('[data-upload-state="error"]')).toBeInTheDocument());
expect(screen.getByText('upload_retry')).toBeInTheDocument();
});
it('renders the rejected state with its reason and a re-upload affordance', () => {
const { container } = renderUpload({ rejected: true, rejectionReason: 'Blurry scan' });
expect(container.querySelector('[data-upload-state="rejected"]')).toBeInTheDocument();
expect(screen.getByText('Blurry scan')).toBeInTheDocument();
expect(screen.getByText('upload_reupload')).toBeInTheDocument();
});
it('renders an already-uploaded document from server metadata', () => {
const { container } = renderUpload({ existingDoc: { name: 'prior-license.pdf' } });
expect(container.querySelector('[data-upload-state="success"]')).toBeInTheDocument();
expect(screen.getByText('prior-license.pdf')).toBeInTheDocument();
});
});
@@ -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 (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;
@@ -0,0 +1,2 @@
export { default } from './DocumentUpload';
export type { DocumentUploadProps, UploadedDocInfo } from './DocumentUpload';
@@ -0,0 +1,38 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
// next-intl echoes keys so we assert on the label key each state maps to.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
import TrustBadge from './TrustBadge';
import type { BadgeState } from '@/services/verification/types';
function renderBadge(state: BadgeState) {
return render(
<ThemeProvider>
<TrustBadge state={state} />
</ThemeProvider>,
);
}
describe('<TrustBadge/> component', () => {
it('renders the verified label + a data attribute for the verified state', () => {
const { container } = renderBadge('verified');
expect(screen.getByText('badge_verified')).toBeInTheDocument();
expect(container.querySelector('[data-badge-state="verified"]')).toBeInTheDocument();
});
it('renders the unverified state distinctly (neutral, not alarming)', () => {
const { container } = renderBadge('unverified');
expect(screen.getByText('badge_unverified')).toBeInTheDocument();
expect(container.querySelector('[data-badge-state="unverified"]')).toBeInTheDocument();
});
it('renders expired as its own state, distinct from unverified', () => {
const { container } = renderBadge('expired');
expect(screen.getByText('badge_expired')).toBeInTheDocument();
expect(container.querySelector('[data-badge-state="expired"]')).toBeInTheDocument();
});
});
@@ -0,0 +1,50 @@
import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import Chip, { ChipProps } from '@mui/material/Chip';
import AppIcon from '../common/AppIcon';
import type { BadgeState } from '@/services/verification/types';
interface BadgeStyle {
bg: string;
fg: string;
icon: string;
labelKey: string;
}
// Colors resolve from the semantic --bal-* tokens so the badge switches with the color scheme.
// verified = green trust mark; unverified = neutral (never alarming); expired = amber "needs renewal"
// (distinct from unverified — a required credential lapsed). Never a hard-coded hex.
const BADGE_STYLE: Record<BadgeState, BadgeStyle> = {
verified: { bg: 'var(--bal-success)', fg: 'var(--bal-success-contrast)', icon: 'verified', labelKey: 'badge_verified' },
unverified: { bg: 'var(--bal-divider)', fg: 'var(--bal-text-secondary)', icon: 'info', labelKey: 'badge_unverified' },
expired: { bg: 'var(--bal-warning)', fg: 'var(--bal-warning-contrast)', icon: 'warning', labelKey: 'badge_expired' },
};
export interface TrustBadgeProps extends Omit<ChipProps, 'color' | 'icon' | 'label'> {
/** The trust state — verified / unverified / expired. */
state: BadgeState;
}
/**
* The public trust signal (the "✓ تاییدشده" mark) rendered on a nurse's profile and — reused unchanged
* in f6 — on search results and the public nurse profile. Fed by `GetVerifiedBadgeQuery`; the state is
* derived by the caller (`ownBadgeState`/`publicBadgeState`). Honest by construction: `verified` only
* renders when the aggregate is approved; `expired` is visually distinct from never-verified.
* @component TrustBadge
*/
const TrustBadge: FunctionComponent<TrustBadgeProps> = ({ state, size = 'small', sx, ...rest }) => {
const t = useTranslations('verification');
const style = BADGE_STYLE[state];
return (
<Chip
data-badge-state={state}
size={size}
label={t(style.labelKey)}
icon={<AppIcon icon={style.icon} size={16} color={style.fg} />}
sx={{ backgroundColor: style.bg, color: style.fg, fontWeight: 700, ...sx }}
{...rest}
/>
);
};
export default TrustBadge;
@@ -0,0 +1,2 @@
export { default } from './TrustBadge';
export type { TrustBadgeProps } from './TrustBadge';
@@ -48,6 +48,13 @@ import PostSurgeryIcon from '@mui/icons-material/HealingOutlined';
import InfantIcon from '@mui/icons-material/ChildCareOutlined';
import ChronicIcon from '@mui/icons-material/MonitorHeartOutlined';
import CompanionshipIcon from '@mui/icons-material/VolunteerActivismOutlined';
// Verification — nurse trust flow (f5/b6): document upload, credential + identity, re-upload
import UploadIcon from '@mui/icons-material/CloudUploadOutlined';
import DocumentIcon from '@mui/icons-material/InsertDriveFileOutlined';
import RefreshIcon from '@mui/icons-material/RefreshOutlined';
import IdentityIcon from '@mui/icons-material/BadgeOutlined';
import LicenseIcon from '@mui/icons-material/WorkspacePremiumOutlined';
import PublishIcon from '@mui/icons-material/RocketLaunchOutlined';
/**
* List of all available Icon names
@@ -110,4 +117,10 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
infant: InfantIcon,
chronic: ChronicIcon,
companionship: CompanionshipIcon,
upload: UploadIcon,
document: DocumentIcon,
refresh: RefreshIcon,
identity: IdentityIcon,
license: LicenseIcon,
publish: PublishIcon,
};
+6
View File
@@ -15,6 +15,8 @@ import BankStatusPanel from './BankStatusPanel';
import CategoryTile from './CategoryTile';
import PriceDisplay from './PriceDisplay';
import VariantCard from './VariantCard';
import TrustBadge from './TrustBadge';
import DocumentUpload from './DocumentUpload';
export {
UserInfo,
@@ -32,6 +34,8 @@ export {
CategoryTile,
PriceDisplay,
VariantCard,
TrustBadge,
DocumentUpload,
};
export type { PlaceholderScreenProps } from './PlaceholderScreen';
export type { OtpInputProps } from './OtpInput';
@@ -47,3 +51,5 @@ export type { BankStatusPanelProps } from './BankStatusPanel';
export type { CategoryTileProps } from './CategoryTile';
export type { PriceDisplayProps } from './PriceDisplay';
export type { VariantCardProps } from './VariantCard';
export type { TrustBadgeProps } from './TrustBadge';
export type { DocumentUploadProps, UploadedDocInfo } from './DocumentUpload';