181 lines
6.1 KiB
TypeScript
181 lines
6.1 KiB
TypeScript
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>
|
||
)
|
||
}
|