327 lines
13 KiB
TypeScript
327 lines
13 KiB
TypeScript
'use client';
|
|
import { ChangeEvent, FunctionComponent, useEffect, useRef } from 'react';
|
|
import { useLocale, useTranslations } from 'next-intl';
|
|
import { useForm, FormProvider, useWatch } from 'react-hook-form';
|
|
import { useSnackbar } from 'notistack';
|
|
import { Avatar, Box, MenuItem, Stack, Typography } from '@mui/material';
|
|
import {
|
|
AccentCard,
|
|
AppButton,
|
|
AppIcon,
|
|
AppLoading,
|
|
FormSection,
|
|
PageHeader,
|
|
RhfChipSelect,
|
|
RhfTextField,
|
|
TrustBadge,
|
|
} from '@/components';
|
|
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
|
import { ROUTES } from '@/constants';
|
|
import { digitsOnly } from '@/utils';
|
|
import { useNurseProfile, useUpsertNurseProfile, useUploadAvatar } from '@/services/profiles';
|
|
import type { NurseProfile } from '@/services/profiles/types';
|
|
import { useVerificationStatus } from '@/services/verification';
|
|
import { ownBadgeState, SPECIALTY_PRESETS } from '@/services/verification/types';
|
|
|
|
const MAX_YEARS = 80;
|
|
const MAX_YEARS_DIGITS = 2;
|
|
const OTHER_CODE = '__other';
|
|
const EDUCATION_LEVELS = ['diploma', 'associate', 'bachelor', 'master', 'doctorate'] as const;
|
|
const EDUCATION_FIELDS = ['nursing', 'midwifery', 'anesthesia', 'operating_room', 'public_health'] as const;
|
|
|
|
interface ProfileFormValues {
|
|
avatarUrl: string | null;
|
|
bio: string;
|
|
years: string;
|
|
educationLevel: string;
|
|
educationLevelOther: string;
|
|
educationField: string;
|
|
educationFieldOther: string;
|
|
specializations: string[];
|
|
}
|
|
|
|
function parseSpecializations(json: string): string[] {
|
|
try {
|
|
const parsed = JSON.parse(json);
|
|
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Splits a stored free-text value into the (select code, "other" free-text) pair the form edits: a
|
|
* value the preset list knows becomes the code, anything else becomes «سایر» + the raw text.
|
|
*/
|
|
function splitPreset(stored: string, presets: readonly string[]): { code: string; other: string } {
|
|
if (presets.includes(stored)) return { code: stored, other: '' };
|
|
return stored ? { code: OTHER_CODE, other: stored } : { code: '', other: '' };
|
|
}
|
|
|
|
/** Nurse profile bootstrap (B7 header): avatar + bio + experience + qualifications. */
|
|
export default function NurseProfilePage() {
|
|
const { data: profile, isLoading } = useNurseProfile();
|
|
if (isLoading) return <AppLoading />;
|
|
return <NurseProfileForm initial={profile ?? null} />;
|
|
}
|
|
|
|
/**
|
|
* The nurse's public-facing profile, rebuilt around three named questions instead of one undivided
|
|
* column of inputs.
|
|
*
|
|
* What it replaced: a flat stack of avatar → bio → years → two selects → two conditional "other"
|
|
* fields → a chip row → a save button, with no headings and no statement of what any of it was for.
|
|
* The nurse could not tell which fields families actually see, which were required, or how much was
|
|
* left — and every keystroke re-rendered the trust badge, the verification banner and the uploader
|
|
* along with the field being typed into, because each input owned a `useState` at page level.
|
|
*
|
|
* Now: `FormSection` groups the fields into معرفی / تجربه و تحصیلات / تخصصها, each saying who the
|
|
* answer is for; react-hook-form owns the values so a keystroke re-renders one field; and validation
|
|
* lives on the field it governs rather than in a hand-rolled block at the top of the submit handler.
|
|
*/
|
|
const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({ initial }) => {
|
|
const t = useTranslations('nurseProfile');
|
|
const tv = useTranslations('verification');
|
|
const tc = useTranslations('common');
|
|
const locale = useLocale();
|
|
const { enqueueSnackbar } = useSnackbar();
|
|
const upsert = useUpsertNurseProfile();
|
|
const uploadAvatar = useUploadAvatar();
|
|
const { data: verificationStatus } = useVerificationStatus();
|
|
const badgeState = ownBadgeState(verificationStatus);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const level = splitPreset(initial?.educationLevel ?? '', EDUCATION_LEVELS);
|
|
const field = splitPreset(initial?.educationField ?? '', EDUCATION_FIELDS);
|
|
|
|
const form = useForm<ProfileFormValues>({
|
|
mode: 'onTouched',
|
|
defaultValues: {
|
|
avatarUrl: initial?.avatarUrl ?? null,
|
|
bio: initial?.bio ?? '',
|
|
years: initial ? String(initial.yearsOfExperience) : '',
|
|
educationLevel: level.code,
|
|
educationLevelOther: level.other,
|
|
educationField: field.code,
|
|
educationFieldOther: field.other,
|
|
specializations: parseSpecializations(initial?.specializationsJson ?? '[]'),
|
|
},
|
|
});
|
|
const { control, handleSubmit, setValue, getValues } = form;
|
|
|
|
// Only these three drive conditional rendering, so they are the only fields worth subscribing the
|
|
// page to — the rest re-render nothing but themselves.
|
|
const avatarUrl = useWatch({ control, name: 'avatarUrl' });
|
|
const educationLevel = useWatch({ control, name: 'educationLevel' });
|
|
const educationField = useWatch({ control, name: 'educationField' });
|
|
|
|
// A staged-but-unsaved avatar must never be silently discarded — warn on reload/tab-close.
|
|
const avatarDirty = avatarUrl !== (initial?.avatarUrl ?? null);
|
|
useEffect(() => {
|
|
if (!avatarDirty) return;
|
|
const handler = (event: BeforeUnloadEvent) => {
|
|
event.preventDefault();
|
|
// Chrome (and most engines) only show the native confirm dialog when returnValue is set.
|
|
event.returnValue = '';
|
|
};
|
|
window.addEventListener('beforeunload', handler);
|
|
return () => window.removeEventListener('beforeunload', handler);
|
|
}, [avatarDirty]);
|
|
|
|
const onFileSelected = (event: ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
event.target.value = '';
|
|
if (!file) return;
|
|
uploadAvatar.mutate(file, {
|
|
onSuccess: (result) => setValue('avatarUrl', result.url, { shouldDirty: true }),
|
|
onError: () => enqueueSnackbar(t('avatar_upload_error'), { variant: 'error' }),
|
|
});
|
|
};
|
|
|
|
const save = (values: ProfileFormValues) => {
|
|
const resolvedLevel =
|
|
values.educationLevel === OTHER_CODE ? values.educationLevelOther.trim() : values.educationLevel;
|
|
const resolvedField =
|
|
values.educationField === OTHER_CODE ? values.educationFieldOther.trim() : values.educationField;
|
|
|
|
upsert.mutate(
|
|
{
|
|
bio: values.bio.trim(),
|
|
yearsOfExperience: values.years.trim() === '' ? 0 : Number(values.years),
|
|
educationLevel: resolvedLevel,
|
|
educationField: resolvedField,
|
|
specializationsJson: JSON.stringify(values.specializations),
|
|
avatarUrl: values.avatarUrl,
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
enqueueSnackbar(t('saved'), { variant: 'success' });
|
|
// Re-baseline so the beforeunload guard and `isDirty` stop reporting saved work as unsaved.
|
|
form.reset(getValues());
|
|
},
|
|
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
|
|
},
|
|
);
|
|
};
|
|
|
|
return (
|
|
<FormProvider {...form}>
|
|
<Box
|
|
component="form"
|
|
noValidate
|
|
onSubmit={handleSubmit(save)}
|
|
sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}
|
|
>
|
|
<PageHeader title={t('title')} subtitle={t('subtitle')} meta={<TrustBadge state={badgeState} />} />
|
|
|
|
{/* Blocked-until-verified nudge — shown until the aggregate is approved (incl. the expired state). */}
|
|
{badgeState !== 'verified' ? (
|
|
<AccentCard tone="warning" padding="sm">
|
|
<Stack sx={{ gap: 1 }}>
|
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
|
<AppIcon icon="warning" size={20} color="var(--bal-warning)" />
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
|
{t('unverified_title')}
|
|
</Typography>
|
|
</Stack>
|
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
{t('unverified_body')}
|
|
</Typography>
|
|
<AppButton
|
|
color="primary"
|
|
variant="text"
|
|
endIcon="forward"
|
|
to={`/${locale}${ROUTES.NURSE_VERIFICATION}`}
|
|
sx={{ alignSelf: 'flex-start' }}
|
|
>
|
|
{t('unverified_cta')}
|
|
</AppButton>
|
|
</Stack>
|
|
</AccentCard>
|
|
) : null}
|
|
|
|
<FormSection title={t('section_intro_title')} description={t('section_intro_description')} icon="account">
|
|
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
|
|
<Avatar src={avatarUrl ?? undefined} sx={{ width: 64, height: 64, bgcolor: 'var(--bal-primary-soft)' }}>
|
|
{avatarUrl ? null : <AppIcon icon="account" size={32} color="var(--bal-primary)" />}
|
|
</Avatar>
|
|
<Stack sx={{ gap: 0.5, minWidth: 0 }}>
|
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
{t('photo_hint')}
|
|
</Typography>
|
|
<AppButton
|
|
variant="outlined"
|
|
color="primary"
|
|
startIcon="camera"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploadAvatar.isPending}
|
|
sx={{ alignSelf: 'flex-start' }}
|
|
>
|
|
{uploadAvatar.isPending ? t('uploading') : t('upload')}
|
|
</AppButton>
|
|
<input ref={fileInputRef} type="file" accept="image/*" hidden onChange={onFileSelected} />
|
|
</Stack>
|
|
</Stack>
|
|
|
|
<RhfTextField<ProfileFormValues>
|
|
name="bio"
|
|
label={t('bio')}
|
|
helperText={t('bio_hint')}
|
|
multiline
|
|
minRows={3}
|
|
fullWidth
|
|
/>
|
|
</FormSection>
|
|
|
|
<FormSection
|
|
title={t('section_experience_title')}
|
|
description={t('section_experience_description')}
|
|
icon="license"
|
|
>
|
|
<RhfTextField<ProfileFormValues>
|
|
name="years"
|
|
label={t('years')}
|
|
transform={(raw) => digitsOnly(raw).slice(0, MAX_YEARS_DIGITS)}
|
|
rules={{
|
|
validate: (value) => {
|
|
const trimmed = String(value ?? '').trim();
|
|
if (trimmed === '') return true;
|
|
const parsed = Number(trimmed);
|
|
return (Number.isInteger(parsed) && parsed >= 0 && parsed <= MAX_YEARS) || t('years_invalid');
|
|
},
|
|
}}
|
|
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
|
|
sx={{ maxWidth: 200 }}
|
|
/>
|
|
|
|
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
|
|
<RhfTextField<ProfileFormValues> name="educationLevel" select label={t('education_level_label')} fullWidth>
|
|
{EDUCATION_LEVELS.map((code) => (
|
|
<MenuItem key={code} value={code}>
|
|
{t(`education_level_${code}`)}
|
|
</MenuItem>
|
|
))}
|
|
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
|
|
</RhfTextField>
|
|
<RhfTextField<ProfileFormValues> name="educationField" select label={t('education_field_label')} fullWidth>
|
|
{EDUCATION_FIELDS.map((code) => (
|
|
<MenuItem key={code} value={code}>
|
|
{t(`education_field_${code}`)}
|
|
</MenuItem>
|
|
))}
|
|
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
|
|
</RhfTextField>
|
|
</Stack>
|
|
|
|
{educationLevel === OTHER_CODE ? (
|
|
<RhfTextField<ProfileFormValues>
|
|
name="educationLevelOther"
|
|
label={t('education_level_other_label')}
|
|
rules={{ required: t('education_other_required') }}
|
|
fullWidth
|
|
/>
|
|
) : null}
|
|
{educationField === OTHER_CODE ? (
|
|
<RhfTextField<ProfileFormValues>
|
|
name="educationFieldOther"
|
|
label={t('education_field_other_label')}
|
|
rules={{ required: t('education_other_required') }}
|
|
fullWidth
|
|
/>
|
|
) : null}
|
|
</FormSection>
|
|
|
|
<FormSection
|
|
title={t('specializations_label')}
|
|
description={t('section_specializations_description')}
|
|
icon="clinical"
|
|
optional
|
|
optionalLabel={tc('optional')}
|
|
>
|
|
<RhfChipSelect<ProfileFormValues>
|
|
name="specializations"
|
|
options={SPECIALTY_PRESETS.map((code) => ({
|
|
code,
|
|
label: tv.has(`specialty_${code}`) ? tv(`specialty_${code}`) : code,
|
|
}))}
|
|
allowCustomValues
|
|
/>
|
|
</FormSection>
|
|
|
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
<AppButton type="submit" color="primary" variant="contained" disabled={upsert.isPending}>
|
|
{upsert.isPending ? tc('saving') : t('save')}
|
|
</AppButton>
|
|
<AppButton variant="text" color="primary" startIcon="account" to={`/${locale}${ROUTES.NURSE_PROFILE_PREVIEW}`}>
|
|
{t('preview_cta')}
|
|
</AppButton>
|
|
</Stack>
|
|
|
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
{t('deferred_services')}
|
|
</Typography>
|
|
</Box>
|
|
</FormProvider>
|
|
);
|
|
};
|