'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')}> {t('language_fa')} {t('language_en')} 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) => ( ))} ); }