first commit

This commit is contained in:
lendry
2026-06-24 14:37:15 +03:00
commit 995adeedd4
188 changed files with 28810 additions and 0 deletions

View File

@@ -0,0 +1,291 @@
'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 { useRequireAuth } from '@/hooks/use-require-auth';
import {
getDocumentType,
indexDocumentsByType,
type DocumentTypeCode
} from '@/lib/document-catalog';
import { apiFetch, getApiErrorMessage, softDeleteProfile, UserDocument } from '@/lib/api';
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, logout } = 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 [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 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 ?? '');
setPhone(user.phone ?? '');
setBackupPhone(user.backupPhone ?? '');
}, [user]);
useEffect(() => {
if (isReady && user && !isPinLocked) void loadDocuments();
}, [isPinLocked, isReady, loadDocuments, 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 })
}, token);
const contacts: Record<string, string> = {};
if (email.trim() && email.trim() !== (user.email ?? '')) contacts.email = email.trim();
if (phone.trim() && phone.trim() !== (user.phone ?? '')) contacts.phone = phone.trim();
if (backupEmail.trim() && backupEmail.trim() !== (user.backupEmail ?? '')) contacts.backupEmail = backupEmail.trim();
if (backupPhone.trim() && backupPhone.trim() !== (user.backupPhone ?? '')) contacts.backupPhone = backupPhone.trim();
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 {
await softDeleteProfile(user.id, token);
setDeleteDialogOpen(false);
showToast('Профиль удалён');
logout();
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось удалить профиль');
if (message) showToast(message);
} finally {
setIsDeleting(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} 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)} />
<Input placeholder="Дата рождения" />
<Input placeholder="Основная почта" value={email} onChange={(event) => setEmail(event.target.value)} />
<Input placeholder="Резервная почта" value={backupEmail} onChange={(event) => setBackupEmail(event.target.value)} />
<Input placeholder="Основной телефон" value={phone} onChange={(event) => setPhone(event.target.value)} />
<Input placeholder="Резервный телефон" value={backupPhone} onChange={(event) => setBackupPhone(event.target.value)} />
</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>
<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]">
Ваш аккаунт будет помечен как удалённый. Вы сразу выйдете из системы и больше не сможете войти с текущими
данными. Почта, телефон и логин будут освобождены для новой регистрации. Административные роли будут сняты.
Запись в базе сохранится в архивном виде.
</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>
);
}