'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 ;
// 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 refetch()} />;
return (
);
}
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({
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 (
setPersonalSheetOpen(true)} />
setEmergencySheetOpen(true)}
/>
goTo(ROUTES.ADDRESSES)} />
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. */}
goTo(ROUTES.NOTIFICATIONS)} />
goTo(ROUTES.SUPPORT_TICKETS)} />
{/* Renders nothing for a single-role session — see ActorSwitcher's own doc. */}
setSignOutOpen(true)} tone="error" />
{/* اطلاعات شخصی */}
setPersonalSheetOpen(false)}
closeLabel={closeLabel}
discardTitle={discardTitle}
discardBody={discardBody}
discardConfirmLabel={discardConfirmLabel}
discardCancelLabel={cancelLabel}
>
name="firstName" label={t('first_name')} fullWidth />
name="lastName" label={t('last_name')} fullWidth />
setPersonalSheetOpen(false)} onSave={savePersonal} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
{/* زبان — the row owns the server-stored preference; the actual UI locale switch is phase 2's LocaleSwitcher, reused verbatim. */}
setLanguageSheetOpen(false)}
closeLabel={closeLabel}
discardTitle={discardTitle}
discardBody={discardBody}
discardConfirmLabel={discardConfirmLabel}
discardCancelLabel={cancelLabel}
>
{t('app_language')}
{t('app_language_hint')}
name="language" select label={t('language')} helperText={t('language_hint')}>
setLanguageSheetOpen(false)} onSave={saveLanguage} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
{/* مخاطب اضطراری */}
setEmergencySheetOpen(false)}
closeLabel={closeLabel}
discardTitle={discardTitle}
discardBody={discardBody}
discardConfirmLabel={discardConfirmLabel}
discardCancelLabel={cancelLabel}
>
{t('emergency_hint')}
name="emergencyName"
label={t('emergency_name')}
rules={{ validate: (value) => String(value ?? '').trim().length > 0 }}
fullWidth
/>
name="emergencyPhone"
rules={{ validate: (value) => isIranianMobile(String(value ?? '')) }}
>
{({ field, hasError }) => (
)}
setEmergencySheetOpen(false)} onSave={saveEmergency} saving={upsert.isPending} saveLabel={tc('save')} cancelLabel={cancelLabel} />
setSignOutOpen(false)}
onConfirm={() => {
setSignOutOpen(false);
logout.mutate();
}}
/>
);
};
const AccountRow: FunctionComponent<{
icon: string;
label: string;
onClick: () => void;
tone?: 'default' | 'error';
}> = ({ icon, label, onClick, tone = 'default' }) => (
{label}
{tone !== 'error' ? : null}
);
const EmergencyContactCard: FunctionComponent<{
complete: boolean;
name: string;
phone: string;
onEdit: () => void;
}> = ({ complete, name, phone, onEdit }) => {
const t = useTranslations('profile');
return (
{t('emergency_section')}
{complete ? (
{name}
{phone}
) : (
{t('emergency_hint')}
)}
{t('edit')}
);
};
const SheetActions: FunctionComponent<{
onCancel: () => void;
onSave: () => void;
saving: boolean;
saveLabel: string;
cancelLabel: string;
}> = ({ onCancel, onSave, saving, saveLabel, cancelLabel }) => {
const tc = useTranslations('common');
return (
{cancelLabel}
{saving ? tc('saving') : saveLabel}
);
};
function ProfileSkeleton() {
return (
{[0, 1, 2, 3, 4, 5].map((key) => (
))}
);
}