'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 ; return ; } /** * 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(null); const level = splitPreset(initial?.educationLevel ?? '', EDUCATION_LEVELS); const field = splitPreset(initial?.educationField ?? '', EDUCATION_FIELDS); const form = useForm({ 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) => { 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 ( } /> {/* Blocked-until-verified nudge — shown until the aggregate is approved (incl. the expired state). */} {badgeState !== 'verified' ? ( {t('unverified_title')} {t('unverified_body')} {t('unverified_cta')} ) : null} {avatarUrl ? null : } {t('photo_hint')} fileInputRef.current?.click()} disabled={uploadAvatar.isPending} sx={{ alignSelf: 'flex-start' }} > {uploadAvatar.isPending ? t('uploading') : t('upload')} name="bio" label={t('bio')} helperText={t('bio_hint')} multiline minRows={3} fullWidth /> 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 }} /> name="educationLevel" select label={t('education_level_label')} fullWidth> {EDUCATION_LEVELS.map((code) => ( {t(`education_level_${code}`)} ))} {t('education_other')} name="educationField" select label={t('education_field_label')} fullWidth> {EDUCATION_FIELDS.map((code) => ( {t(`education_field_${code}`)} ))} {t('education_other')} {educationLevel === OTHER_CODE ? ( name="educationLevelOther" label={t('education_level_other_label')} rules={{ required: t('education_other_required') }} fullWidth /> ) : null} {educationField === OTHER_CODE ? ( name="educationFieldOther" label={t('education_field_other_label')} rules={{ required: t('education_other_required') }} fullWidth /> ) : null} name="specializations" options={SPECIALTY_PRESETS.map((code) => ({ code, label: tv.has(`specialty_${code}`) ? tv(`specialty_${code}`) : code, }))} allowCustomValues /> {upsert.isPending ? tc('saving') : t('save')} {t('preview_cta')} {t('deferred_services')} ); };