'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 { DocumentFormDialog } from '@/components/documents/document-form-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, 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 ( ); } 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 ?? 'не указан'}

Личные данные

Эти данные помогают быстрее входить в сервисы и восстанавливать доступ.

setDisplayName(event.target.value)} /> setEmail(event.target.value)} /> setBackupEmail(event.target.value)} /> { setPhoneDigits(digits); setPhone(digits ? toE164(phoneCountry, digits) : ''); }} required={false} /> { setBackupPhoneDigits(digits); setBackupPhone(digits ? toE164(backupPhoneCountry, digits) : ''); }} required={false} />

Документы

ID бережно хранит документы и подставляет их там, где вы разрешили.

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)); return (
openDocument(document.type as DocumentTypeCode)} /> {photoKeys.length > 0 && userId && token ? (
) : null}
); }) )}

Контакты

Управление данными

router.push('/data/consents')} />
{deletionStatus?.pending && deletionStatus.effectiveAt ? (

Удаление аккаунта запланировано

Профиль будет окончательно удалён {formatDeletionDate(deletionStatus.effectiveAt)}. До этой даты вы можете пользоваться сервисом или отменить удаление.

) : (
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 дней — срок задаётся в настройках администратора). По истечении срока профиль будет окончательно удалён: контакты и логин освободятся, семьи и чаты будут удалены или покинууты, сессии завершены. До этого момента удаление можно отменить на этой странице.

); }