Files
mdm-ordinis/ordinis-admin-ui/src/routes/me.notifications.preferences.tsx
T
2026-05-15 14:56:48 +00:00

181 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useAuth } from 'react-oidc-context'
import {
Button,
EmptyState,
LoadingBlock,
PageHeader,
Panel,
QueryErrorState,
Switch,
} from '@/ui'
import { useMyNotificationPreferences } from '@/api/queries'
import { useUpdateNotificationPreferences } from '@/api/mutations'
import type { NotificationPreferences } from '@/api/client'
export const Route = createFileRoute('/me/notifications/preferences')({
component: NotificationPreferencesPage,
})
/**
* Phase B — UI для per-channel notification opt-in/out.
*
* <p>Email — единственный actually wired channel в Phase A/B (через Spring
* \`spring.mail.host\`). Express + Telegram toggles disabled с подсказкой
* "Скоро" — backend хранит preference (на случай early opt-in), но dispatcher
* никогда не пошлёт пока channel bean не зарегистрирован.
*
* <p>Optimistic update — toggle flips мгновенно, network roundtrip ~200ms
* почти не виден. onError refetch восстанавливает state если PUT 4xx/5xx.
*/
function NotificationPreferencesPage() {
const { t } = useTranslation()
const auth = useAuth()
const prefsQ = useMyNotificationPreferences()
const updateMut = useUpdateNotificationPreferences()
if (!auth.isAuthenticated) {
return (
<Panel>
<PageHeader
title={t('prefs.title', { defaultValue: 'Уведомления' })}
description={t('prefs.description', {
defaultValue:
'Per-channel настройки доставки. Применяется только к вашим уведомлениям.',
})}
/>
<EmptyState
title={t('prefs.anonTitle', {
defaultValue: 'Войдите чтобы управлять подписками',
})}
description={t('prefs.anonDescription', {
defaultValue:
'Настройки уведомлений хранятся per-user (по JWT sub claim). Войдите через Keycloak чтобы открыть страницу.',
})}
action={
<Button onClick={() => auth.signinRedirect()}>
{t('auth.login', { defaultValue: 'Войти' })}
</Button>
}
/>
</Panel>
)
}
if (prefsQ.isLoading) {
return <LoadingBlock size="md" label={t('loading')} />
}
if (prefsQ.error) {
return <QueryErrorState error={prefsQ.error} onRetry={() => prefsQ.refetch()} />
}
const prefs: NotificationPreferences = prefsQ.data ?? {
email: true,
express: false,
telegram: false,
}
const handleToggle = (channel: keyof NotificationPreferences, next: boolean) => {
updateMut.mutate({ ...prefs, [channel]: next })
}
return (
<Panel>
<PageHeader
title={t('prefs.title', { defaultValue: 'Уведомления' })}
description={t('prefs.description', {
defaultValue:
'Per-channel настройки доставки. Применяется только к вашим уведомлениям (reviewer pool inbox остаётся ON).',
})}
/>
<div className="mt-6 space-y-1">
<ToggleRow
label={t('prefs.email.label', { defaultValue: 'Email' })}
description={t('prefs.email.description', {
defaultValue:
'SMTP доставка через сконфигурированный mail.host. На staging это mailpit catcher; на prod — реальный relay.',
})}
checked={prefs.email}
onChange={(v) => handleToggle('email', v)}
/>
<ToggleRow
label={t('prefs.express.label', { defaultValue: 'Express' })}
description={t('prefs.express.description', {
defaultValue:
'Корпоративный мессенджер. Канал ещё не подключён — toggle сохраняется на будущее.',
})}
checked={prefs.express}
onChange={(v) => handleToggle('express', v)}
disabled
comingSoon
/>
<ToggleRow
label={t('prefs.telegram.label', { defaultValue: 'Telegram' })}
description={t('prefs.telegram.description', {
defaultValue:
'Telegram бот. Канал ещё не подключён — toggle сохраняется на будущее.',
})}
checked={prefs.telegram}
onChange={(v) => handleToggle('telegram', v)}
disabled
comingSoon
/>
</div>
{updateMut.isError && (
<div
role="alert"
className="mt-4 rounded border border-danger bg-danger-bg p-3 text-sm text-danger"
>
{t('prefs.saveError', {
defaultValue: 'Не удалось сохранить настройки. Попробуйте ещё раз.',
})}
</div>
)}
</Panel>
)
}
type ToggleRowProps = {
label: string
description: string
checked: boolean
onChange: (v: boolean) => void
disabled?: boolean
comingSoon?: boolean
}
function ToggleRow({
label,
description,
checked,
onChange,
disabled,
comingSoon,
}: ToggleRowProps) {
const { t } = useTranslation()
return (
<div className="flex items-start justify-between gap-6 border-b border-line py-4 last:border-b-0">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-base font-medium text-ink">{label}</span>
{comingSoon && (
<span className="rounded bg-mute-bg px-2 py-0.5 text-xs text-mute">
{t('prefs.comingSoon', { defaultValue: 'Скоро' })}
</span>
)}
</div>
<p className="mt-1 text-sm text-ink-2">{description}</p>
</div>
<Switch
checked={checked}
onCheckedChange={onChange}
disabled={disabled}
aria-label={label}
/>
</div>
)
}