feat(admin-ui): Phase B frontend — notifications preferences route + UI
This commit is contained in:
@@ -719,3 +719,18 @@ export type NotificationsResponse = {
|
|||||||
/** Unread count for badge — pre-filtered server-side. */
|
/** Unread count for badge — pre-filtered server-side. */
|
||||||
totalUnread: number
|
totalUnread: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase B — per-channel notification opt-in/out для current user. Backend
|
||||||
|
* endpoint: GET/PUT /api/v1/me/notifications/preferences. Default semantics
|
||||||
|
* (server-side, when row missing): email=true, express=false, telegram=false.
|
||||||
|
*
|
||||||
|
* <p>Express/Telegram channels пока not wired backend-side — toggles в UI
|
||||||
|
* disabled с tooltip 'Скоро'. Email — единственный фактически работающий
|
||||||
|
* канал в Phase A/B.
|
||||||
|
*/
|
||||||
|
export type NotificationPreferences = {
|
||||||
|
email: boolean
|
||||||
|
express: boolean
|
||||||
|
telegram: boolean
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type DecisionRequest,
|
type DecisionRequest,
|
||||||
type DictionaryDetail,
|
type DictionaryDetail,
|
||||||
type DraftResponse,
|
type DraftResponse,
|
||||||
|
type NotificationPreferences,
|
||||||
type PublishSchemaDraftRequest,
|
type PublishSchemaDraftRequest,
|
||||||
type PublishSchemaDraftResult,
|
type PublishSchemaDraftResult,
|
||||||
type RecordResponse,
|
type RecordResponse,
|
||||||
@@ -700,3 +701,43 @@ export const useMarkAllNotificationsRead = () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase B — update per-channel notification preferences. PUT upserts all 3
|
||||||
|
* каналов атомарно (backend @Transactional). Optimistic update — UI flips
|
||||||
|
* toggle instantly, onError refetch restores original state.
|
||||||
|
*
|
||||||
|
* <p>Cache key 'notifications/me/preferences' — separate from feed query
|
||||||
|
* (которая использует 'notifications/me'). Invalidate prefs alone не
|
||||||
|
* triggers feed refetch.
|
||||||
|
*/
|
||||||
|
export const useUpdateNotificationPreferences = () => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (prefs: NotificationPreferences): Promise<NotificationPreferences> => {
|
||||||
|
const { data } = await apiClient.put<NotificationPreferences>(
|
||||||
|
'/me/notifications/preferences',
|
||||||
|
prefs,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onMutate: async (next) => {
|
||||||
|
await qc.cancelQueries({ queryKey: ['notifications', 'me', 'preferences'] })
|
||||||
|
const prev = qc.getQueryData<NotificationPreferences>([
|
||||||
|
'notifications',
|
||||||
|
'me',
|
||||||
|
'preferences',
|
||||||
|
])
|
||||||
|
qc.setQueryData(['notifications', 'me', 'preferences'], next)
|
||||||
|
return { prev }
|
||||||
|
},
|
||||||
|
onError: (_err, _next, ctx) => {
|
||||||
|
if (ctx?.prev) {
|
||||||
|
qc.setQueryData(['notifications', 'me', 'preferences'], ctx.prev)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['notifications', 'me', 'preferences'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type CascadePlan,
|
type CascadePlan,
|
||||||
type ChangelogDiff,
|
type ChangelogDiff,
|
||||||
type ChangelogResponse,
|
type ChangelogResponse,
|
||||||
|
type NotificationPreferences,
|
||||||
type NotificationsResponse,
|
type NotificationsResponse,
|
||||||
type SchemaDraft,
|
type SchemaDraft,
|
||||||
type SchemaReviewQueuePage,
|
type SchemaReviewQueuePage,
|
||||||
@@ -851,3 +852,23 @@ export const myNotificationsQuery = (unreadOnly = false, limit = 20) =>
|
|||||||
|
|
||||||
export const useMyNotifications = (unreadOnly = false, limit = 20) =>
|
export const useMyNotifications = (unreadOnly = false, limit = 20) =>
|
||||||
useQuery(myNotificationsQuery(unreadOnly, limit))
|
useQuery(myNotificationsQuery(unreadOnly, limit))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase B — per-channel notification preferences для current user. GET — single
|
||||||
|
* row (no pagination), 401 если anonymous. Default semantics filled in
|
||||||
|
* backend-side когда DB row missing (email=true / express=false / telegram=false).
|
||||||
|
*/
|
||||||
|
export const myNotificationPreferencesQuery = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['notifications', 'me', 'preferences'] as const,
|
||||||
|
queryFn: async (): Promise<NotificationPreferences> => {
|
||||||
|
const { data } = await apiClient.get<NotificationPreferences>(
|
||||||
|
'/me/notifications/preferences',
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
staleTime: 60_000, // prefs не меняются часто, кэш дольше
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useMyNotificationPreferences = () =>
|
||||||
|
useQuery(myNotificationPreferencesQuery())
|
||||||
|
|||||||
@@ -88,8 +88,19 @@ export function NotificationsDrawer({ open, onClose }: Props) {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{totalUnread > 0 && (
|
<div className="border-t border-line pt-3 flex items-center justify-between">
|
||||||
<div className="border-t border-line pt-3 flex items-center justify-end">
|
{/* Phase B: link к per-channel preferences page. Всегда visible
|
||||||
|
(даже если unread=0 — пользователь может зайти настроить впрок). */}
|
||||||
|
<Link
|
||||||
|
to="/me/notifications/preferences"
|
||||||
|
className="text-sm text-accent hover:underline"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{t('notifications.preferencesLink', {
|
||||||
|
defaultValue: 'Настроить каналы →',
|
||||||
|
})}
|
||||||
|
</Link>
|
||||||
|
{totalUnread > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -102,8 +113,8 @@ export function NotificationsDrawer({ open, onClose }: Props) {
|
|||||||
defaultValue: 'Прочитать все',
|
defaultValue: 'Прочитать все',
|
||||||
})}
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -617,6 +617,25 @@ i18n
|
|||||||
'reviews.mine.anonTitle': 'Войдите чтобы видеть свои черновики',
|
'reviews.mine.anonTitle': 'Войдите чтобы видеть свои черновики',
|
||||||
'reviews.mine.anonDescription':
|
'reviews.mine.anonDescription':
|
||||||
'Собственные drafts видны только после авторизации — backend фильтрует по вашему JWT sub claim. Войдите через Keycloak чтобы открыть Мои.',
|
'Собственные drafts видны только после авторизации — backend фильтрует по вашему JWT sub claim. Войдите через Keycloak чтобы открыть Мои.',
|
||||||
|
// === /me/notifications/preferences (Phase B) ===
|
||||||
|
'prefs.title': 'Уведомления',
|
||||||
|
'prefs.description':
|
||||||
|
'Per-channel настройки доставки. Применяется только к вашим уведомлениям (reviewer pool inbox остаётся ON).',
|
||||||
|
'prefs.anonTitle': 'Войдите чтобы управлять подписками',
|
||||||
|
'prefs.anonDescription':
|
||||||
|
'Настройки уведомлений хранятся per-user (по JWT sub claim). Войдите через Keycloak чтобы открыть страницу.',
|
||||||
|
'prefs.email.label': 'Email',
|
||||||
|
'prefs.email.description':
|
||||||
|
'SMTP доставка через сконфигурированный mail.host. На staging это mailpit catcher; на prod — реальный relay.',
|
||||||
|
'prefs.express.label': 'Express',
|
||||||
|
'prefs.express.description':
|
||||||
|
'Корпоративный мессенджер. Канал ещё не подключён — toggle сохраняется на будущее.',
|
||||||
|
'prefs.telegram.label': 'Telegram',
|
||||||
|
'prefs.telegram.description':
|
||||||
|
'Telegram бот. Канал ещё не подключён — toggle сохраняется на будущее.',
|
||||||
|
'prefs.comingSoon': 'Скоро',
|
||||||
|
'prefs.saveError': 'Не удалось сохранить настройки. Попробуйте ещё раз.',
|
||||||
|
'notifications.preferencesLink': 'Настроить каналы →',
|
||||||
'reviews.mine.filter.all': 'Все',
|
'reviews.mine.filter.all': 'Все',
|
||||||
'reviews.mine.filter.pending': 'На ревью',
|
'reviews.mine.filter.pending': 'На ревью',
|
||||||
'reviews.mine.filter.wip': 'В работе',
|
'reviews.mine.filter.wip': 'В работе',
|
||||||
@@ -1377,6 +1396,25 @@ i18n
|
|||||||
'reviews.mine.anonTitle': 'Sign in to see your drafts',
|
'reviews.mine.anonTitle': 'Sign in to see your drafts',
|
||||||
'reviews.mine.anonDescription':
|
'reviews.mine.anonDescription':
|
||||||
'Your own drafts are visible only after sign-in — backend filters by your JWT sub claim. Sign in via Keycloak to open Mine.',
|
'Your own drafts are visible only after sign-in — backend filters by your JWT sub claim. Sign in via Keycloak to open Mine.',
|
||||||
|
// === /me/notifications/preferences (Phase B) ===
|
||||||
|
'prefs.title': 'Notifications',
|
||||||
|
'prefs.description':
|
||||||
|
'Per-channel delivery preferences. Applies only to your notifications (reviewer pool inbox stays ON).',
|
||||||
|
'prefs.anonTitle': 'Sign in to manage your subscriptions',
|
||||||
|
'prefs.anonDescription':
|
||||||
|
'Notification preferences are stored per-user (by JWT sub claim). Sign in via Keycloak to open this page.',
|
||||||
|
'prefs.email.label': 'Email',
|
||||||
|
'prefs.email.description':
|
||||||
|
'SMTP delivery via configured mail.host. On staging it is the mailpit catcher; on prod — real relay.',
|
||||||
|
'prefs.express.label': 'Express',
|
||||||
|
'prefs.express.description':
|
||||||
|
'Corporate messenger. Channel not wired yet — toggle is stored for later.',
|
||||||
|
'prefs.telegram.label': 'Telegram',
|
||||||
|
'prefs.telegram.description':
|
||||||
|
'Telegram bot. Channel not wired yet — toggle is stored for later.',
|
||||||
|
'prefs.comingSoon': 'Soon',
|
||||||
|
'prefs.saveError': 'Failed to save preferences. Please try again.',
|
||||||
|
'notifications.preferencesLink': 'Manage channels →',
|
||||||
'reviews.mine.emptyFilter': 'No drafts in this category',
|
'reviews.mine.emptyFilter': 'No drafts in this category',
|
||||||
'reviews.mine.filter.all': 'All',
|
'reviews.mine.filter.all': 'All',
|
||||||
'reviews.mine.filter.pending': 'Under review',
|
'reviews.mine.filter.pending': 'Under review',
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Route as WebhooksIndexRouteImport } from './routes/webhooks.index'
|
|||||||
import { Route as DictionariesIndexRouteImport } from './routes/dictionaries.index'
|
import { Route as DictionariesIndexRouteImport } from './routes/dictionaries.index'
|
||||||
import { Route as WebhooksIdRouteImport } from './routes/webhooks.$id'
|
import { Route as WebhooksIdRouteImport } from './routes/webhooks.$id'
|
||||||
import { Route as DictionariesNameRouteImport } from './routes/dictionaries.$name'
|
import { Route as DictionariesNameRouteImport } from './routes/dictionaries.$name'
|
||||||
|
import { Route as MeNotificationsPreferencesRouteImport } from './routes/me.notifications.preferences'
|
||||||
|
|
||||||
const WebhooksRoute = WebhooksRouteImport.update({
|
const WebhooksRoute = WebhooksRouteImport.update({
|
||||||
id: '/webhooks',
|
id: '/webhooks',
|
||||||
@@ -88,6 +89,12 @@ const DictionariesNameRoute = DictionariesNameRouteImport.update({
|
|||||||
path: '/$name',
|
path: '/$name',
|
||||||
getParentRoute: () => DictionariesRoute,
|
getParentRoute: () => DictionariesRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const MeNotificationsPreferencesRoute =
|
||||||
|
MeNotificationsPreferencesRouteImport.update({
|
||||||
|
id: '/me/notifications/preferences',
|
||||||
|
path: '/me/notifications/preferences',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
@@ -103,6 +110,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/webhooks/$id': typeof WebhooksIdRoute
|
'/webhooks/$id': typeof WebhooksIdRoute
|
||||||
'/dictionaries/': typeof DictionariesIndexRoute
|
'/dictionaries/': typeof DictionariesIndexRoute
|
||||||
'/webhooks/': typeof WebhooksIndexRoute
|
'/webhooks/': typeof WebhooksIndexRoute
|
||||||
|
'/me/notifications/preferences': typeof MeNotificationsPreferencesRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
@@ -116,6 +124,7 @@ export interface FileRoutesByTo {
|
|||||||
'/webhooks/$id': typeof WebhooksIdRoute
|
'/webhooks/$id': typeof WebhooksIdRoute
|
||||||
'/dictionaries': typeof DictionariesIndexRoute
|
'/dictionaries': typeof DictionariesIndexRoute
|
||||||
'/webhooks': typeof WebhooksIndexRoute
|
'/webhooks': typeof WebhooksIndexRoute
|
||||||
|
'/me/notifications/preferences': typeof MeNotificationsPreferencesRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -132,6 +141,7 @@ export interface FileRoutesById {
|
|||||||
'/webhooks/$id': typeof WebhooksIdRoute
|
'/webhooks/$id': typeof WebhooksIdRoute
|
||||||
'/dictionaries/': typeof DictionariesIndexRoute
|
'/dictionaries/': typeof DictionariesIndexRoute
|
||||||
'/webhooks/': typeof WebhooksIndexRoute
|
'/webhooks/': typeof WebhooksIndexRoute
|
||||||
|
'/me/notifications/preferences': typeof MeNotificationsPreferencesRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
@@ -149,6 +159,7 @@ export interface FileRouteTypes {
|
|||||||
| '/webhooks/$id'
|
| '/webhooks/$id'
|
||||||
| '/dictionaries/'
|
| '/dictionaries/'
|
||||||
| '/webhooks/'
|
| '/webhooks/'
|
||||||
|
| '/me/notifications/preferences'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
@@ -162,6 +173,7 @@ export interface FileRouteTypes {
|
|||||||
| '/webhooks/$id'
|
| '/webhooks/$id'
|
||||||
| '/dictionaries'
|
| '/dictionaries'
|
||||||
| '/webhooks'
|
| '/webhooks'
|
||||||
|
| '/me/notifications/preferences'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
@@ -177,6 +189,7 @@ export interface FileRouteTypes {
|
|||||||
| '/webhooks/$id'
|
| '/webhooks/$id'
|
||||||
| '/dictionaries/'
|
| '/dictionaries/'
|
||||||
| '/webhooks/'
|
| '/webhooks/'
|
||||||
|
| '/me/notifications/preferences'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -189,6 +202,7 @@ export interface RootRouteChildren {
|
|||||||
ReviewsRoute: typeof ReviewsRoute
|
ReviewsRoute: typeof ReviewsRoute
|
||||||
SearchRoute: typeof SearchRoute
|
SearchRoute: typeof SearchRoute
|
||||||
WebhooksRoute: typeof WebhooksRouteWithChildren
|
WebhooksRoute: typeof WebhooksRouteWithChildren
|
||||||
|
MeNotificationsPreferencesRoute: typeof MeNotificationsPreferencesRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
@@ -284,6 +298,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DictionariesNameRouteImport
|
preLoaderRoute: typeof DictionariesNameRouteImport
|
||||||
parentRoute: typeof DictionariesRoute
|
parentRoute: typeof DictionariesRoute
|
||||||
}
|
}
|
||||||
|
'/me/notifications/preferences': {
|
||||||
|
id: '/me/notifications/preferences'
|
||||||
|
path: '/me/notifications/preferences'
|
||||||
|
fullPath: '/me/notifications/preferences'
|
||||||
|
preLoaderRoute: typeof MeNotificationsPreferencesRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,6 +346,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ReviewsRoute: ReviewsRoute,
|
ReviewsRoute: ReviewsRoute,
|
||||||
SearchRoute: SearchRoute,
|
SearchRoute: SearchRoute,
|
||||||
WebhooksRoute: WebhooksRouteWithChildren,
|
WebhooksRoute: WebhooksRouteWithChildren,
|
||||||
|
MeNotificationsPreferencesRoute: MeNotificationsPreferencesRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user