Create unified settings workspace

This commit is contained in:
lizerk 2026-04-11 00:41:37 +03:00
parent 6132261d69
commit f228c10a1a
3 changed files with 974 additions and 471 deletions

View File

@ -199,7 +199,7 @@ export default function NotificationsPage() {
</button>
)}
<Link
href="/notifications/settings"
href="/settings?section=notifications"
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
>
<Settings size={16} />

View File

@ -1,473 +1,5 @@
'use client';
import { AppShell } from '@/components/AppShell';
import { useEffect, useState, useRef } from 'react';
import { Bell, Mail, Smartphone, Moon, ArrowLeft, AlertCircle } from 'lucide-react';
import { apiClient } from '@/lib/api';
import { useWebPush } from '@/hooks/useWebPush';
import { getVerbName } from '@/lib/verbTranslations';
import Link from 'next/link';
interface Preferences {
web_enabled: boolean;
email_enabled: boolean;
email_frequency: 'instant' | 'daily' | 'weekly' | 'disabled';
push_enabled: boolean;
dnd_enabled: boolean;
dnd_start_time: string | null;
dnd_end_time: string | null;
disabled_verbs: string[];
}
interface VerbType {
verb: string;
name: string;
total: number;
unread: number;
}
import { redirect } from "next/navigation";
export default function NotificationSettingsPage() {
const [preferences, setPreferences] = useState<Preferences>({
web_enabled: true,
email_enabled: false,
email_frequency: 'instant',
push_enabled: false,
dnd_enabled: false,
dnd_start_time: null,
dnd_end_time: null,
disabled_verbs: [],
});
const [verbTypes, setVerbTypes] = useState<VerbType[]>([]);
const [loading, setLoading] = useState(true);
const [preferencesLoaded, setPreferencesLoaded] = useState(false);
const [pushSynced, setPushSynced] = useState(false);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { isSupported, isSubscribed, subscribe, unsubscribe, permission, isLoading: pushLoading } = useWebPush();
useEffect(() => {
loadPreferences();
loadVerbTypes();
}, []);
// Синхронизация с реальной браузерной подпиской при первой загрузке
useEffect(() => {
console.log('[Settings] Sync check:', {
isSupported,
preferencesLoaded,
loading,
pushLoading,
pushSynced,
'preferences.push_enabled': preferences.push_enabled,
isSubscribed,
});
// Если браузер не поддерживает Push, то синхронизация не нужна
if (!isSupported) {
setPushSynced(true);
return;
}
// Ждем пока загрузятся и preferences и push состояние
// Синхронизируем только один раз
if (preferencesLoaded && !loading && !pushLoading && !pushSynced) {
if (preferences.push_enabled !== isSubscribed) {
console.log(`[Settings] ⚠️ Syncing push state: DB=${preferences.push_enabled}, Browser=${isSubscribed}`);
// Приоритет у браузерной подписки (это источник правды)
setPreferences(prev => ({ ...prev, push_enabled: isSubscribed }));
} else {
console.log('[Settings] ✅ Push state already in sync');
}
setPushSynced(true);
}
}, [preferencesLoaded, loading, pushLoading, isSupported, pushSynced, isSubscribed, preferences.push_enabled]);
// Автосохранение при изменении настроек (debounce 1 секунда)
useEffect(() => {
// Пропускаем автосохранение до первой загрузки
if (!preferencesLoaded) {
return;
}
// Очищаем предыдущий таймер
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
// Устанавливаем новый таймер для автосохранения
saveTimeoutRef.current = setTimeout(async () => {
console.log('[Settings] Auto-saving preferences...');
try {
const payload = {
...preferences,
dnd_start_time: preferences.dnd_start_time || undefined,
dnd_end_time: preferences.dnd_end_time || undefined,
};
await apiClient.updateNotificationPreferences(payload);
console.log('[Settings] ✅ Auto-saved successfully');
} catch (error) {
console.error('[Settings] ❌ Auto-save failed:', error);
}
}, 1000); // 1 секунда после последнего изменения
// Cleanup
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, [preferences, preferencesLoaded]);
const loadPreferences = async () => {
try {
setLoading(true);
const data = await apiClient.getNotificationPreferences();
console.log('[Settings] Preferences loaded from API:', data);
setPreferences(data);
setPreferencesLoaded(true);
} catch (error) {
console.error('Failed to load preferences:', error);
} finally {
setLoading(false);
}
};
const loadVerbTypes = async () => {
try {
const data = await apiClient.getVerbTypes();
setVerbTypes(data.verb_types || []);
} catch (error) {
console.error('Failed to load verb types:', error);
}
};
const toggleVerbDisabled = (verb: string) => {
setPreferences((prev) => ({
...prev,
disabled_verbs: prev.disabled_verbs.includes(verb)
? prev.disabled_verbs.filter((v) => v !== verb)
: [...prev.disabled_verbs, verb],
}));
};
const handlePushToggle = async (enabled: boolean) => {
// Обновляем состояние
setPreferences({ ...preferences, push_enabled: enabled });
// Синхронизируем браузерную подписку
if (enabled && !isSubscribed) {
await subscribe();
} else if (!enabled && isSubscribed) {
await unsubscribe();
}
};
const handleDndToggle = (enabled: boolean) => {
if (enabled) {
// При включении DND устанавливаем дефолтное время (весь день)
setPreferences({
...preferences,
dnd_enabled: true,
dnd_start_time: preferences.dnd_start_time || '00:00',
dnd_end_time: preferences.dnd_end_time || '23:59',
});
} else {
// При выключении просто отключаем, время оставляем
setPreferences({ ...preferences, dnd_enabled: false });
}
};
if (loading) {
return (
<AppShell>
<div className="mx-auto max-w-3xl px-4 py-6">
<div className="rounded-xl bg-gray-50 p-12 text-center">
<div className="mx-auto mb-3 h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-sky-500"></div>
<p className="text-sm text-gray-500">Загрузка настроек...</p>
</div>
</div>
</AppShell>
);
}
return (
<AppShell>
<div className="mx-auto max-w-3xl px-4 py-6">
{/* Заголовок */}
<div className="mb-6">
<Link
href="/notifications"
className="mb-3 inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 transition"
>
<ArrowLeft size={16} />
Вернуться к уведомлениям
</Link>
<h1 className="text-2xl font-bold text-gray-900">Настройки уведомлений</h1>
<p className="mt-1 text-sm text-gray-500">
Управляйте способами получения уведомлений · Изменения сохраняются автоматически
</p>
</div>
<div className="space-y-6">
{/* Каналы доставки */}
<section className="rounded-xl border border-gray-200 bg-white p-6">
<h2 className="mb-4 text-lg font-semibold text-gray-900">Каналы доставки</h2>
<div className="space-y-4">
{/* Web уведомления */}
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<Bell className="mt-0.5 h-5 w-5 text-sky-600" />
<div>
<h3 className="font-medium text-gray-900">Web уведомления</h3>
<p className="text-sm text-gray-500">
Уведомления в браузере в реальном времени
</p>
</div>
</div>
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
checked={preferences.web_enabled}
onChange={(e) =>
setPreferences({ ...preferences, web_enabled: e.target.checked })
}
className="peer sr-only"
/>
<div className="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-sky-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-sky-100"></div>
</label>
</div>
{/* Email уведомления */}
<div className="space-y-3 border-t border-gray-100 pt-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<Mail className="mt-0.5 h-5 w-5 text-sky-600" />
<div>
<h3 className="font-medium text-gray-900">Email уведомления</h3>
<p className="text-sm text-gray-500">
Получать уведомления на электронную почту
</p>
</div>
</div>
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
checked={preferences.email_enabled}
onChange={(e) =>
setPreferences({ ...preferences, email_enabled: e.target.checked })
}
className="peer sr-only"
/>
<div className="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-sky-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-sky-100"></div>
</label>
</div>
{preferences.email_enabled && (
<div className="ml-8">
<label className="mb-2 block text-sm font-medium text-gray-700">
Частота отправки
</label>
<select
value={preferences.email_frequency}
onChange={(e) =>
setPreferences({
...preferences,
email_frequency: e.target.value as any,
})
}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-100"
>
<option value="instant">Мгновенно</option>
<option value="daily">Ежедневно (дайджест)</option>
<option value="weekly">Еженедельно (дайджест)</option>
<option value="disabled">Отключено</option>
</select>
</div>
)}
</div>
{/* Push уведомления */}
<div className="space-y-3 border-t border-gray-100 pt-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-5 w-5 text-sky-600" />
<div>
<h3 className="font-medium text-gray-900">Push уведомления</h3>
<p className="text-sm text-gray-500">
Системные уведомления на устройстве
</p>
</div>
</div>
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
checked={preferences.push_enabled}
onChange={(e) => handlePushToggle(e.target.checked)}
disabled={!isSupported || permission === 'denied'}
className="peer sr-only"
/>
<div className="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-sky-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-sky-100 peer-disabled:opacity-50 peer-disabled:cursor-not-allowed"></div>
</label>
</div>
{!isSupported && (
<div className="ml-8 flex items-start gap-2 rounded-lg bg-amber-50 p-3">
<AlertCircle className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-amber-800">
Ваш браузер не поддерживает Push уведомления
</p>
</div>
)}
{isSupported && permission === 'denied' && (
<div className="ml-8 flex items-start gap-2 rounded-lg bg-red-50 p-3">
<AlertCircle className="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-red-800">
Вы запретили уведомления. Измените настройки браузера.
</p>
</div>
)}
{isSupported && permission === 'granted' && preferences.push_enabled && (
<div className="ml-8 flex items-start gap-2 rounded-lg bg-green-50 p-3">
<AlertCircle className="h-4 w-4 text-green-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-green-800">
Push уведомления активны {isSubscribed && '(подписка активна)'}
</p>
</div>
)}
</div>
</div>
</section>
{/* Режим "Не беспокоить" */}
<section className="rounded-xl border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center gap-2">
<Moon className="h-5 w-5 text-purple-600" />
<h2 className="text-lg font-semibold text-gray-900">Не беспокоить</h2>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-gray-900">Включить режим DND</h3>
<p className="text-sm text-gray-500">
В указанный период Email и Push будут отключены, Web уведомления — без звука
</p>
</div>
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
checked={preferences.dnd_enabled}
onChange={(e) => handleDndToggle(e.target.checked)}
className="peer sr-only"
/>
<div className="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-purple-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-100"></div>
</label>
</div>
{preferences.dnd_enabled && (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Начало периода
</label>
<input
type="time"
value={preferences.dnd_start_time || '00:00'}
onChange={(e) =>
setPreferences({ ...preferences, dnd_start_time: e.target.value })
}
required
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-100"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Конец периода
</label>
<input
type="time"
value={preferences.dnd_end_time || '23:59'}
onChange={(e) =>
setPreferences({ ...preferences, dnd_end_time: e.target.value })
}
required
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-100"
/>
</div>
</div>
<div className="flex items-start gap-2 rounded-lg bg-purple-50 p-3 border border-purple-200">
<AlertCircle className="h-4 w-4 text-purple-600 mt-0.5 flex-shrink-0" />
<div className="text-xs text-purple-800">
<p className="font-medium mb-1">Режим активен</p>
<p>
С {preferences.dnd_start_time || '00:00'} до {preferences.dnd_end_time || '23:59'} будут приходить только Web уведомления (без звука).
Email и Push отключены.
{(preferences.dnd_start_time || '00:00') > (preferences.dnd_end_time || '23:59') && (
<span className="block mt-1 italic">
Период через полночь: с {preferences.dnd_start_time} до {preferences.dnd_end_time} следующего дня.
</span>
)}
</p>
</div>
</div>
</>
)}
</div>
</section>
{/* Типы уведомлений */}
{verbTypes.length > 0 && (
<section className="rounded-xl border border-gray-200 bg-white p-6">
<h2 className="mb-4 text-lg font-semibold text-gray-900">Типы уведомлений</h2>
<p className="mb-4 text-sm text-gray-500">
Выберите, какие типы уведомлений вы хотите получать
</p>
<div className="space-y-2">
{verbTypes.map((verbType) => (
<label
key={verbType.verb}
className="flex cursor-pointer items-center justify-between rounded-lg border border-gray-200 p-3 transition hover:bg-gray-50"
>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={!preferences.disabled_verbs.includes(verbType.verb)}
onChange={() => toggleVerbDisabled(verbType.verb)}
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-2 focus:ring-sky-100"
/>
<div>
<div className="font-medium text-gray-900">{getVerbName(verbType.verb)}</div>
<div className="text-xs text-gray-500">
{verbType.total} всего • {verbType.unread} непрочитанных
</div>
</div>
</div>
</label>
))}
</div>
</section>
)}
{/* Навигация */}
<div className="flex justify-start">
<Link
href="/notifications"
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-6 py-2.5 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
>
<ArrowLeft size={16} />
Вернуться к уведомлениям
</Link>
</div>
</div>
</div>
</AppShell>
);
redirect("/settings?section=notifications");
}

View File

@ -0,0 +1,971 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useMemo, useRef, useState } from "react";
import {
ArrowRight,
Bell,
BellRing,
CalendarClock,
CheckCircle2,
ChevronRight,
CircleAlert,
Clock3,
Lock,
LogOut,
Mail,
MoonStar,
Phone,
Save,
ShieldCheck,
Smartphone,
UserRound,
Users,
} from "lucide-react";
import { toast } from "sonner";
import { AppShell, PageHeader } from "@/components/AppShell";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import { useWebPush } from "@/hooks/useWebPush";
import { getVerbName } from "@/lib/verbTranslations";
import { resolveMediaUrl } from "@/lib/url";
type SectionId = "account" | "contacts" | "notifications" | "access";
type NotificationPreferences = {
web_enabled: boolean;
email_enabled: boolean;
email_frequency: "instant" | "daily" | "weekly" | "never";
push_enabled: boolean;
dnd_enabled: boolean;
dnd_start_time: string | null;
dnd_end_time: string | null;
disabled_verbs: string[];
};
type VerbType = {
verb: string;
name: string;
total: number;
unread: number;
};
const sectionMeta: Array<{ id: SectionId; label: string; description: string }> = [
{ id: "account", label: "Профиль", description: "Имя, фото и основная информация" },
{ id: "contacts", label: "Контакты", description: "Почта, телефон и мессенджеры" },
{ id: "notifications", label: "Уведомления", description: "Каналы, DND и типы событий" },
{ id: "access", label: "Доступ", description: "Сессия и быстрые переходы" },
];
const defaultPreferences: NotificationPreferences = {
web_enabled: true,
email_enabled: false,
email_frequency: "instant",
push_enabled: false,
dnd_enabled: false,
dnd_start_time: null,
dnd_end_time: null,
disabled_verbs: [],
};
function preferencesSignature(value: NotificationPreferences | null) {
if (!value) return "";
return JSON.stringify({
...value,
disabled_verbs: [...value.disabled_verbs].sort(),
dnd_start_time: value.dnd_start_time || null,
dnd_end_time: value.dnd_end_time || null,
});
}
function formatDateTime(value?: string) {
if (!value) return "Не указано";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function initials(firstName?: string, lastName?: string) {
return `${lastName?.[0] || ""}${firstName?.[0] || ""}`.trim() || "П";
}
function SectionCard({
id,
title,
description,
children,
action,
}: {
id: string;
title: string;
description: string;
children: React.ReactNode;
action?: React.ReactNode;
}) {
return (
<section id={id} className="scroll-mt-24 rounded-3xl bg-white p-6 shadow-sm ring-1 ring-slate-100">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">{title}</h2>
<p className="mt-1 max-w-2xl text-sm text-slate-500">{description}</p>
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
{children}
</section>
);
}
function Toggle({
checked,
onChange,
disabled,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}) {
return (
<label className="relative inline-flex cursor-pointer items-center">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="peer sr-only"
/>
<div className="peer h-6 w-11 rounded-full bg-slate-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:shadow-sm after:transition-all after:content-[''] peer-checked:bg-sky-600 peer-checked:after:translate-x-full peer-disabled:cursor-not-allowed peer-disabled:opacity-50" />
</label>
);
}
export default function SettingsPage() {
const { user, loading, refreshUser, logout } = useUser();
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const pushSyncedRef = useRef(false);
const [profileForm, setProfileForm] = useState({
first_name: "",
last_name: "",
patronymic: "",
birth_date: "",
});
const [contactsForm, setContactsForm] = useState({
email: "",
phone_number: "",
telegram: "",
whatsapp: "",
wechat: "",
});
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [savingProfile, setSavingProfile] = useState(false);
const [savingContacts, setSavingContacts] = useState(false);
const [preferences, setPreferences] = useState<NotificationPreferences>(defaultPreferences);
const [savedPreferences, setSavedPreferences] = useState<NotificationPreferences | null>(null);
const [verbTypes, setVerbTypes] = useState<VerbType[]>([]);
const [preferencesLoading, setPreferencesLoading] = useState(true);
const [savingPreferences, setSavingPreferences] = useState(false);
const {
isSupported,
isSubscribed,
permission,
isLoading: pushLoading,
subscribe,
unsubscribe,
} = useWebPush();
useEffect(() => {
if (!user) return;
setProfileForm({
first_name: user.first_name || "",
last_name: user.last_name || "",
patronymic: user.patronymic || "",
birth_date: user.birth_date || "",
});
setContactsForm({
email: user.email || "",
phone_number: user.phone_number || "",
telegram: user.telegram || "",
whatsapp: user.whatsapp || "",
wechat: user.wechat || "",
});
}, [user]);
useEffect(() => {
let mounted = true;
async function loadNotificationSettings() {
try {
setPreferencesLoading(true);
const [prefsResponse, verbsResponse] = await Promise.all([
apiClient.getNotificationPreferences(),
apiClient.getVerbTypes(),
]);
if (!mounted) return;
const normalized: NotificationPreferences = {
...defaultPreferences,
...prefsResponse,
email_frequency:
prefsResponse.email_frequency === "disabled"
? "never"
: prefsResponse.email_frequency,
};
setPreferences(normalized);
setSavedPreferences(normalized);
setVerbTypes(verbsResponse.verb_types || []);
} catch (error) {
console.error("Failed to load settings", error);
toast.error("Не удалось загрузить настройки уведомлений");
} finally {
if (mounted) {
setPreferencesLoading(false);
}
}
}
loadNotificationSettings();
return () => {
mounted = false;
};
}, []);
useEffect(() => {
if (!savedPreferences || pushLoading || pushSyncedRef.current) return;
if (!isSupported) {
pushSyncedRef.current = true;
return;
}
pushSyncedRef.current = true;
setPreferences((prev) => ({ ...prev, push_enabled: isSubscribed }));
setSavedPreferences((prev) => (prev ? { ...prev, push_enabled: isSubscribed } : prev));
}, [isSubscribed, isSupported, pushLoading, savedPreferences]);
useEffect(() => {
const section = new URLSearchParams(window.location.search).get("section") as SectionId | null;
if (!section) return;
const element = document.getElementById(section);
if (element) {
requestAnimationFrame(() => {
element.scrollIntoView({ behavior: "smooth", block: "start" });
});
}
}, []);
const fullName = useMemo(() => {
if (!user) return "Пользователь";
return `${user.last_name || ""} ${user.first_name || ""} ${user.patronymic || ""}`.trim() || "Пользователь";
}, [user]);
const profileDirty = useMemo(() => {
if (!user) return false;
return (
profileForm.first_name !== (user.first_name || "") ||
profileForm.last_name !== (user.last_name || "") ||
profileForm.patronymic !== (user.patronymic || "") ||
profileForm.birth_date !== (user.birth_date || "") ||
Boolean(avatarFile)
);
}, [avatarFile, profileForm, user]);
const contactsDirty = useMemo(() => {
if (!user) return false;
return (
contactsForm.email !== (user.email || "") ||
contactsForm.phone_number !== (user.phone_number || "") ||
contactsForm.telegram !== (user.telegram || "") ||
contactsForm.whatsapp !== (user.whatsapp || "") ||
contactsForm.wechat !== (user.wechat || "")
);
}, [contactsForm, user]);
const notificationDirty = useMemo(() => {
return preferencesSignature(preferences) !== preferencesSignature(savedPreferences);
}, [preferences, savedPreferences]);
const activeVerbCount = useMemo(
() => verbTypes.filter((item) => !preferences.disabled_verbs.includes(item.verb)).length,
[preferences.disabled_verbs, verbTypes],
);
const unreadVerbCount = useMemo(
() => verbTypes.reduce((sum, item) => sum + item.unread, 0),
[verbTypes],
);
const handleAvatarChange = (file: File | null) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("Можно загрузить только изображение");
return;
}
if (file.size > 5 * 1024 * 1024) {
toast.error("Максимальный размер файла 5 МБ");
return;
}
setAvatarFile(file);
const reader = new FileReader();
reader.onload = () => {
setAvatarPreview(typeof reader.result === "string" ? reader.result : null);
};
reader.readAsDataURL(file);
};
const saveProfile = async () => {
if (!profileDirty) return;
try {
setSavingProfile(true);
await apiClient.updateCurrentUserProfile({
first_name: profileForm.first_name.trim(),
last_name: profileForm.last_name.trim(),
patronymic: profileForm.patronymic.trim(),
birth_date: profileForm.birth_date.trim() || null,
avatar: avatarFile || undefined,
});
await refreshUser();
setAvatarFile(null);
setAvatarPreview(null);
toast.success("Профиль обновлен");
} catch (error) {
console.error(error);
toast.error("Не удалось сохранить профиль");
} finally {
setSavingProfile(false);
}
};
const saveContacts = async () => {
if (!contactsDirty) return;
try {
setSavingContacts(true);
await apiClient.updateCurrentUserProfile({
email: contactsForm.email.trim(),
phone_number: contactsForm.phone_number.trim(),
telegram: contactsForm.telegram.trim(),
whatsapp: contactsForm.whatsapp.trim(),
wechat: contactsForm.wechat.trim(),
});
await refreshUser();
toast.success("Контакты обновлены");
} catch (error) {
console.error(error);
toast.error("Не удалось сохранить контакты");
} finally {
setSavingContacts(false);
}
};
const savePreferences = async () => {
if (!notificationDirty) return;
try {
setSavingPreferences(true);
if (preferences.push_enabled !== isSubscribed) {
if (preferences.push_enabled) {
const success = await subscribe();
if (!success) {
throw new Error("Push subscription failed");
}
} else {
const success = await unsubscribe();
if (!success && isSubscribed) {
throw new Error("Push unsubscribe failed");
}
}
}
const payload = {
...preferences,
email_frequency: preferences.email_frequency,
dnd_start_time: preferences.dnd_start_time || undefined,
dnd_end_time: preferences.dnd_end_time || undefined,
};
await apiClient.updateNotificationPreferences(payload);
setSavedPreferences(preferences);
toast.success("Настройки уведомлений сохранены");
} catch (error) {
console.error(error);
toast.error("Не удалось сохранить настройки уведомлений");
} finally {
setSavingPreferences(false);
}
};
if (loading || !user) {
return (
<AppShell>
<div className="flex items-center justify-center py-16">
<div className="text-center">
<div className="mx-auto mb-4 h-10 w-10 animate-spin rounded-full border-4 border-sky-300 border-t-transparent" />
<p className="text-sm text-slate-500">Загрузка настроек...</p>
</div>
</div>
</AppShell>
);
}
const summaryAvatar = avatarPreview || (user.avatar ? resolveMediaUrl(user.avatar) : null);
return (
<AppShell>
<div className="space-y-6">
<PageHeader
title="Настройки"
eyebrow="Личный кабинет"
subtitle="Единая точка для профиля, контактов и правил доставки уведомлений без прыжков по разным экранам."
badge={`${sectionMeta.length} раздела`}
/>
<div className="grid gap-6 xl:grid-cols-[320px,minmax(0,1fr)]">
<aside className="space-y-4 xl:sticky xl:top-24 xl:self-start">
<div className="overflow-hidden rounded-3xl bg-[radial-gradient(circle_at_top_left,_rgba(14,165,233,0.18),_transparent_42%),linear-gradient(180deg,_#ffffff_0%,_#f8fbff_100%)] p-5 shadow-sm ring-1 ring-sky-100">
<div className="flex items-start gap-4">
<div className="relative h-16 w-16 overflow-hidden rounded-2xl bg-sky-500 text-white shadow-sm">
{summaryAvatar ? (
<Image src={summaryAvatar} alt={fullName} fill className="object-cover" unoptimized />
) : (
<div className="flex h-full w-full items-center justify-center text-lg font-semibold">
{initials(user.first_name, user.last_name)}
</div>
)}
</div>
<div className="min-w-0">
<p className="truncate text-lg font-semibold text-slate-900">{fullName}</p>
<p className="truncate text-sm text-slate-500">{user.email || "Почта не указана"}</p>
<div className="mt-2 flex flex-wrap gap-2">
<span className="rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700 ring-1 ring-emerald-100">
{user.is_active ? "Активный сотрудник" : "Неактивный профиль"}
</span>
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
{user.departments?.length || 0} отделов
</span>
</div>
</div>
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<div className="rounded-2xl bg-white/80 p-3 ring-1 ring-slate-100">
<p className="text-xs uppercase tracking-[0.18em] text-slate-400">Навыки</p>
<p className="mt-2 text-2xl font-semibold text-slate-900">{user.skills?.length || 0}</p>
</div>
<div className="rounded-2xl bg-white/80 p-3 ring-1 ring-slate-100">
<p className="text-xs uppercase tracking-[0.18em] text-slate-400">Непрочитано</p>
<p className="mt-2 text-2xl font-semibold text-slate-900">{unreadVerbCount}</p>
</div>
</div>
</div>
<div className="rounded-3xl bg-white p-3 shadow-sm ring-1 ring-slate-100">
<p className="px-2 pb-3 pt-1 text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">
Разделы
</p>
<div className="space-y-1">
{sectionMeta.map((section) => (
<button
key={section.id}
type="button"
onClick={() => document.getElementById(section.id)?.scrollIntoView({ behavior: "smooth", block: "start" })}
className="flex w-full items-center justify-between rounded-2xl px-3 py-3 text-left transition hover:bg-sky-50"
>
<div>
<p className="text-sm font-medium text-slate-800">{section.label}</p>
<p className="mt-0.5 text-xs text-slate-500">{section.description}</p>
</div>
<ChevronRight size={16} className="text-slate-300" />
</button>
))}
</div>
</div>
</aside>
<div className="space-y-6">
<SectionCard
id="account"
title="Профиль"
description="Основные данные сотрудника. Изменения сохраняются отдельно, чтобы не смешивать контактные поля и карточку профиля."
action={
<button
type="button"
onClick={saveProfile}
disabled={!profileDirty || savingProfile}
className="inline-flex items-center gap-2 rounded-full bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Save size={16} />
{savingProfile ? "Сохраняем..." : "Сохранить профиль"}
</button>
}
>
<div className="grid gap-6 lg:grid-cols-[220px,minmax(0,1fr)]">
<div className="space-y-3">
<div className="relative mx-auto h-40 w-40 overflow-hidden rounded-[2rem] bg-slate-100 shadow-sm ring-1 ring-slate-100">
{summaryAvatar ? (
<Image src={summaryAvatar} alt={fullName} fill className="object-cover" unoptimized />
) : (
<div className="flex h-full w-full items-center justify-center bg-sky-500 text-4xl font-semibold text-white">
{initials(user.first_name, user.last_name)}
</div>
)}
</div>
<input
ref={avatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleAvatarChange(e.target.files?.[0] || null)}
/>
<button
type="button"
onClick={() => avatarInputRef.current?.click()}
className="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Загрузить новое фото
</button>
<p className="text-xs text-slate-500">PNG, JPG или WEBP до 5 МБ.</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700">Имя</span>
<input
value={profileForm.first_name}
onChange={(e) => setProfileForm((prev) => ({ ...prev, first_name: e.target.value }))}
className="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
/>
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700">Фамилия</span>
<input
value={profileForm.last_name}
onChange={(e) => setProfileForm((prev) => ({ ...prev, last_name: e.target.value }))}
className="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
/>
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700">Отчество</span>
<input
value={profileForm.patronymic}
onChange={(e) => setProfileForm((prev) => ({ ...prev, patronymic: e.target.value }))}
className="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
/>
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700">Дата рождения</span>
<input
type="date"
value={profileForm.birth_date}
onChange={(e) => setProfileForm((prev) => ({ ...prev, birth_date: e.target.value }))}
className="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
/>
</label>
<div className="rounded-2xl bg-slate-50 p-4 ring-1 ring-slate-100 sm:col-span-2">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-slate-400">Последний вход</p>
<p className="mt-2 text-sm text-slate-700">{formatDateTime(user.last_login)}</p>
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-slate-400">Профиль создан</p>
<p className="mt-2 text-sm text-slate-700">{formatDateTime(user.date_joined || user.created_at)}</p>
</div>
</div>
</div>
</div>
</div>
</SectionCard>
<SectionCard
id="contacts"
title="Контакты"
description="Рабочие каналы связи пользователя. Эти поля используются в карточках сотрудников и при некоторых типах уведомлений."
action={
<button
type="button"
onClick={saveContacts}
disabled={!contactsDirty || savingContacts}
className="inline-flex items-center gap-2 rounded-full bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
>
<Save size={16} />
{savingContacts ? "Сохраняем..." : "Сохранить контакты"}
</button>
}
>
<div className="grid gap-4 sm:grid-cols-2">
<label className="space-y-2">
<span className="inline-flex items-center gap-2 text-sm font-medium text-slate-700">
<Mail size={15} className="text-slate-400" />
Email
</span>
<input
type="email"
value={contactsForm.email}
onChange={(e) => setContactsForm((prev) => ({ ...prev, email: e.target.value }))}
className="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
/>
</label>
<label className="space-y-2">
<span className="inline-flex items-center gap-2 text-sm font-medium text-slate-700">
<Phone size={15} className="text-slate-400" />
Телефон
</span>
<input
value={contactsForm.phone_number}
onChange={(e) => setContactsForm((prev) => ({ ...prev, phone_number: e.target.value }))}
className="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
/>
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700">Telegram</span>
<input
value={contactsForm.telegram}
onChange={(e) => setContactsForm((prev) => ({ ...prev, telegram: e.target.value }))}
className="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
/>
</label>
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700">WhatsApp</span>
<input
value={contactsForm.whatsapp}
onChange={(e) => setContactsForm((prev) => ({ ...prev, whatsapp: e.target.value }))}
className="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
/>
</label>
<label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700">WeChat</span>
<input
value={contactsForm.wechat}
onChange={(e) => setContactsForm((prev) => ({ ...prev, wechat: e.target.value }))}
className="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
/>
</label>
</div>
<div className="mt-5 rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="flex flex-wrap items-center gap-3">
<span className="inline-flex items-center gap-2 rounded-full bg-white px-3 py-1.5 text-sm text-slate-700 ring-1 ring-slate-200">
<ShieldCheck size={15} className={user.email_verified ? "text-emerald-500" : "text-amber-500"} />
{user.email_verified ? "Почта подтверждена" : "Почта не подтверждена"}
</span>
<span className="inline-flex items-center gap-2 rounded-full bg-white px-3 py-1.5 text-sm text-slate-700 ring-1 ring-slate-200">
<Users size={15} className="text-sky-500" />
{user.departments?.map((item) => item.name).join(", ") || "Без отдела"}
</span>
</div>
</div>
</SectionCard>
<SectionCard
id="notifications"
title="Уведомления"
description="Настройка каналов доставки, режима тишины и типов событий. Здесь нет автосохранения, чтобы каждое переключение не уходило отдельным запросом."
action={
<button
type="button"
onClick={savePreferences}
disabled={preferencesLoading || !notificationDirty || savingPreferences}
className="inline-flex items-center gap-2 rounded-full bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Save size={16} />
{savingPreferences ? "Сохраняем..." : "Сохранить уведомления"}
</button>
}
>
{preferencesLoading ? (
<div className="rounded-2xl bg-slate-50 p-8 text-center text-sm text-slate-500">
Загрузка настроек уведомлений...
</div>
) : (
<div className="space-y-6">
<div className="grid gap-4 lg:grid-cols-3">
<div className="rounded-2xl border border-slate-200 p-4">
<p className="text-xs uppercase tracking-[0.18em] text-slate-400">Активных типов</p>
<p className="mt-2 text-2xl font-semibold text-slate-900">{activeVerbCount}</p>
</div>
<div className="rounded-2xl border border-slate-200 p-4">
<p className="text-xs uppercase tracking-[0.18em] text-slate-400">Email режим</p>
<p className="mt-2 text-2xl font-semibold capitalize text-slate-900">{preferences.email_frequency}</p>
</div>
<div className="rounded-2xl border border-slate-200 p-4">
<p className="text-xs uppercase tracking-[0.18em] text-slate-400">Push</p>
<p className="mt-2 text-2xl font-semibold text-slate-900">
{preferences.push_enabled ? "Вкл." : "Выкл."}
</p>
</div>
</div>
<div className="grid gap-4">
<div className="rounded-2xl border border-slate-200 p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<BellRing className="mt-0.5 h-5 w-5 text-sky-600" />
<div>
<p className="font-medium text-slate-900">Web-уведомления</p>
<p className="text-sm text-slate-500">Моментальная доставка внутри приложения.</p>
</div>
</div>
<Toggle
checked={preferences.web_enabled}
onChange={(checked) => setPreferences((prev) => ({ ...prev, web_enabled: checked }))}
/>
</div>
</div>
<div className="rounded-2xl border border-slate-200 p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Mail className="mt-0.5 h-5 w-5 text-sky-600" />
<div>
<p className="font-medium text-slate-900">Email-уведомления</p>
<p className="text-sm text-slate-500">Полезно для дайджестов и событий вне рабочего окна.</p>
</div>
</div>
<Toggle
checked={preferences.email_enabled}
onChange={(checked) => setPreferences((prev) => ({ ...prev, email_enabled: checked }))}
/>
</div>
<div className="mt-4 grid gap-2 sm:max-w-xs">
<label className="text-sm font-medium text-slate-700">Частота писем</label>
<select
value={preferences.email_frequency}
onChange={(e) =>
setPreferences((prev) => ({
...prev,
email_frequency: e.target.value as NotificationPreferences["email_frequency"],
}))
}
className="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
>
<option value="instant">Мгновенно</option>
<option value="daily">Ежедневный дайджест</option>
<option value="weekly">Еженедельный дайджест</option>
<option value="never">Не отправлять</option>
</select>
</div>
</div>
<div className="rounded-2xl border border-slate-200 p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 h-5 w-5 text-sky-600" />
<div>
<p className="font-medium text-slate-900">Push-уведомления</p>
<p className="text-sm text-slate-500">Системные уведомления браузера для текущего устройства.</p>
</div>
</div>
<Toggle
checked={preferences.push_enabled}
onChange={(checked) => setPreferences((prev) => ({ ...prev, push_enabled: checked }))}
disabled={!isSupported || permission === "denied" || pushLoading}
/>
</div>
<div className="mt-4 rounded-2xl bg-slate-50 p-4 ring-1 ring-slate-100">
<div className="flex items-start gap-3">
{!isSupported || permission === "denied" ? (
<CircleAlert className="mt-0.5 h-5 w-5 text-amber-500" />
) : (
<CheckCircle2 className="mt-0.5 h-5 w-5 text-emerald-500" />
)}
<div className="text-sm text-slate-600">
<p className="font-medium text-slate-800">
{!isSupported
? "Этот браузер не поддерживает Push"
: permission === "denied"
? "Разрешение на уведомления отклонено"
: isSubscribed
? "Текущее устройство уже подписано"
: "Подписка будет создана при сохранении"}
</p>
<p className="mt-1">
{permission === "denied"
? "Нужно разрешить уведомления в настройках браузера."
: "Состояние браузера и серверной настройки будет синхронизировано одной операцией."}
</p>
</div>
</div>
</div>
</div>
</div>
<div className="rounded-[1.75rem] border border-violet-200 bg-violet-50/60 p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<MoonStar className="mt-0.5 h-5 w-5 text-violet-600" />
<div>
<p className="font-medium text-slate-900">Режим &quot;Не беспокоить&quot;</p>
<p className="text-sm text-slate-500">
В период тишины email и push отключаются, web остаются беззвучными.
</p>
</div>
</div>
<Toggle
checked={preferences.dnd_enabled}
onChange={(checked) =>
setPreferences((prev) => ({
...prev,
dnd_enabled: checked,
dnd_start_time: checked ? prev.dnd_start_time || "00:00" : prev.dnd_start_time,
dnd_end_time: checked ? prev.dnd_end_time || "23:59" : prev.dnd_end_time,
}))
}
/>
</div>
{preferences.dnd_enabled ? (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<label className="space-y-2">
<span className="inline-flex items-center gap-2 text-sm font-medium text-slate-700">
<Clock3 size={15} className="text-violet-500" />
Начало
</span>
<input
type="time"
value={preferences.dnd_start_time || "00:00"}
onChange={(e) => setPreferences((prev) => ({ ...prev, dnd_start_time: e.target.value }))}
className="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
/>
</label>
<label className="space-y-2">
<span className="inline-flex items-center gap-2 text-sm font-medium text-slate-700">
<CalendarClock size={15} className="text-violet-500" />
Конец
</span>
<input
type="time"
value={preferences.dnd_end_time || "23:59"}
onChange={(e) => setPreferences((prev) => ({ ...prev, dnd_end_time: e.target.value }))}
className="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
/>
</label>
</div>
) : null}
</div>
<div className="rounded-2xl border border-slate-200 p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="font-medium text-slate-900">Типы уведомлений</p>
<p className="text-sm text-slate-500">Отключайте только шумные категории, а не весь канал целиком.</p>
</div>
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600">
{verbTypes.length} типов
</span>
</div>
<div className="grid gap-3">
{verbTypes.map((item) => {
const enabled = !preferences.disabled_verbs.includes(item.verb);
return (
<label
key={item.verb}
className="flex cursor-pointer items-start justify-between rounded-2xl border border-slate-200 px-4 py-3 transition hover:bg-slate-50"
>
<div className="flex items-start gap-3">
<input
type="checkbox"
checked={enabled}
onChange={() =>
setPreferences((prev) => ({
...prev,
disabled_verbs: enabled
? prev.disabled_verbs.concat(item.verb)
: prev.disabled_verbs.filter((verb) => verb !== item.verb),
}))
}
className="mt-1 h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-200"
/>
<div>
<p className="font-medium text-slate-800">{getVerbName(item.verb)}</p>
<p className="mt-1 text-xs text-slate-500">
{item.total} всего, {item.unread} непрочитанных
</p>
</div>
</div>
<span className={`rounded-full px-2.5 py-1 text-xs font-medium ${enabled ? "bg-emerald-50 text-emerald-700" : "bg-slate-100 text-slate-500"}`}>
{enabled ? "Вкл." : "Выкл."}
</span>
</label>
);
})}
</div>
</div>
</div>
)}
</SectionCard>
<SectionCard
id="access"
title="Доступ и навигация"
description="Быстрые переходы и действия по учетной записи. Отсюда можно уйти в детальные разделы, если нужен рабочий контекст, а не только настройки."
>
<div className="grid gap-4 lg:grid-cols-2">
<Link
href="/profile"
className="group rounded-2xl border border-slate-200 p-5 transition hover:border-sky-200 hover:bg-sky-50"
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="inline-flex items-center gap-2 text-sm font-medium text-slate-900">
<UserRound size={16} className="text-sky-600" />
Полная карточка профиля
</p>
<p className="mt-2 text-sm text-slate-500">Посмотреть отделы, навыки и историю действий пользователя.</p>
</div>
<ArrowRight size={18} className="text-slate-300 transition group-hover:text-sky-600" />
</div>
</Link>
<Link
href="/notifications"
className="group rounded-2xl border border-slate-200 p-5 transition hover:border-sky-200 hover:bg-sky-50"
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="inline-flex items-center gap-2 text-sm font-medium text-slate-900">
<Bell size={16} className="text-sky-600" />
Центр уведомлений
</p>
<p className="mt-2 text-sm text-slate-500">Открыть историю уведомлений и быстро прочитать новые события.</p>
</div>
<ArrowRight size={18} className="text-slate-300 transition group-hover:text-sky-600" />
</div>
</Link>
</div>
<div className="mt-4 rounded-2xl border border-slate-200 bg-slate-50 p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="inline-flex items-center gap-2 text-sm font-medium text-slate-900">
<Lock size={16} className="text-slate-500" />
Текущая сессия
</p>
<p className="mt-2 text-sm text-slate-500">
Если устройство общее или сессия больше не нужна, выйдите из аккаунта вручную.
</p>
</div>
<button
type="button"
onClick={logout}
className="inline-flex items-center justify-center gap-2 rounded-full border border-red-200 bg-white px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-red-50"
>
<LogOut size={16} />
Выйти из аккаунта
</button>
</div>
</div>
</SectionCard>
</div>
</div>
</div>
</AppShell>
);
}