'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Car, ChevronRight, FileKey2, FileText, Loader2, Mail, Phone, Trash2, UserRound } from 'lucide-react';
import { AddressQuickSection } from '@/components/addresses/address-quick-section';
import { DocumentDialog } from '@/components/documents/document-dialog';
import { DocumentPhotosInline } from '@/components/documents/document-photo-gallery';
import { ActionTile } from '@/components/id/action-tile';
import { useAuth } from '@/components/id/auth-provider';
import { IdShell } from '@/components/id/shell';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { AvatarDisplay, AvatarUpload } from '@/components/id/avatar-upload';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { PhoneInput, parseE164Phone, phoneCountries, toE164 } from '@/components/ui/phone-input';
import { useRequireAuth } from '@/hooks/use-require-auth';
import {
getDocumentType,
indexDocumentsByType,
parseAttachmentMeta,
parseDocumentPhotos,
parseMetadata,
type DocumentTypeCode
} from '@/lib/document-catalog';
import { apiFetch, getApiErrorMessage, cancelAccountDeletion, fetchAccountDeletionStatus, requestAccountDeletion, type AccountDeletionStatus, UserDocument, UserProfileResponse } from '@/lib/api';
function formatDeletionDate(value: string) {
return new Intl.DateTimeFormat('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(value));
}
function Row({
icon: Icon,
title,
text,
onClick,
destructive
}: {
icon: typeof Mail;
title: string;
text?: string;
onClick?: () => void;
destructive?: boolean;
}) {
const content = (
<>
{title}
{text ?
{text}
: null}
{onClick ? : null}
>
);
if (!onClick) {
return {content}
;
}
return (
{content}
);
}
export default function DataPage() {
const router = useRouter();
const { user, token, applyUserPatch } = useAuth();
const { isReady, isPinLocked } = useRequireAuth();
const { showToast } = useToast();
const userId = user?.id;
const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState('');
const [backupEmail, setBackupEmail] = useState('');
const [phone, setPhone] = useState('');
const [backupPhone, setBackupPhone] = useState('');
const [birthDate, setBirthDate] = useState();
const [phoneCountry, setPhoneCountry] = useState(phoneCountries[0]);
const [phoneDigits, setPhoneDigits] = useState('');
const [backupPhoneCountry, setBackupPhoneCountry] = useState(phoneCountries[0]);
const [backupPhoneDigits, setBackupPhoneDigits] = useState('');
const [documents, setDocuments] = useState([]);
const [isSaving, setIsSaving] = useState(false);
const [activeDocumentType, setActiveDocumentType] = useState(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deletionStatus, setDeletionStatus] = useState(null);
const [cancellingDeletion, setCancellingDeletion] = useState(false);
const loadDocuments = useCallback(async () => {
if (!userId || !token || isPinLocked) return;
try {
const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${userId}`, {}, token);
setDocuments(response.documents ?? []);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось загрузить документы');
if (message) showToast(message);
}
}, [isPinLocked, showToast, token, userId]);
useEffect(() => {
if (!user) return;
setDisplayName(user.displayName);
setEmail(user.email ?? '');
setBackupEmail(user.backupEmail ?? '');
const parsedPhone = parseE164Phone(user.phone ?? '');
setPhoneCountry(parsedPhone.country);
setPhoneDigits(parsedPhone.digits);
setPhone(parsedPhone.digits ? toE164(parsedPhone.country, parsedPhone.digits) : '');
const parsedBackupPhone = parseE164Phone(user.backupPhone ?? '');
setBackupPhoneCountry(parsedBackupPhone.country);
setBackupPhoneDigits(parsedBackupPhone.digits);
setBackupPhone(parsedBackupPhone.digits ? toE164(parsedBackupPhone.country, parsedBackupPhone.digits) : '');
}, [user]);
useEffect(() => {
if (!isReady || !userId || !token || isPinLocked) return;
void apiFetch(`/profile/users/${userId}`, {}, token)
.then((profile) => {
if (profile.birthDate) setBirthDate(profile.birthDate);
})
.catch(() => undefined);
}, [isPinLocked, isReady, token, userId]);
useEffect(() => {
if (isReady && userId && !isPinLocked) void loadDocuments();
}, [isPinLocked, isReady, loadDocuments, userId]);
const loadDeletionStatus = useCallback(async () => {
if (!userId || !token || isPinLocked) return;
try {
const status = await fetchAccountDeletionStatus(userId, token);
setDeletionStatus(status);
} catch {
// Фоновый сбой gateway не должен сбрасывать уже показанный статус удаления.
}
}, [isPinLocked, token, userId]);
useEffect(() => {
if (isReady && userId && !isPinLocked) void loadDeletionStatus();
}, [isPinLocked, isReady, loadDeletionStatus, userId]);
const documentsByType = useMemo(() => indexDocumentsByType(documents), [documents]);
function openDocument(type: DocumentTypeCode) {
setActiveDocumentType(type);
}
async function updateProfile() {
if (!user || !token) return;
setIsSaving(true);
const trimmedName = displayName.trim();
const [firstName, ...rest] = trimmedName.split(' ');
try {
await apiFetch(`/profile/users/${user.id}`, {
method: 'PATCH',
body: JSON.stringify({
firstName: firstName || undefined,
lastName: rest.join(' ') || undefined,
birthDate: birthDate || undefined
})
}, token);
const contacts: Record = {};
const nextPhone = phoneDigits ? toE164(phoneCountry, phoneDigits) : '';
const nextBackupPhone = backupPhoneDigits ? toE164(backupPhoneCountry, backupPhoneDigits) : '';
if (email.trim() && email.trim() !== (user.email ?? '')) contacts.email = email.trim();
if (nextPhone && nextPhone !== (user.phone ?? '')) contacts.phone = nextPhone;
if (backupEmail.trim() && backupEmail.trim() !== (user.backupEmail ?? '')) contacts.backupEmail = backupEmail.trim();
if (nextBackupPhone && nextBackupPhone !== (user.backupPhone ?? '')) contacts.backupPhone = nextBackupPhone;
if (Object.keys(contacts).length > 0) {
await apiFetch(`/profile/users/${user.id}/contacts`, {
method: 'PATCH',
body: JSON.stringify(contacts)
}, token);
}
applyUserPatch({
displayName: trimmedName || user.displayName,
email: contacts.email ?? user.email,
phone: contacts.phone ?? user.phone,
backupEmail: contacts.backupEmail ?? user.backupEmail,
backupPhone: contacts.backupPhone ?? user.backupPhone
});
showToast('Профиль обновлён');
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось обновить профиль');
if (message) showToast(message);
} finally {
setIsSaving(false);
}
}
async function confirmDeleteProfile() {
if (!user || !token) return;
setIsDeleting(true);
try {
const response = await requestAccountDeletion(user.id, token);
setDeletionStatus(response);
setDeleteDialogOpen(false);
showToast(
response.effectiveAt
? `Удаление запланировано на ${formatDeletionDate(response.effectiveAt)}`
: 'Удаление аккаунта запланировано'
);
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось запланировать удаление профиля');
if (message) showToast(message);
} finally {
setIsDeleting(false);
}
}
async function handleCancelDeletion() {
if (!user || !token) return;
setCancellingDeletion(true);
try {
await cancelAccountDeletion(user.id, token);
setDeletionStatus({ pending: false, graceDays: deletionStatus?.graceDays ?? 30 });
showToast('Удаление аккаунта отменено');
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось отменить удаление');
if (message) showToast(message);
} finally {
setCancellingDeletion(false);
}
}
return (
{user ? (
{
applyUserPatch({ hasAvatar: true });
}}
/>
) : (
)}
{user?.displayName ?? 'Профиль'}
Логин: {user?.username ?? user?.email ?? user?.phone ?? 'не указан'}
Личные данные
Эти данные помогают быстрее входить в сервисы и восстанавливать доступ.
{isSaving ? 'Сохраняем...' : 'Обновить профиль'}
Документы
ID бережно хранит документы и подставляет их там, где вы разрешили.
router.push('/documents')}>Все документы
openDocument('PASSPORT_RF')} />
openDocument('FOREIGN_PASSPORT')} />
openDocument('DRIVER_LICENSE')} />
openDocument('OMS')} />
{documents.length === 0 ? (
Документы пока не добавлены
) : (
documents.map((document) => {
const config = getDocumentType(document.type);
const photoKeys = parseDocumentPhotos(parseMetadata(document.metadataJson));
const attachmentMeta = parseAttachmentMeta(parseMetadata(document.metadataJson));
return (
openDocument(document.type as DocumentTypeCode)}
/>
{photoKeys.length > 0 && userId && token ? (
) : null}
);
})
)}
Управление данными
router.push('/data/consents')}
/>
{deletionStatus?.pending && deletionStatus.effectiveAt ? (
Удаление аккаунта запланировано
Профиль будет окончательно удалён {formatDeletionDate(deletionStatus.effectiveAt)}. До этой даты вы
можете пользоваться сервисом или отменить удаление.
void handleCancelDeletion()}
>
{cancellingDeletion ? (
<>
Отменяем...
>
) : (
'Отменить удаление'
)}
) : (
setDeleteDialogOpen(true)}
destructive
/>
)}
{activeDocumentType && user ? (
{
if (!open) setActiveDocumentType(null);
}}
documentType={activeDocumentType}
userId={user.id}
token={token}
existing={documentsByType.get(activeDocumentType) ?? null}
onSaved={loadDocuments}
/>
) : null}
Удалить профиль?
Аккаунт не удалится сразу. После подтверждения начнётся период ожидания (по умолчанию 30 дней — срок
задаётся в настройках администратора). По истечении срока профиль будет окончательно удалён: контакты и
логин освободятся, семьи и чаты будут удалены или покинууты, сессии завершены. До этого момента удаление
можно отменить на этой странице.
setDeleteDialogOpen(false)} disabled={isDeleting}>
Отмена
void confirmDeleteProfile()} disabled={isDeleting}>
{isDeleting ? (
<>
Удаляем...
>
) : (
'Запланировать удаление'
)}
);
}