421 lines
18 KiB
TypeScript
421 lines
18 KiB
TypeScript
'use client';
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import { Car, ChevronRight, 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 { 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,
|
||
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 = (
|
||
<>
|
||
<div className={`flex h-11 w-11 items-center justify-center rounded-2xl ${destructive ? 'bg-red-50' : 'bg-[#f4f5f8]'}`}>
|
||
<Icon className={`h-5 w-5 ${destructive ? 'text-red-500' : ''}`} />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className={`font-medium ${destructive ? 'text-red-600' : ''}`}>{title}</div>
|
||
{text ? <div className="truncate text-sm text-[#667085]">{text}</div> : null}
|
||
</div>
|
||
{onClick ? <ChevronRight className="h-5 w-5 text-[#a8adbc]" /> : null}
|
||
</>
|
||
);
|
||
|
||
if (!onClick) {
|
||
return <div className="flex items-center gap-4 border-b border-[#eceef4] py-4">{content}</div>;
|
||
}
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className="flex w-full items-center gap-4 border-b border-[#eceef4] py-4 text-left transition hover:bg-[#fafbfd]"
|
||
>
|
||
{content}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export default function DataPage() {
|
||
const router = useRouter();
|
||
const { user, token, refreshProfile } = useAuth();
|
||
const { isReady, isPinLocked } = useRequireAuth();
|
||
const { showToast } = useToast();
|
||
const [displayName, setDisplayName] = useState('');
|
||
const [email, setEmail] = useState('');
|
||
const [backupEmail, setBackupEmail] = useState('');
|
||
const [phone, setPhone] = useState('');
|
||
const [backupPhone, setBackupPhone] = useState('');
|
||
const [birthDate, setBirthDate] = useState<string | undefined>();
|
||
const [phoneCountry, setPhoneCountry] = useState(phoneCountries[0]);
|
||
const [phoneDigits, setPhoneDigits] = useState('');
|
||
const [backupPhoneCountry, setBackupPhoneCountry] = useState(phoneCountries[0]);
|
||
const [backupPhoneDigits, setBackupPhoneDigits] = useState('');
|
||
const [documents, setDocuments] = useState<UserDocument[]>([]);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [activeDocumentType, setActiveDocumentType] = useState<DocumentTypeCode | null>(null);
|
||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||
const [isDeleting, setIsDeleting] = useState(false);
|
||
const [deletionStatus, setDeletionStatus] = useState<AccountDeletionStatus | null>(null);
|
||
const [cancellingDeletion, setCancellingDeletion] = useState(false);
|
||
|
||
const loadDocuments = useCallback(async () => {
|
||
if (!user || !token || isPinLocked) return;
|
||
try {
|
||
const response = await apiFetch<{ documents?: UserDocument[] }>(`/documents/users/${user.id}`, {}, token);
|
||
setDocuments(response.documents ?? []);
|
||
} catch (error) {
|
||
const message = getApiErrorMessage(error, 'Не удалось загрузить документы');
|
||
if (message) showToast(message);
|
||
}
|
||
}, [isPinLocked, showToast, token, user]);
|
||
|
||
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 (!user || !token || isPinLocked) return;
|
||
void apiFetch<UserProfileResponse>(`/profile/users/${user.id}`, {}, token)
|
||
.then((profile) => {
|
||
if (profile.birthDate) setBirthDate(profile.birthDate);
|
||
})
|
||
.catch(() => undefined);
|
||
}, [isPinLocked, token, user]);
|
||
|
||
useEffect(() => {
|
||
if (isReady && user && !isPinLocked) void loadDocuments();
|
||
}, [isPinLocked, isReady, loadDocuments, user]);
|
||
|
||
const loadDeletionStatus = useCallback(async () => {
|
||
if (!user || !token || isPinLocked) return;
|
||
try {
|
||
const status = await fetchAccountDeletionStatus(user.id, token);
|
||
setDeletionStatus(status);
|
||
} catch {
|
||
setDeletionStatus(null);
|
||
}
|
||
}, [isPinLocked, token, user]);
|
||
|
||
useEffect(() => {
|
||
if (isReady && user && !isPinLocked) void loadDeletionStatus();
|
||
}, [isPinLocked, isReady, loadDeletionStatus, user]);
|
||
|
||
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<string, string> = {};
|
||
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);
|
||
}
|
||
|
||
await refreshProfile();
|
||
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 (
|
||
<IdShell active="/data">
|
||
<div className="mb-8 flex items-center gap-4 rounded-[24px] border border-[#20212b] p-4">
|
||
{user ? (
|
||
<AvatarUpload
|
||
userId={user.id}
|
||
displayName={user.displayName}
|
||
hasAvatar={user.hasAvatar}
|
||
token={token}
|
||
isVerified={user.isVerified}
|
||
verificationIcon={user.verificationIcon}
|
||
className="h-14 w-14"
|
||
badgeSize="sm"
|
||
onUpdated={refreshProfile}
|
||
/>
|
||
) : (
|
||
<AvatarDisplay userId="" displayName="" hasAvatar={false} token={null} className="h-14 w-14" />
|
||
)}
|
||
<div className="flex-1">
|
||
<h1 className="text-lg font-semibold">{user?.displayName ?? 'Профиль'}</h1>
|
||
<p className="text-sm text-[#667085]">Логин: {user?.username ?? user?.email ?? user?.phone ?? 'не указан'}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<section>
|
||
<h2 className="text-2xl font-medium">Личные данные</h2>
|
||
<p className="mt-1 text-sm text-[#667085]">Эти данные помогают быстрее входить в сервисы и восстанавливать доступ.</p>
|
||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||
<Input placeholder="ФИО" value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
|
||
<DatePicker value={birthDate} onChange={setBirthDate} />
|
||
<Input placeholder="Основная почта" value={email} onChange={(event) => setEmail(event.target.value)} />
|
||
<Input placeholder="Резервная почта" value={backupEmail} onChange={(event) => setBackupEmail(event.target.value)} />
|
||
<PhoneInput
|
||
variant="light"
|
||
country={phoneCountry}
|
||
value={phoneDigits}
|
||
onCountryChange={setPhoneCountry}
|
||
onValueChange={(digits) => {
|
||
setPhoneDigits(digits);
|
||
setPhone(digits ? toE164(phoneCountry, digits) : '');
|
||
}}
|
||
required={false}
|
||
/>
|
||
<PhoneInput
|
||
variant="light"
|
||
country={backupPhoneCountry}
|
||
value={backupPhoneDigits}
|
||
onCountryChange={setBackupPhoneCountry}
|
||
onValueChange={(digits) => {
|
||
setBackupPhoneDigits(digits);
|
||
setBackupPhone(digits ? toE164(backupPhoneCountry, digits) : '');
|
||
}}
|
||
required={false}
|
||
/>
|
||
</div>
|
||
<Button className="mt-4" onClick={updateProfile} disabled={isSaving}>
|
||
{isSaving ? 'Сохраняем...' : 'Обновить профиль'}
|
||
</Button>
|
||
</section>
|
||
|
||
<section className="mt-10">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-2xl font-medium">Документы</h2>
|
||
<p className="mt-1 text-sm text-[#667085]">ID бережно хранит документы и подставляет их там, где вы разрешили.</p>
|
||
</div>
|
||
<Button variant="secondary" onClick={() => router.push('/documents')}>Все документы</Button>
|
||
</div>
|
||
<div className="mt-5 grid grid-cols-4 gap-3">
|
||
<ActionTile icon={FileText} label="Паспорт РФ" onClick={() => openDocument('PASSPORT_RF')} />
|
||
<ActionTile icon={FileText} label="Загран" onClick={() => openDocument('FOREIGN_PASSPORT')} />
|
||
<ActionTile icon={Car} label="ВУ" onClick={() => openDocument('DRIVER_LICENSE')} />
|
||
<ActionTile icon={FileText} label="ОМС" onClick={() => openDocument('OMS')} />
|
||
</div>
|
||
<div className="mt-4 overflow-hidden rounded-[24px] bg-[#f4f5f8]">
|
||
{documents.length === 0 ? (
|
||
<p className="px-4 py-6 text-center text-sm text-[#667085]">Документы пока не добавлены</p>
|
||
) : (
|
||
documents.map((document) => {
|
||
const config = getDocumentType(document.type);
|
||
return (
|
||
<Row
|
||
key={document.id}
|
||
icon={FileText}
|
||
title={config?.label ?? document.type}
|
||
text={document.number}
|
||
onClick={() => openDocument(document.type as DocumentTypeCode)}
|
||
/>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<AddressQuickSection layout="data" isPinLocked={isPinLocked} isReady={isReady} />
|
||
|
||
<section className="mt-10">
|
||
<h2 className="text-2xl font-medium">Контакты</h2>
|
||
<Row icon={Mail} title="Email в ID" text={email || 'Не указан'} />
|
||
<Row icon={Phone} title="Основной телефон" text={phone || 'Не указан'} />
|
||
<Row icon={Mail} title="Запасная почта" text={backupEmail || 'Не указана'} />
|
||
<Row icon={UserRound} title="Добавить внешние аккаунты" text="Помогут быстрее входить и заполнить данные" />
|
||
</section>
|
||
|
||
<section className="mt-10">
|
||
<h2 className="text-2xl font-medium">Управление данными</h2>
|
||
{deletionStatus?.pending && deletionStatus.effectiveAt ? (
|
||
<div className="mt-4 rounded-[24px] border border-amber-200 bg-amber-50/80 p-5">
|
||
<p className="font-medium text-amber-900">Удаление аккаунта запланировано</p>
|
||
<p className="mt-2 text-sm leading-relaxed text-amber-800">
|
||
Профиль будет окончательно удалён {formatDeletionDate(deletionStatus.effectiveAt)}. До этой даты вы
|
||
можете пользоваться сервисом или отменить удаление.
|
||
</p>
|
||
<Button
|
||
className="mt-4"
|
||
variant="secondary"
|
||
disabled={cancellingDeletion}
|
||
onClick={() => void handleCancelDeletion()}
|
||
>
|
||
{cancellingDeletion ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
Отменяем...
|
||
</>
|
||
) : (
|
||
'Отменить удаление'
|
||
)}
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div className="mt-2 overflow-hidden rounded-[24px] border border-red-100 bg-red-50/40">
|
||
<Row
|
||
icon={Trash2}
|
||
title="Удалить профиль"
|
||
text="Аккаунт будет удалён после периода ожидания (настраивается администратором)"
|
||
onClick={() => setDeleteDialogOpen(true)}
|
||
destructive
|
||
/>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{activeDocumentType && user ? (
|
||
<DocumentFormDialog
|
||
open={Boolean(activeDocumentType)}
|
||
onOpenChange={(open) => {
|
||
if (!open) setActiveDocumentType(null);
|
||
}}
|
||
documentType={activeDocumentType}
|
||
userId={user.id}
|
||
token={token}
|
||
existing={documentsByType.get(activeDocumentType) ?? null}
|
||
onSaved={loadDocuments}
|
||
/>
|
||
) : null}
|
||
|
||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||
<DialogContent className="rounded-[28px] sm:max-w-[440px]">
|
||
<DialogHeader>
|
||
<DialogTitle>Удалить профиль?</DialogTitle>
|
||
</DialogHeader>
|
||
<p className="text-sm leading-relaxed text-[#667085]">
|
||
Аккаунт не удалится сразу. После подтверждения начнётся период ожидания (по умолчанию 30 дней — срок
|
||
задаётся в настройках администратора). По истечении срока профиль будет окончательно удалён: контакты и
|
||
логин освободятся, семьи и чаты будут удалены или покинууты, сессии завершены. До этого момента удаление
|
||
можно отменить на этой странице.
|
||
</p>
|
||
<div className="mt-6 flex gap-3">
|
||
<Button variant="secondary" className="flex-1" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}>
|
||
Отмена
|
||
</Button>
|
||
<Button className="flex-1 bg-red-600 hover:bg-red-700" onClick={() => void confirmDeleteProfile()} disabled={isDeleting}>
|
||
{isDeleting ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
Удаляем...
|
||
</>
|
||
) : (
|
||
'Запланировать удаление'
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</IdShell>
|
||
);
|
||
}
|