manual improvement 2 & add telegram bot

This commit is contained in:
hamid
2026-07-27 23:58:16 +03:30
parent baa3cc63cd
commit e6a8f93a1e
57 changed files with 4181 additions and 2484 deletions
@@ -1,20 +1,45 @@
'use client';
import { ChangeEvent, FunctionComponent, useEffect, useRef, useState } from 'react';
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, Chip, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, TrustBadge } from '@/components';
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);
@@ -24,13 +49,36 @@ function parseSpecializations(json: string): string[] {
}
}
/** Nurse profile bootstrap (B7 header): avatar + bio + years + qualifications. */
/**
* 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');
@@ -43,28 +91,29 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
const badgeState = ownBadgeState(verificationStatus);
const fileInputRef = useRef<HTMLInputElement>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(initial?.avatarUrl ?? null);
const [bio, setBio] = useState(initial?.bio ?? '');
const [years, setYears] = useState(initial ? String(initial.yearsOfExperience) : '');
const [yearsError, setYearsError] = useState(false);
const level = splitPreset(initial?.educationLevel ?? '', EDUCATION_LEVELS);
const field = splitPreset(initial?.educationField ?? '', EDUCATION_FIELDS);
const initialLevel = initial?.educationLevel ?? '';
const initialField = initial?.educationField ?? '';
const [educationLevel, setEducationLevel] = useState(
(EDUCATION_LEVELS as readonly string[]).includes(initialLevel) ? initialLevel : initialLevel ? OTHER_CODE : '',
);
const [educationLevelOther, setEducationLevelOther] = useState(
(EDUCATION_LEVELS as readonly string[]).includes(initialLevel) ? '' : initialLevel,
);
const [educationField, setEducationField] = useState(
(EDUCATION_FIELDS as readonly string[]).includes(initialField) ? initialField : initialField ? OTHER_CODE : '',
);
const [educationFieldOther, setEducationFieldOther] = useState(
(EDUCATION_FIELDS as readonly string[]).includes(initialField) ? '' : initialField,
);
const [specializations, setSpecializations] = useState<string[]>(
parseSpecializations(initial?.specializationsJson ?? '[]'),
);
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);
@@ -79,224 +128,199 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
return () => window.removeEventListener('beforeunload', handler);
}, [avatarDirty]);
const pickFile = () => fileInputRef.current?.click();
const onFileSelected = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
uploadAvatar.mutate(file, {
onSuccess: (result) => setAvatarUrl(result.url),
onSuccess: (result) => setValue('avatarUrl', result.url, { shouldDirty: true }),
onError: () => enqueueSnackbar(t('avatar_upload_error'), { variant: 'error' }),
});
};
const toggleSpecialty = (value: string) =>
setSpecializations((prev) => (prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value]));
const handleSave = () => {
const trimmed = years.trim();
const yearsNum = trimmed === '' ? 0 : Number(trimmed);
const yearsInvalid = !Number.isInteger(yearsNum) || yearsNum < 0 || yearsNum > MAX_YEARS;
setYearsError(yearsInvalid);
if (yearsInvalid) return;
const resolvedLevel = educationLevel === OTHER_CODE ? educationLevelOther.trim() : educationLevel;
const resolvedField = educationField === OTHER_CODE ? educationFieldOther.trim() : educationField;
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: bio.trim(),
yearsOfExperience: yearsNum,
bio: values.bio.trim(),
yearsOfExperience: values.years.trim() === '' ? 0 : Number(values.years),
educationLevel: resolvedLevel,
educationField: resolvedField,
specializationsJson: JSON.stringify(specializations),
avatarUrl,
specializationsJson: JSON.stringify(values.specializations),
avatarUrl: values.avatarUrl,
},
{
onSuccess: () => enqueueSnackbar(t('saved'), { variant: 'success' }),
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
{/* The public trust signal on the nurse's own profile — the same badge f6 reuses in search. */}
<TrustBadge state={badgeState} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
<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 banner — shown until the aggregate is approved (incl. the expired state). */}
{badgeState !== 'verified' ? (
<Paper
elevation={0}
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-warning)' }}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon icon="warning" size={24} color="var(--bal-warning)" />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{/* 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>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('unverified_body')}
</Typography>
</Box>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('unverified_body')}
</Typography>
<AppButton
color="primary"
variant="outlined"
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>
</Paper>
) : null}
<Stack direction="row" sx={{ gap: 2, alignItems: 'center' }}>
<Avatar src={avatarUrl ?? undefined} sx={{ width: 72, height: 72, bgcolor: 'var(--bal-primary-soft)' }}>
{avatarUrl ? null : <AppIcon icon="account" size={36} color="var(--bal-primary)" />}
</Avatar>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('photo')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('photo_hint')}
</Typography>
<AppButton
variant="outlined"
color="primary"
startIcon="camera"
onClick={pickFile}
disabled={uploadAvatar.isPending}
sx={{ mt: 0.5, alignSelf: 'flex-start' }}
>
{uploadAvatar.isPending ? t('uploading') : t('upload')}
<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>
<input ref={fileInputRef} type="file" accept="image/*" hidden onChange={onFileSelected} />
</Stack>
</Stack>
<TextField
label={t('bio')}
value={bio}
onChange={(e) => setBio(e.target.value)}
helperText={t('bio_hint')}
multiline
minRows={3}
fullWidth
/>
<TextField
label={t('years')}
value={years}
onChange={(e) => {
setYears(e.target.value.replace(/\D/g, '').slice(0, 2));
if (yearsError) setYearsError(false);
}}
error={yearsError}
helperText={yearsError ? t('years_invalid') : undefined}
slotProps={{ htmlInput: { dir: 'ltr', inputMode: 'numeric', style: { textAlign: 'start' } } }}
sx={{ maxWidth: 200 }}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField select label={t('education_level_label')} value={educationLevel} onChange={(e) => setEducationLevel(e.target.value)} fullWidth>
{EDUCATION_LEVELS.map((code) => (
<MenuItem key={code} value={code}>
{t(`education_level_${code}`)}
</MenuItem>
))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</TextField>
<TextField select label={t('education_field_label')} value={educationField} onChange={(e) => setEducationField(e.target.value)} fullWidth>
{EDUCATION_FIELDS.map((code) => (
<MenuItem key={code} value={code}>
{t(`education_field_${code}`)}
</MenuItem>
))}
<MenuItem value={OTHER_CODE}>{t('education_other')}</MenuItem>
</TextField>
</Stack>
{educationLevel === OTHER_CODE ? (
<TextField
label={t('education_level_other_label')}
value={educationLevelOther}
onChange={(e) => setEducationLevelOther(e.target.value)}
fullWidth
/>
) : null}
{educationField === OTHER_CODE ? (
<TextField
label={t('education_field_other_label')}
value={educationFieldOther}
onChange={(e) => setEducationFieldOther(e.target.value)}
fullWidth
/>
) : null}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('specializations_label')}
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('deferred_services')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{SPECIALTY_PRESETS.map((code) => {
const selected = specializations.includes(code);
return (
<Chip
key={code}
label={tv.has(`specialty_${code}`) ? tv(`specialty_${code}`) : code}
onClick={() => toggleSpecialty(code)}
variant={selected ? 'filled' : 'outlined'}
sx={{
fontWeight: 500,
backgroundColor: selected ? 'var(--bal-primary)' : 'transparent',
color: selected ? 'var(--bal-primary-contrast)' : 'var(--bal-primary)',
borderColor: 'var(--bal-primary)',
}}
/>
);
})}
</Stack>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('deferred_services')}
</Typography>
<AppButton
variant="text"
color="primary"
startIcon="account"
to={`/${locale}${ROUTES.NURSE_PROFILE_PREVIEW}`}
sx={{ alignSelf: 'flex-start' }}
>
{t('preview_cta')}
</AppButton>
<AppButton
color="primary"
variant="contained"
onClick={handleSave}
disabled={upsert.isPending}
sx={{ alignSelf: 'flex-start' }}
>
{upsert.isPending ? tc('saving') : t('save')}
</AppButton>
</Box>
</Box>
</FormProvider>
);
};