389 lines
15 KiB
TypeScript
389 lines
15 KiB
TypeScript
'use client';
|
|
import { FunctionComponent, useState } from 'react';
|
|
import { useLocale, useTranslations } from 'next-intl';
|
|
import { useRouter } from 'next/navigation';
|
|
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
|
import { useSnackbar } from 'notistack';
|
|
import { Box, Divider, MenuItem, Skeleton, Stack, Typography } from '@mui/material';
|
|
import {
|
|
AppButton,
|
|
AppIcon,
|
|
ConfirmDialog,
|
|
ErrorState,
|
|
FormDialogShell,
|
|
PhoneNumberField,
|
|
ProfileSummary,
|
|
RhfControlGroup,
|
|
RhfTextField,
|
|
} from '@/components';
|
|
import LocaleSwitcher from '@/components/common/LocaleSwitcher';
|
|
import { ThemeModeSetting } from '@/components/settings';
|
|
import { isIranianMobile } from '@/components/PhoneNumberField';
|
|
import { ROUTES } from '@/constants';
|
|
import { digitsOnly } from '@/utils';
|
|
import { ActorSwitcher } from '@/layout';
|
|
import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles';
|
|
import { useMe, useLogout } from '@/services/auth';
|
|
import type { CustomerProfile } from '@/services/profiles/types';
|
|
|
|
/** Customer profile — the account hub: identity header, grouped rows, emergency-contact status card. */
|
|
export default function CustomerProfilePage() {
|
|
const t = useTranslations('profile');
|
|
const tc = useTranslations('common');
|
|
const { data: profile, isLoading, isError, refetch } = useCustomerProfile();
|
|
const { data: me, isLoading: meLoading } = useMe();
|
|
|
|
if (isLoading || meLoading) return <ProfileSkeleton />;
|
|
// The form must never render on a failed fetch — it would otherwise show blank/undefined fields
|
|
// whose save could overwrite server truth.
|
|
if (isError) return <ErrorState message={t('load_error')} retryLabel={tc('retry')} onRetry={() => refetch()} />;
|
|
|
|
return (
|
|
<AccountHub
|
|
initial={profile ?? null}
|
|
nameFallback={{ firstName: me?.firstName ?? null, lastName: me?.lastName ?? null }}
|
|
phone={me?.phone}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface AccountFormValues {
|
|
firstName: string;
|
|
lastName: string;
|
|
language: string;
|
|
emergencyName: string;
|
|
emergencyPhone: string;
|
|
}
|
|
|
|
const AccountHub: FunctionComponent<{
|
|
initial: CustomerProfile | null;
|
|
nameFallback: { firstName: string | null; lastName: string | null };
|
|
phone?: string;
|
|
}> = ({ initial, nameFallback, phone }) => {
|
|
const t = useTranslations('profile');
|
|
const tc = useTranslations('common');
|
|
const locale = useLocale();
|
|
const router = useRouter();
|
|
const { enqueueSnackbar } = useSnackbar();
|
|
const upsert = useUpsertCustomerProfile();
|
|
const logout = useLogout();
|
|
|
|
const [personalSheetOpen, setPersonalSheetOpen] = useState(false);
|
|
const [languageSheetOpen, setLanguageSheetOpen] = useState(false);
|
|
const [emergencySheetOpen, setEmergencySheetOpen] = useState(false);
|
|
const [signOutOpen, setSignOutOpen] = useState(false);
|
|
|
|
// ONE form behind all three sheets. Each sheet edits its own slice, but every save writes the whole
|
|
// profile (the wire upsert has no PATCH semantics), so the untouched fields have to come from
|
|
// somewhere — a single form is that somewhere, and `dirtyFields` then answers per-sheet "is there
|
|
// unsaved work here?" without a hand-written comparison per section.
|
|
const form = useForm<AccountFormValues>({
|
|
mode: 'onTouched',
|
|
defaultValues: {
|
|
firstName: initial?.firstName ?? nameFallback.firstName ?? '',
|
|
lastName: initial?.lastName ?? nameFallback.lastName ?? '',
|
|
language: initial?.preferredLanguage ?? 'fa',
|
|
emergencyName: initial?.defaultEmergencyContactName ?? '',
|
|
emergencyPhone: digitsOnly(initial?.defaultEmergencyContactPhone ?? ''),
|
|
},
|
|
});
|
|
const { control, formState, getValues, reset, trigger } = form;
|
|
const { dirtyFields } = formState;
|
|
const watched = useWatch({ control });
|
|
|
|
const displayName = [watched.firstName, watched.lastName].filter(Boolean).join(' ').trim() || phone || '';
|
|
const emergencyComplete = Boolean(watched.emergencyName?.trim() && watched.emergencyPhone);
|
|
|
|
const save = (onDone: () => void) => {
|
|
const values = getValues();
|
|
upsert.mutate(
|
|
{
|
|
defaultEmergencyContactName: values.emergencyName.trim(),
|
|
defaultEmergencyContactPhone: values.emergencyPhone,
|
|
firstName: values.firstName.trim() || null,
|
|
lastName: values.lastName.trim() || null,
|
|
preferredLanguage: values.language,
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
enqueueSnackbar(t('saved'), { variant: 'success' });
|
|
// Re-baseline so the saved slice stops counting as unsaved work in its sheet's discard guard.
|
|
reset(getValues());
|
|
onDone();
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
const savePersonal = () => save(() => setPersonalSheetOpen(false));
|
|
const saveLanguage = () => save(() => setLanguageSheetOpen(false));
|
|
const saveEmergency = async () => {
|
|
if (!(await trigger(['emergencyName', 'emergencyPhone']))) return;
|
|
save(() => setEmergencySheetOpen(false));
|
|
};
|
|
|
|
const personalDirty = Boolean(dirtyFields.firstName || dirtyFields.lastName);
|
|
const languageDirty = Boolean(dirtyFields.language);
|
|
const emergencyDirty = Boolean(dirtyFields.emergencyName || dirtyFields.emergencyPhone);
|
|
|
|
const goTo = (path: string) => router.push(`/${locale}${path}`);
|
|
|
|
// Shared discard-confirm strings for every FormDialogShell instance below.
|
|
const closeLabel = tc('close');
|
|
const discardTitle = tc('discard_title');
|
|
const discardBody = tc('discard_body');
|
|
const discardConfirmLabel = tc('discard_confirm');
|
|
const cancelLabel = tc('cancel');
|
|
|
|
return (
|
|
<FormProvider {...form}>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 520 }}>
|
|
<ProfileSummary displayName={displayName} phone={phone} initialsFallback={displayName || undefined} />
|
|
|
|
<Stack sx={{ gap: 1 }}>
|
|
<AccountRow icon="account" label={t('row_personal')} onClick={() => setPersonalSheetOpen(true)} />
|
|
<EmergencyContactCard
|
|
complete={emergencyComplete}
|
|
name={watched.emergencyName ?? ''}
|
|
phone={watched.emergencyPhone ?? ''}
|
|
onEdit={() => setEmergencySheetOpen(true)}
|
|
/>
|
|
<AccountRow icon="location" label={t('row_addresses')} onClick={() => goTo(ROUTES.ADDRESSES)} />
|
|
<AccountRow icon="language" label={t('row_language')} onClick={() => setLanguageSheetOpen(true)} />
|
|
{/* The app's appearance control lives here (and in each other actor's settings hub) — it
|
|
used to occupy a permanent slot in every top bar for a preference set once. */}
|
|
<ThemeModeSetting />
|
|
<AccountRow icon="notifications" label={t('row_notifications')} onClick={() => goTo(ROUTES.NOTIFICATIONS)} />
|
|
<AccountRow icon="support" label={t('row_support')} onClick={() => goTo(ROUTES.SUPPORT_TICKETS)} />
|
|
</Stack>
|
|
|
|
{/* Renders nothing for a single-role session — see ActorSwitcher's own doc. */}
|
|
<ActorSwitcher target="nurse" />
|
|
|
|
<Stack sx={{ gap: 1 }}>
|
|
<Divider sx={{ my: 0.5 }} />
|
|
<AccountRow icon="logout" label={t('sign_out')} onClick={() => setSignOutOpen(true)} tone="error" />
|
|
</Stack>
|
|
|
|
{/* اطلاعات شخصی */}
|
|
<FormDialogShell
|
|
open={personalSheetOpen}
|
|
title={t('row_personal')}
|
|
dirty={personalDirty}
|
|
onClose={() => setPersonalSheetOpen(false)}
|
|
closeLabel={closeLabel}
|
|
discardTitle={discardTitle}
|
|
discardBody={discardBody}
|
|
discardConfirmLabel={discardConfirmLabel}
|
|
discardCancelLabel={cancelLabel}
|
|
>
|
|
<Stack sx={{ gap: 2.5 }}>
|
|
<RhfTextField<AccountFormValues> name="firstName" label={t('first_name')} fullWidth />
|
|
<RhfTextField<AccountFormValues> name="lastName" label={t('last_name')} fullWidth />
|
|
<SheetActions onCancel={() => setPersonalSheetOpen(false)} onSave={savePersonal} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
|
|
</Stack>
|
|
</FormDialogShell>
|
|
|
|
{/* زبان — the row owns the server-stored preference; the actual UI locale switch is phase 2's LocaleSwitcher, reused verbatim. */}
|
|
<FormDialogShell
|
|
open={languageSheetOpen}
|
|
title={t('row_language')}
|
|
dirty={languageDirty}
|
|
onClose={() => setLanguageSheetOpen(false)}
|
|
closeLabel={closeLabel}
|
|
discardTitle={discardTitle}
|
|
discardBody={discardBody}
|
|
discardConfirmLabel={discardConfirmLabel}
|
|
discardCancelLabel={cancelLabel}
|
|
>
|
|
<Stack sx={{ gap: 2.5 }}>
|
|
<Stack sx={{ gap: 1 }}>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
|
{t('app_language')}
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
{t('app_language_hint')}
|
|
</Typography>
|
|
<LocaleSwitcher />
|
|
</Stack>
|
|
<Divider />
|
|
<RhfTextField<AccountFormValues> name="language" select label={t('language')} helperText={t('language_hint')}>
|
|
<MenuItem value="fa">{t('language_fa')}</MenuItem>
|
|
<MenuItem value="en">{t('language_en')}</MenuItem>
|
|
</RhfTextField>
|
|
<SheetActions onCancel={() => setLanguageSheetOpen(false)} onSave={saveLanguage} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
|
|
</Stack>
|
|
</FormDialogShell>
|
|
|
|
{/* مخاطب اضطراری */}
|
|
<FormDialogShell
|
|
open={emergencySheetOpen}
|
|
title={t('emergency_section')}
|
|
dirty={emergencyDirty}
|
|
onClose={() => setEmergencySheetOpen(false)}
|
|
closeLabel={closeLabel}
|
|
discardTitle={discardTitle}
|
|
discardBody={discardBody}
|
|
discardConfirmLabel={discardConfirmLabel}
|
|
discardCancelLabel={cancelLabel}
|
|
>
|
|
<Stack sx={{ gap: 2.5 }}>
|
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
{t('emergency_hint')}
|
|
</Typography>
|
|
<RhfTextField<AccountFormValues>
|
|
name="emergencyName"
|
|
label={t('emergency_name')}
|
|
rules={{ validate: (value) => String(value ?? '').trim().length > 0 }}
|
|
fullWidth
|
|
/>
|
|
<RhfControlGroup<AccountFormValues>
|
|
name="emergencyPhone"
|
|
rules={{ validate: (value) => isIranianMobile(String(value ?? '')) }}
|
|
>
|
|
{({ field, hasError }) => (
|
|
<PhoneNumberField
|
|
label={t('emergency_phone')}
|
|
value={(field.value as string) ?? ''}
|
|
onChange={field.onChange}
|
|
error={hasError}
|
|
helperText={hasError ? t('emergency_phone_invalid') : undefined}
|
|
fullWidth
|
|
/>
|
|
)}
|
|
</RhfControlGroup>
|
|
<SheetActions onCancel={() => setEmergencySheetOpen(false)} onSave={saveEmergency} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
|
|
</Stack>
|
|
</FormDialogShell>
|
|
|
|
<ConfirmDialog
|
|
open={signOutOpen}
|
|
title={t('sign_out_confirm_title')}
|
|
body={t('sign_out_confirm_body')}
|
|
confirmLabel={t('sign_out')}
|
|
cancelLabel={cancelLabel}
|
|
confirmColor="error"
|
|
onClose={() => setSignOutOpen(false)}
|
|
onConfirm={() => {
|
|
setSignOutOpen(false);
|
|
logout.mutate();
|
|
}}
|
|
/>
|
|
</Box>
|
|
</FormProvider>
|
|
);
|
|
};
|
|
|
|
const AccountRow: FunctionComponent<{
|
|
icon: string;
|
|
label: string;
|
|
onClick: () => void;
|
|
tone?: 'default' | 'error';
|
|
}> = ({ icon, label, onClick, tone = 'default' }) => (
|
|
<Box
|
|
component="button"
|
|
type="button"
|
|
onClick={onClick}
|
|
sx={{ background: 'none', border: 'none', p: 0, width: '100%', textAlign: 'start', font: 'inherit', cursor: 'pointer' }}
|
|
>
|
|
<Stack
|
|
direction="row"
|
|
sx={{
|
|
alignItems: 'center',
|
|
gap: 1.5,
|
|
p: 1.5,
|
|
borderRadius: 'var(--bal-radius-md)',
|
|
color: tone === 'error' ? 'var(--bal-error)' : 'text.primary',
|
|
'&:hover': { bgcolor: 'action.hover' },
|
|
}}
|
|
>
|
|
<AppIcon icon={icon} size={22} color={tone === 'error' ? 'var(--bal-error)' : 'var(--bal-primary)'} />
|
|
<Typography variant="body1" sx={{ flexGrow: 1, fontWeight: 500 }}>
|
|
{label}
|
|
</Typography>
|
|
{tone !== 'error' ? <AppIcon icon="forward" size={18} color="var(--bal-text-secondary)" /> : null}
|
|
</Stack>
|
|
</Box>
|
|
);
|
|
|
|
const EmergencyContactCard: FunctionComponent<{
|
|
complete: boolean;
|
|
name: string;
|
|
phone: string;
|
|
onEdit: () => void;
|
|
}> = ({ complete, name, phone, onEdit }) => {
|
|
const t = useTranslations('profile');
|
|
return (
|
|
<Box sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
|
|
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
|
|
<AppIcon icon={complete ? 'verified' : 'emergency'} size={22} color={complete ? 'var(--bal-success)' : 'var(--bal-warning)'} />
|
|
<Stack sx={{ flexGrow: 1, gap: 0.25, minWidth: 0 }}>
|
|
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
|
{t('emergency_section')}
|
|
</Typography>
|
|
{complete ? (
|
|
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
{name}
|
|
</Typography>
|
|
<Typography
|
|
component="a"
|
|
href={`tel:${phone}`}
|
|
dir="ltr"
|
|
variant="body2"
|
|
sx={{ color: 'var(--bal-primary)', textDecoration: 'none' }}
|
|
>
|
|
{phone}
|
|
</Typography>
|
|
</Stack>
|
|
) : (
|
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
|
{t('emergency_hint')}
|
|
</Typography>
|
|
)}
|
|
</Stack>
|
|
<AppButton variant="text" color="primary" onClick={onEdit}>
|
|
{t('edit')}
|
|
</AppButton>
|
|
</Stack>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const SheetActions: FunctionComponent<{
|
|
onCancel: () => void;
|
|
onSave: () => void;
|
|
saving: boolean;
|
|
saveLabel: string;
|
|
cancelLabel: string;
|
|
}> = ({ onCancel, onSave, saving, saveLabel, cancelLabel }) => {
|
|
const tc = useTranslations('common');
|
|
return (
|
|
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
|
|
<AppButton variant="text" onClick={onCancel} disabled={saving}>
|
|
{cancelLabel}
|
|
</AppButton>
|
|
<AppButton color="primary" variant="contained" onClick={onSave} disabled={saving}>
|
|
{saving ? tc('saving') : saveLabel}
|
|
</AppButton>
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
function ProfileSkeleton() {
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 520 }}>
|
|
<Stack sx={{ alignItems: 'center', gap: 1 }}>
|
|
<Skeleton variant="circular" width={56} height={56} />
|
|
<Skeleton variant="text" width={140} />
|
|
<Skeleton variant="text" width={100} />
|
|
</Stack>
|
|
<Stack sx={{ gap: 1 }}>
|
|
{[0, 1, 2, 3, 4, 5].map((key) => (
|
|
<Skeleton key={key} variant="rounded" height={56} />
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
);
|
|
}
|