diff --git a/src/App.tsx b/src/App.tsx index 7aa7fb7..331d790 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -87,6 +87,7 @@ const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail')); const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail')); const AdminPinnedMessages = lazy(() => import('./pages/AdminPinnedMessages')); const AdminPinnedMessageCreate = lazy(() => import('./pages/AdminPinnedMessageCreate')); +const AdminChannelSubscriptions = lazy(() => import('./pages/AdminChannelSubscriptions')); const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview')); function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -854,6 +855,16 @@ function App() { } /> + + + + + + } + /> => { + const { data } = await apiClient.get( + '/cabinet/admin/channel-subscriptions', + ); + return data; + }, + + create: async (req: CreateChannelRequest): Promise => { + const { data } = await apiClient.post( + '/cabinet/admin/channel-subscriptions', + req, + ); + return data; + }, + + update: async (id: number, req: UpdateChannelRequest): Promise => { + const { data } = await apiClient.patch( + `/cabinet/admin/channel-subscriptions/${id}`, + req, + ); + return data; + }, + + toggle: async (id: number): Promise => { + const { data } = await apiClient.post( + `/cabinet/admin/channel-subscriptions/${id}/toggle`, + ); + return data; + }, + + delete: async (id: number): Promise => { + await apiClient.delete(`/cabinet/admin/channel-subscriptions/${id}`); + }, +}; + +export default adminChannelsApi; diff --git a/src/api/client.ts b/src/api/client.ts index 046aa9d..0b344a0 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -133,6 +133,12 @@ export interface ChannelSubscriptionError { code: 'channel_subscription_required'; message: string; channel_link?: string; + channels?: Array<{ + channel_id: string; + channel_link?: string; + title?: string; + is_subscribed: boolean; + }>; } export interface BlacklistedError { @@ -189,6 +195,7 @@ apiClient.interceptors.response.use( useBlockingStore.getState().setChannelSubscription({ message: detail.message, channel_link: detail.channel_link, + channels: detail.channels, }); return Promise.reject(error); } diff --git a/src/api/index.ts b/src/api/index.ts index ffe0600..3b0ba8d 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -12,6 +12,7 @@ export { infoApi } from './info'; export { adminSettingsApi } from './adminSettings'; export { adminApi, statsApi } from './admin'; export { adminAppsApi } from './adminApps'; +export { adminChannelsApi } from './adminChannels'; export { adminBroadcastsApi } from './adminBroadcasts'; export { adminEmailTemplatesApi } from './adminEmailTemplates'; export { adminPaymentMethodsApi } from './adminPaymentMethods'; diff --git a/src/components/blocking/ChannelSubscriptionScreen.tsx b/src/components/blocking/ChannelSubscriptionScreen.tsx index 67bb643..88a2cfe 100644 --- a/src/components/blocking/ChannelSubscriptionScreen.tsx +++ b/src/components/blocking/ChannelSubscriptionScreen.tsx @@ -1,10 +1,22 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useBlockingStore } from '../../store/blocking'; -import { apiClient } from '../../api/client'; +import { apiClient, isChannelSubscriptionError } from '../../api/client'; const CHECK_COOLDOWN_SECONDS = 5; +function safeOpenUrl(url: string | undefined | null): void { + if (!url) return; + try { + const parsed = new URL(url); + if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { + window.open(url, '_blank', 'noopener,noreferrer'); + } + } catch { + // invalid URL, do nothing + } +} + export default function ChannelSubscriptionScreen() { const { t } = useTranslation(); const channelInfo = useBlockingStore((state) => state.channelInfo); @@ -12,6 +24,7 @@ export default function ChannelSubscriptionScreen() { const [isChecking, setIsChecking] = useState(false); const [cooldown, setCooldown] = useState(0); const [error, setError] = useState(null); + const isCheckingRef = useRef(false); // Cooldown timer useEffect(() => { @@ -30,43 +43,31 @@ export default function ChannelSubscriptionScreen() { return () => clearInterval(timer); }, [cooldown]); - const openChannel = useCallback(() => { - if (channelInfo?.channel_link) { - window.open(channelInfo.channel_link, '_blank'); - } - }, [channelInfo?.channel_link]); + const allChannels = channelInfo?.channels ?? []; + const channels = allChannels.filter((ch) => !ch.is_subscribed); const checkSubscription = useCallback(async () => { - if (isChecking || cooldown > 0) return; - + if (isCheckingRef.current) return; + isCheckingRef.current = true; setIsChecking(true); setError(null); try { - // Make any authenticated request - if channel check passes, it will succeed await apiClient.get('/cabinet/auth/me'); - // If we get here, subscription is valid - reload page clearBlocking(); window.location.reload(); } catch (err: unknown) { - // Check if it's still a channel subscription error - const error = err as { - response?: { status?: number; data?: { detail?: { code?: string } } }; - }; - if ( - error.response?.status === 403 && - error.response?.data?.detail?.code === 'channel_subscription_required' - ) { + if (isChannelSubscriptionError(err)) { setError(t('blocking.channel.notSubscribed')); } else { - // Other error - might be network issue setError(t('blocking.channel.checkError')); } } finally { + isCheckingRef.current = false; setIsChecking(false); setCooldown(CHECK_COOLDOWN_SECONDS); } - }, [isChecking, cooldown, clearBlocking, t]); + }, [clearBlocking, t]); return (
@@ -84,98 +85,113 @@ export default function ChannelSubscriptionScreen() {

{t('blocking.channel.title')}

{/* Message */} -

+

{channelInfo?.message || t('blocking.channel.defaultMessage')}

+ {/* Channel list (only unsubscribed channels) */} + {channels.length > 0 && ( +
+ {channels.map((ch) => ( +
+ {ch.title || ch.channel_id} + {ch.channel_link && ( + + )} +
+ ))} +
+ )} + + {/* Fallback: single channel (legacy) */} + {channels.length === 0 && channelInfo?.channel_link && ( + + )} + {/* Error message */} {error && ( -
+

{error}

)} - {/* Buttons */} -
- {/* Open channel button */} - - - {/* Check subscription button */} - -
+ strokeWidth="4" + /> + + + {t('blocking.channel.checking')} + + ) : cooldown > 0 ? ( + <> + + + + {t('blocking.channel.waitSeconds', { seconds: cooldown })} + + ) : ( + <> + + + + {t('blocking.channel.checkSubscription')} + + )} + {/* Hint */} -

{t('blocking.channel.hint')}

+

{t('blocking.channel.hint')}

); diff --git a/src/locales/en.json b/src/locales/en.json index 4360b98..2b6ffcd 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -861,7 +861,8 @@ "updates": "Updates", "pinnedMessages": "Pinned Messages", "partners": "Partners", - "withdrawals": "Withdrawals" + "withdrawals": "Withdrawals", + "channelSubscriptions": "Required Channels" }, "panel": { "title": "Admin Panel", @@ -888,7 +889,8 @@ "updatesDesc": "Bot & cabinet versions", "pinnedMessagesDesc": "Manage pinned messages for users", "partnersDesc": "Manage partner applications and commissions", - "withdrawalsDesc": "Review and process withdrawal requests" + "withdrawalsDesc": "Review and process withdrawal requests", + "channelSubscriptionsDesc": "Manage required channel subscriptions" }, "trafficUsage": { "title": "Traffic Usage", @@ -1297,6 +1299,31 @@ "editMessage": "Edit", "cantDeleteActive": "Cannot delete active message" }, + "channelSubscriptions": { + "title": "Required Channels", + "subtitle": "Users must subscribe to these channels", + "addChannel": "Add Channel", + "empty": "No channels configured", + "enabled": "Enabled", + "disabled": "Disabled", + "enable": "Enable", + "disable": "Disable", + "edit": "Edit", + "editing": "Editing", + "delete": "Delete", + "deleteConfirm": "Delete this channel?", + "sortOrder": "Order", + "form": { + "title": "Display Name", + "channelId": "Channel ID", + "channelIdHint": "-100XXXXXXXXXX", + "channelLink": "Channel Link", + "channelLinkHint": "https://t.me/channel", + "submit": "Add", + "save": "Save", + "cancel": "Cancel" + } + }, "settings": { "title": "System Settings", "allCategories": "All categories", @@ -3144,7 +3171,9 @@ "waitSeconds": "Wait {{seconds}} sec.", "hint": "Click check button after subscribing", "notSubscribed": "You haven't subscribed to the channel yet", - "checkError": "Check failed. Please try again later." + "checkError": "Check failed. Please try again later.", + "subscribed": "Subscribed", + "channelsRequired": "Subscribe to all channels below to continue" }, "blacklisted": { "title": "Access Denied", diff --git a/src/locales/fa.json b/src/locales/fa.json index 68f682c..61e7267 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -741,7 +741,8 @@ "updates": "به‌روزرسانی‌ها", "pinnedMessages": "پیام‌های سنجاق‌شده", "partners": "شرکا", - "withdrawals": "برداشت‌ها" + "withdrawals": "برداشت‌ها", + "channelSubscriptions": "کانال‌های اجباری" }, "panel": { "title": "پنل مدیریت", @@ -768,7 +769,8 @@ "updatesDesc": "نسخه‌های ربات و کابینت", "pinnedMessagesDesc": "مدیریت پیام‌های سنجاق‌شده", "partnersDesc": "مدیریت درخواست‌های شراکت و کمیسیون‌ها", - "withdrawalsDesc": "بررسی و پردازش درخواست‌های برداشت" + "withdrawalsDesc": "بررسی و پردازش درخواست‌های برداشت", + "channelSubscriptionsDesc": "مدیریت اشتراک‌های اجباری کانال" }, "trafficUsage": { "title": "مصرف ترافیک", @@ -1000,6 +1002,31 @@ "editMessage": "Edit", "cantDeleteActive": "Cannot delete active message" }, + "channelSubscriptions": { + "title": "کانال‌های اجباری", + "subtitle": "کاربران باید در این کانال‌ها عضو شوند", + "addChannel": "افزودن کانال", + "empty": "کانالی تنظیم نشده", + "enabled": "فعال", + "disabled": "غیرفعال", + "enable": "فعال‌سازی", + "disable": "غیرفعال‌سازی", + "edit": "ویرایش", + "editing": "ویرایش", + "delete": "حذف", + "deleteConfirm": "این کانال حذف شود؟", + "sortOrder": "ترتیب", + "form": { + "title": "نام نمایشی", + "channelId": "شناسه کانال", + "channelIdHint": "-100XXXXXXXXXX", + "channelLink": "لینک کانال", + "channelLinkHint": "https://t.me/channel", + "submit": "افزودن", + "save": "ذخیره", + "cancel": "لغو" + } + }, "payments": { "title": "تأیید پرداخت", "description": "پرداخت‌های معلق در ۲۴ ساعت گذشته", @@ -2759,7 +2786,9 @@ "waitSeconds": "{{seconds}} ثانیه صبر کنید", "hint": "پس از عضویت دکمه بررسی را بزنید", "notSubscribed": "شما هنوز در کانال عضو نشده‌اید", - "checkError": "بررسی ناموفق بود. لطفاً بعداً دوباره امتحان کنید." + "checkError": "بررسی ناموفق بود. لطفاً بعداً دوباره امتحان کنید.", + "subscribed": "عضو شده", + "channelsRequired": "برای ادامه در همه کانال‌های زیر عضو شوید" }, "blacklisted": { "title": "دسترسی ممنوع", diff --git a/src/locales/ru.json b/src/locales/ru.json index e14a4e9..e10b230 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -883,7 +883,8 @@ "updates": "Обновления", "pinnedMessages": "Закреплённые", "partners": "Партнёры", - "withdrawals": "Выводы" + "withdrawals": "Выводы", + "channelSubscriptions": "Обязательные каналы" }, "panel": { "title": "Панель администратора", @@ -910,7 +911,8 @@ "updatesDesc": "Версии бота и кабинета", "pinnedMessagesDesc": "Управление закреплёнными сообщениями", "partnersDesc": "Управление заявками партнёров и комиссиями", - "withdrawalsDesc": "Проверка и обработка заявок на вывод" + "withdrawalsDesc": "Проверка и обработка заявок на вывод", + "channelSubscriptionsDesc": "Управление обязательными подписками на каналы" }, "trafficUsage": { "title": "Расход трафика", @@ -1323,6 +1325,31 @@ "editMessage": "Редактировать", "cantDeleteActive": "Нельзя удалить активное сообщение" }, + "channelSubscriptions": { + "title": "Обязательные каналы", + "subtitle": "Пользователи должны подписаться на эти каналы", + "addChannel": "Добавить канал", + "empty": "Каналы не настроены", + "enabled": "Активен", + "disabled": "Отключён", + "enable": "Включить", + "disable": "Отключить", + "edit": "Изменить", + "editing": "Редактирование", + "delete": "Удалить", + "deleteConfirm": "Удалить этот канал?", + "sortOrder": "Порядок", + "form": { + "title": "Название", + "channelId": "ID канала", + "channelIdHint": "-100XXXXXXXXXX", + "channelLink": "Ссылка на канал", + "channelLinkHint": "https://t.me/channel", + "submit": "Добавить", + "save": "Сохранить", + "cancel": "Отмена" + } + }, "settings": { "title": "Настройки системы", "allCategories": "Все категории", @@ -3699,7 +3726,9 @@ "waitSeconds": "Подождите {{seconds}} сек.", "hint": "После подписки нажмите кнопку проверки", "notSubscribed": "Вы ещё не подписались на канал", - "checkError": "Ошибка проверки. Попробуйте позже." + "checkError": "Ошибка проверки. Попробуйте позже.", + "subscribed": "Подписан", + "channelsRequired": "Подпишитесь на все каналы ниже для продолжения" }, "blacklisted": { "title": "Доступ запрещён", diff --git a/src/locales/zh.json b/src/locales/zh.json index 1f856e7..b493a0d 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -741,7 +741,8 @@ "updates": "更新", "pinnedMessages": "置顶消息", "partners": "合作伙伴", - "withdrawals": "提现" + "withdrawals": "提现", + "channelSubscriptions": "必订频道" }, "panel": { "title": "管理面板", @@ -768,7 +769,8 @@ "updatesDesc": "机器人和面板版本", "pinnedMessagesDesc": "管理用户置顶消息", "partnersDesc": "管理合作伙伴申请和佣金", - "withdrawalsDesc": "审核和处理提现请求" + "withdrawalsDesc": "审核和处理提现请求", + "channelSubscriptionsDesc": "管理必订频道订阅" }, "trafficUsage": { "title": "流量使用", @@ -1052,6 +1054,31 @@ "editMessage": "编辑", "cantDeleteActive": "无法删除活跃消息" }, + "channelSubscriptions": { + "title": "必订频道", + "subtitle": "用户必须订阅这些频道", + "addChannel": "添加频道", + "empty": "未配置频道", + "enabled": "已启用", + "disabled": "已禁用", + "enable": "启用", + "disable": "禁用", + "edit": "编辑", + "editing": "编辑", + "delete": "删除", + "deleteConfirm": "删除此频道?", + "sortOrder": "排序", + "form": { + "title": "显示名称", + "channelId": "频道ID", + "channelIdHint": "-100XXXXXXXXXX", + "channelLink": "频道链接", + "channelLinkHint": "https://t.me/channel", + "submit": "添加", + "save": "保存", + "cancel": "取消" + } + }, "settings": { "title": "系统设置", "allCategories": "所有分类", @@ -2640,7 +2667,9 @@ "waitSeconds": "请等待 {{seconds}} 秒", "hint": "订阅后点击检查按钮", "notSubscribed": "您还没有订阅该频道", - "checkError": "检查失败。请稍后重试。" + "checkError": "检查失败。请稍后重试。", + "subscribed": "已订阅", + "channelsRequired": "请订阅以下所有频道以继续" }, "blacklisted": { "title": "访问被拒绝", diff --git a/src/pages/AdminChannelSubscriptions.tsx b/src/pages/AdminChannelSubscriptions.tsx new file mode 100644 index 0000000..ba37fca --- /dev/null +++ b/src/pages/AdminChannelSubscriptions.tsx @@ -0,0 +1,597 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { useHaptic, useNotify } from '../platform'; +import { + adminChannelsApi, + type RequiredChannel, + type CreateChannelRequest, + type UpdateChannelRequest, +} from '../api/adminChannels'; +import { AdminBackButton } from '../components/admin'; + +// Icons +const ChannelIcon = () => ( + + + +); + +const PlusIcon = () => ( + + + +); + +const RefreshIcon = () => ( + + + +); + +const TrashIcon = () => ( + + + +); + +const CheckIcon = () => ( + + + +); + +const XIcon = () => ( + + + +); + +const EditIcon = () => ( + + + +); + +const LinkIcon = () => ( + + + +); + +// Channel card component +function ChannelCard({ + channel, + onToggle, + onDelete, + onEdit, +}: { + channel: RequiredChannel; + onToggle: (id: number) => void; + onDelete: (id: number) => void; + onEdit: (channel: RequiredChannel) => void; +}) { + const { t } = useTranslation(); + + const displayName = channel.title || channel.channel_id; + const hasLink = !!channel.channel_link; + + return ( +
+
+
+ {/* Status + sort order */} +
+ + {channel.is_active + ? t('admin.channelSubscriptions.enabled') + : t('admin.channelSubscriptions.disabled')} + + #{channel.id} + + {t('admin.channelSubscriptions.sortOrder')}: {channel.sort_order} + +
+ + {/* Title / channel_id */} +

{displayName}

+ + {/* Channel ID (if title exists, show ID separately) */} + {channel.title &&

{channel.channel_id}

} + + {/* Link */} + {hasLink && ( + + )} +
+
+ + {/* Action buttons */} +
+ + + {channel.is_active ? ( + + ) : ( + + )} + + +
+
+ ); +} + +// Shared form fields component +function ChannelFormFields({ + channelId, + setChannelId, + title, + setTitle, + channelLink, + setChannelLink, + sortOrder, + setSortOrder, + showChannelId, + showSortOrder, +}: { + channelId: string; + setChannelId: (v: string) => void; + title: string; + setTitle: (v: string) => void; + channelLink: string; + setChannelLink: (v: string) => void; + sortOrder: string; + setSortOrder: (v: string) => void; + showChannelId: boolean; + showSortOrder: boolean; +}) { + const { t } = useTranslation(); + + return ( + <> + {showChannelId && ( +
+ + setChannelId(e.target.value)} + placeholder={t('admin.channelSubscriptions.form.channelIdHint')} + className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-sm text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500" + autoFocus + /> +
+ )} + +
+ + setTitle(e.target.value)} + placeholder={t('admin.channelSubscriptions.form.title')} + className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-sm text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500" + autoFocus={!showChannelId} + /> +
+ +
+ + setChannelLink(e.target.value)} + placeholder={t('admin.channelSubscriptions.form.channelLinkHint')} + className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-sm text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500" + /> +
+ + {showSortOrder && ( +
+ + setSortOrder(e.target.value)} + placeholder="0" + className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-sm text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500" + /> +
+ )} + + ); +} + +// Add channel form component +function AddChannelForm({ + onSubmit, + onCancel, + isLoading, +}: { + onSubmit: (data: CreateChannelRequest) => void; + onCancel: () => void; + isLoading: boolean; +}) { + const { t } = useTranslation(); + const [channelId, setChannelId] = useState(''); + const [channelLink, setChannelLink] = useState(''); + const [title, setTitle] = useState(''); + + const handleSubmit = () => { + if (!channelId.trim()) return; + onSubmit({ + channel_id: channelId.trim(), + channel_link: channelLink.trim() || undefined, + title: title.trim() || undefined, + }); + }; + + return ( +
+
+ {}} + showChannelId + showSortOrder={false} + /> + +
+ + +
+
+
+ ); +} + +// Edit channel form component +function EditChannelForm({ + channel, + onSubmit, + onCancel, + isLoading, +}: { + channel: RequiredChannel; + onSubmit: (id: number, data: UpdateChannelRequest) => void; + onCancel: () => void; + isLoading: boolean; +}) { + const { t } = useTranslation(); + const [title, setTitle] = useState(channel.title ?? ''); + const [channelLink, setChannelLink] = useState(channel.channel_link ?? ''); + const [sortOrder, setSortOrder] = useState(String(channel.sort_order)); + + const handleSubmit = () => { + const updates: UpdateChannelRequest = {}; + const newTitle = title.trim() || undefined; + const newLink = channelLink.trim() || undefined; + const newSort = parseInt(sortOrder, 10); + + if (newTitle !== (channel.title ?? undefined)) + updates.title = newTitle ?? (null as unknown as string); + if (newLink !== (channel.channel_link ?? undefined)) + updates.channel_link = newLink ?? (null as unknown as string); + if (!isNaN(newSort) && newSort !== channel.sort_order) updates.sort_order = newSort; + + onSubmit(channel.id, updates); + }; + + return ( +
+

+ {t('admin.channelSubscriptions.editing')}:{' '} + {channel.channel_id} +

+
+ {}} + title={title} + setTitle={setTitle} + channelLink={channelLink} + setChannelLink={setChannelLink} + sortOrder={sortOrder} + setSortOrder={setSortOrder} + showChannelId={false} + showSortOrder + /> + +
+ + +
+
+
+ ); +} + +// Main component +export default function AdminChannelSubscriptions() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const haptic = useHaptic(); + const notify = useNotify(); + const [showAddForm, setShowAddForm] = useState(false); + const [editingChannel, setEditingChannel] = useState(null); + + // Fetch channels + const { data, isLoading, refetch } = useQuery({ + queryKey: ['admin-channels'], + queryFn: adminChannelsApi.list, + }); + + const toggleMutation = useMutation({ + mutationFn: (id: number) => adminChannelsApi.toggle(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-channels'] }); + haptic.impact('light'); + }, + onError: () => { + haptic.notification('error'); + notify.error(t('common.error')); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => adminChannelsApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-channels'] }); + haptic.impact('medium'); + }, + onError: () => { + haptic.notification('error'); + notify.error(t('common.error')); + }, + }); + + const createMutation = useMutation({ + mutationFn: (req: CreateChannelRequest) => adminChannelsApi.create(req), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-channels'] }); + setShowAddForm(false); + haptic.impact('light'); + }, + onError: () => { + haptic.notification('error'); + notify.error(t('common.error')); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: UpdateChannelRequest }) => + adminChannelsApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-channels'] }); + setEditingChannel(null); + haptic.impact('light'); + }, + onError: () => { + haptic.notification('error'); + notify.error(t('common.error')); + }, + }); + + const handleToggle = (id: number) => { + toggleMutation.mutate(id); + }; + + const handleDelete = (id: number) => { + if (window.confirm(t('admin.channelSubscriptions.deleteConfirm'))) { + deleteMutation.mutate(id); + } + }; + + const handleCreate = (data: CreateChannelRequest) => { + createMutation.mutate(data); + }; + + const handleUpdate = (id: number, data: UpdateChannelRequest) => { + updateMutation.mutate({ id, data }); + }; + + const handleEdit = (channel: RequiredChannel) => { + setEditingChannel(channel); + setShowAddForm(false); + }; + + const channels = data?.items ?? []; + + return ( +
+ {/* Header */} +
+
+ +
+
+ +
+
+

+ {t('admin.channelSubscriptions.title')} +

+

{t('admin.channelSubscriptions.subtitle')}

+
+
+
+
+ + {!showAddForm && !editingChannel && ( + + )} +
+
+ + {/* Add form */} + {showAddForm && ( + setShowAddForm(false)} + isLoading={createMutation.isPending} + /> + )} + + {/* Edit form */} + {editingChannel && ( + setEditingChannel(null)} + isLoading={updateMutation.isPending} + /> + )} + + {/* Channel list */} + {isLoading ? ( +
+
+ +
+

{t('common.loading')}

+
+ ) : channels.length === 0 ? ( +
+
+ +
+

{t('admin.channelSubscriptions.empty')}

+
+ ) : ( +
+ {channels.map((channel: RequiredChannel) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 3bbe34b..0f4b551 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -482,6 +482,12 @@ export default function AdminPanel() { iconBg: 'bg-violet-500/20', iconColor: 'text-violet-400', items: [ + { + to: '/admin/channel-subscriptions', + icon: , + title: t('admin.nav.channelSubscriptions'), + description: t('admin.panel.channelSubscriptionsDesc'), + }, { to: '/admin/settings', icon: , diff --git a/src/store/blocking.ts b/src/store/blocking.ts index 7188bff..cd4d53a 100644 --- a/src/store/blocking.ts +++ b/src/store/blocking.ts @@ -7,9 +7,22 @@ interface MaintenanceInfo { reason?: string; } +/** + * User-facing channel subscription status returned by the blocking 403 response. + * Intentionally separate from `RequiredChannel` (api/adminChannels.ts) which + * represents the admin CRUD entity with `is_active` / `sort_order` fields. + */ +interface ChannelInfo { + channel_id: string; + channel_link?: string; + title?: string; + is_subscribed: boolean; +} + interface ChannelSubscriptionInfo { message: string; channel_link?: string; + channels?: ChannelInfo[]; } interface BlacklistedInfo {