diff --git a/frontend/src/app/notifications/page.tsx b/frontend/src/app/notifications/page.tsx index b3409b1a..86679dcc 100644 --- a/frontend/src/app/notifications/page.tsx +++ b/frontend/src/app/notifications/page.tsx @@ -199,7 +199,7 @@ export default function NotificationsPage() { )} diff --git a/frontend/src/app/notifications/settings/page.tsx b/frontend/src/app/notifications/settings/page.tsx index 02de6235..ad64e64b 100644 --- a/frontend/src/app/notifications/settings/page.tsx +++ b/frontend/src/app/notifications/settings/page.tsx @@ -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({ - 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([]); - const [loading, setLoading] = useState(true); - const [preferencesLoaded, setPreferencesLoaded] = useState(false); - const [pushSynced, setPushSynced] = useState(false); - const saveTimeoutRef = useRef(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 ( - -
-
-
-

Загрузка настроек...

-
-
-
- ); - } - - return ( - -
- {/* Заголовок */} -
- - - Вернуться к уведомлениям - -

Настройки уведомлений

-

- Управляйте способами получения уведомлений · Изменения сохраняются автоматически -

-
- -
- {/* Каналы доставки */} -
-

Каналы доставки

- -
- {/* Web уведомления */} -
-
- -
-

Web уведомления

-

- Уведомления в браузере в реальном времени -

-
-
- -
- - {/* Email уведомления */} -
-
-
- -
-

Email уведомления

-

- Получать уведомления на электронную почту -

-
-
- -
- - {preferences.email_enabled && ( -
- - -
- )} -
- - {/* Push уведомления */} -
-
-
- -
-

Push уведомления

-

- Системные уведомления на устройстве -

-
-
- -
- - {!isSupported && ( -
- -

- Ваш браузер не поддерживает Push уведомления -

-
- )} - - {isSupported && permission === 'denied' && ( -
- -

- Вы запретили уведомления. Измените настройки браузера. -

-
- )} - - {isSupported && permission === 'granted' && preferences.push_enabled && ( -
- -

- Push уведомления активны {isSubscribed && '(подписка активна)'} -

-
- )} -
-
-
- - {/* Режим "Не беспокоить" */} -
-
- -

Не беспокоить

-
- -
-
-
-

Включить режим DND

-

- В указанный период Email и Push будут отключены, Web уведомления — без звука -

-
- -
- - {preferences.dnd_enabled && ( - <> -
-
- - - 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" - /> -
- -
- - - 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" - /> -
-
- -
- -
-

Режим активен

-

- С {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') && ( - - Период через полночь: с {preferences.dnd_start_time} до {preferences.dnd_end_time} следующего дня. - - )} -

-
-
- - )} -
-
- - {/* Типы уведомлений */} - {verbTypes.length > 0 && ( -
-

Типы уведомлений

-

- Выберите, какие типы уведомлений вы хотите получать -

- -
- {verbTypes.map((verbType) => ( - - ))} -
-
- )} - - {/* Навигация */} -
- - - Вернуться к уведомлениям - -
-
-
-
- ); + redirect("/settings?section=notifications"); } diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx new file mode 100644 index 00000000..bfc27090 --- /dev/null +++ b/frontend/src/app/settings/page.tsx @@ -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 ( +
+
+
+

{title}

+

{description}

+
+ {action ?
{action}
: null} +
+ {children} +
+ ); +} + +function Toggle({ + checked, + onChange, + disabled, +}: { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; +}) { + return ( +