From a767fe96d3992f91b5c1b722de132ea67f975432 Mon Sep 17 00:00:00 2001
From: Fringg
Date: Tue, 24 Feb 2026 02:50:52 +0300
Subject: [PATCH 1/2] feat: add multi-channel subscription blocking UI and
admin management
- Channel subscription blocking screen with per-channel status display
- Safe URL handling with protocol validation (no open redirect)
- Race-condition-safe subscription check with useRef guard
- Admin channel management page with create, toggle, delete
- Error handlers on all admin mutations with haptic feedback
- Proper type guard for channel subscription error detection
- Accessibility: aria-labels on icon-only buttons
- Localization: en, ru, zh, fa translations
---
src/App.tsx | 11 +
src/api/adminChannels.ts | 68 +++
src/api/client.ts | 7 +
src/api/index.ts | 1 +
.../blocking/ChannelSubscriptionScreen.tsx | 229 +++++-----
src/locales/en.json | 32 +-
src/locales/fa.json | 32 +-
src/locales/ru.json | 32 +-
src/locales/zh.json | 32 +-
src/pages/AdminChannelSubscriptions.tsx | 408 ++++++++++++++++++
src/pages/AdminPanel.tsx | 6 +
src/store/blocking.ts | 13 +
12 files changed, 758 insertions(+), 113 deletions(-)
create mode 100644 src/api/adminChannels.ts
create mode 100644 src/pages/AdminChannelSubscriptions.tsx
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..d6d45ce 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,30 @@ 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 channels = channelInfo?.channels ?? [];
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 +84,125 @@ export default function ChannelSubscriptionScreen() {
{t('blocking.channel.title')}
{/* Message */}
-
+
{channelInfo?.message || t('blocking.channel.defaultMessage')}
+ {/* Channel list */}
+ {channels.length > 0 && (
+
+ {channels.map((ch) => (
+
+
{ch.title || ch.channel_id}
+
+ {ch.is_subscribed ? (
+
+ {t('blocking.channel.subscribed')}
+
+ ) : (
+ ch.channel_link && (
+
+ )
+ )}
+
+
+ ))}
+
+ )}
+
+ {/* Fallback: single channel (legacy) */}
+ {channels.length === 0 && channelInfo?.channel_link && (
+
+ )}
+
{/* Error message */}
{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..faba9c4 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,28 @@
"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",
+ "delete": "Delete",
+ "deleteConfirm": "Delete this channel?",
+ "sortOrder": "Order",
+ "form": {
+ "title": "Display Name",
+ "channelId": "Channel ID",
+ "channelIdHint": "@username or -100XXXXXXXXXX",
+ "channelLink": "Channel Link",
+ "channelLinkHint": "https://t.me/channel",
+ "submit": "Add",
+ "cancel": "Cancel"
+ }
+ },
"settings": {
"title": "System Settings",
"allCategories": "All categories",
@@ -3144,7 +3168,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..993618d 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,28 @@
"editMessage": "Edit",
"cantDeleteActive": "Cannot delete active message"
},
+ "channelSubscriptions": {
+ "title": "کانالهای اجباری",
+ "subtitle": "کاربران باید در این کانالها عضو شوند",
+ "addChannel": "افزودن کانال",
+ "empty": "کانالی تنظیم نشده",
+ "enabled": "فعال",
+ "disabled": "غیرفعال",
+ "enable": "فعالسازی",
+ "disable": "غیرفعالسازی",
+ "delete": "حذف",
+ "deleteConfirm": "این کانال حذف شود؟",
+ "sortOrder": "ترتیب",
+ "form": {
+ "title": "نام نمایشی",
+ "channelId": "شناسه کانال",
+ "channelIdHint": "@username یا -100XXXXXXXXXX",
+ "channelLink": "لینک کانال",
+ "channelLinkHint": "https://t.me/channel",
+ "submit": "افزودن",
+ "cancel": "لغو"
+ }
+ },
"payments": {
"title": "تأیید پرداخت",
"description": "پرداختهای معلق در ۲۴ ساعت گذشته",
@@ -2759,7 +2783,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..fe0a6ed 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,28 @@
"editMessage": "Редактировать",
"cantDeleteActive": "Нельзя удалить активное сообщение"
},
+ "channelSubscriptions": {
+ "title": "Обязательные каналы",
+ "subtitle": "Пользователи должны подписаться на эти каналы",
+ "addChannel": "Добавить канал",
+ "empty": "Каналы не настроены",
+ "enabled": "Активен",
+ "disabled": "Отключён",
+ "enable": "Включить",
+ "disable": "Отключить",
+ "delete": "Удалить",
+ "deleteConfirm": "Удалить этот канал?",
+ "sortOrder": "Порядок",
+ "form": {
+ "title": "Название",
+ "channelId": "ID канала",
+ "channelIdHint": "@username или -100XXXXXXXXXX",
+ "channelLink": "Ссылка на канал",
+ "channelLinkHint": "https://t.me/channel",
+ "submit": "Добавить",
+ "cancel": "Отмена"
+ }
+ },
"settings": {
"title": "Настройки системы",
"allCategories": "Все категории",
@@ -3699,7 +3723,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..4041148 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,28 @@
"editMessage": "编辑",
"cantDeleteActive": "无法删除活跃消息"
},
+ "channelSubscriptions": {
+ "title": "必订频道",
+ "subtitle": "用户必须订阅这些频道",
+ "addChannel": "添加频道",
+ "empty": "未配置频道",
+ "enabled": "已启用",
+ "disabled": "已禁用",
+ "enable": "启用",
+ "disable": "禁用",
+ "delete": "删除",
+ "deleteConfirm": "删除此频道?",
+ "sortOrder": "排序",
+ "form": {
+ "title": "显示名称",
+ "channelId": "频道ID",
+ "channelIdHint": "@username 或 -100XXXXXXXXXX",
+ "channelLink": "频道链接",
+ "channelLinkHint": "https://t.me/channel",
+ "submit": "添加",
+ "cancel": "取消"
+ }
+ },
"settings": {
"title": "系统设置",
"allCategories": "所有分类",
@@ -2640,7 +2664,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..fcef8e0
--- /dev/null
+++ b/src/pages/AdminChannelSubscriptions.tsx
@@ -0,0 +1,408 @@
+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,
+} from '../api/adminChannels';
+import { AdminBackButton } from '../components/admin';
+
+// Icons
+const ChannelIcon = () => (
+
+
+
+);
+
+const PlusIcon = () => (
+
+
+
+);
+
+const RefreshIcon = () => (
+
+
+
+);
+
+const TrashIcon = () => (
+
+
+
+);
+
+const CheckIcon = () => (
+
+
+
+);
+
+const XIcon = () => (
+
+
+
+);
+
+const LinkIcon = () => (
+
+
+
+);
+
+// Channel card component
+function ChannelCard({
+ channel,
+ onToggle,
+ onDelete,
+}: {
+ channel: RequiredChannel;
+ onToggle: (id: number) => void;
+ onDelete: (id: number) => 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 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
+
+// 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 (
+
+
+ {/* Channel ID (required) */}
+
+
+ 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
+ />
+
+
+ {/* Title (optional) */}
+
+
+ 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"
+ />
+
+
+ {/* Channel Link (optional) */}
+
+
+ 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"
+ />
+
+
+ {/* Buttons */}
+
+
+
+
+
+
+ );
+}
+
+// Main component
+export default function AdminChannelSubscriptions() {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const haptic = useHaptic();
+ const notify = useNotify();
+ const [showAddForm, setShowAddForm] = useState(false);
+
+ // 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 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 channels = data?.items ?? [];
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
+ {t('admin.channelSubscriptions.title')}
+
+
{t('admin.channelSubscriptions.subtitle')}
+
+
+
+
+
+ {!showAddForm && (
+
+ )}
+
+
+
+ {/* Add form */}
+ {showAddForm && (
+
setShowAddForm(false)}
+ isLoading={createMutation.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 {
From 5a5589214529e42fd08a3b41929cddd974d52420 Mon Sep 17 00:00:00 2001
From: Fringg
Date: Tue, 24 Feb 2026 03:12:12 +0300
Subject: [PATCH 2/2] feat: add channel edit in admin, hide subscribed channels
in blocking screen
- Admin panel: added Edit button on channel cards with inline edit form
(title, link, sort order) using existing PATCH API
- Subscription screen: filter out already-subscribed channels so users
only see what they still need to subscribe to
- Updated channelIdHint to remove @username reference (numeric only)
- Added i18n keys: edit, editing, form.save (en/ru/fa/zh)
---
.../blocking/ChannelSubscriptionScreen.tsx | 35 +--
src/locales/en.json | 5 +-
src/locales/fa.json | 5 +-
src/locales/ru.json | 5 +-
src/locales/zh.json | 5 +-
src/pages/AdminChannelSubscriptions.tsx | 277 +++++++++++++++---
6 files changed, 261 insertions(+), 71 deletions(-)
diff --git a/src/components/blocking/ChannelSubscriptionScreen.tsx b/src/components/blocking/ChannelSubscriptionScreen.tsx
index d6d45ce..88a2cfe 100644
--- a/src/components/blocking/ChannelSubscriptionScreen.tsx
+++ b/src/components/blocking/ChannelSubscriptionScreen.tsx
@@ -43,7 +43,8 @@ export default function ChannelSubscriptionScreen() {
return () => clearInterval(timer);
}, [cooldown]);
- const channels = channelInfo?.channels ?? [];
+ const allChannels = channelInfo?.channels ?? [];
+ const channels = allChannels.filter((ch) => !ch.is_subscribed);
const checkSubscription = useCallback(async () => {
if (isCheckingRef.current) return;
@@ -88,35 +89,23 @@ export default function ChannelSubscriptionScreen() {
{channelInfo?.message || t('blocking.channel.defaultMessage')}
- {/* Channel list */}
+ {/* Channel list (only unsubscribed channels) */}
{channels.length > 0 && (
{channels.map((ch) => (
{ch.title || ch.channel_id}
-
- {ch.is_subscribed ? (
-
- {t('blocking.channel.subscribed')}
-
- ) : (
- ch.channel_link && (
-
- )
- )}
-
+ {ch.channel_link && (
+
+ )}
))}
diff --git a/src/locales/en.json b/src/locales/en.json
index faba9c4..2b6ffcd 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -1308,16 +1308,19 @@
"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": "@username or -100XXXXXXXXXX",
+ "channelIdHint": "-100XXXXXXXXXX",
"channelLink": "Channel Link",
"channelLinkHint": "https://t.me/channel",
"submit": "Add",
+ "save": "Save",
"cancel": "Cancel"
}
},
diff --git a/src/locales/fa.json b/src/locales/fa.json
index 993618d..61e7267 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -1011,16 +1011,19 @@
"disabled": "غیرفعال",
"enable": "فعالسازی",
"disable": "غیرفعالسازی",
+ "edit": "ویرایش",
+ "editing": "ویرایش",
"delete": "حذف",
"deleteConfirm": "این کانال حذف شود؟",
"sortOrder": "ترتیب",
"form": {
"title": "نام نمایشی",
"channelId": "شناسه کانال",
- "channelIdHint": "@username یا -100XXXXXXXXXX",
+ "channelIdHint": "-100XXXXXXXXXX",
"channelLink": "لینک کانال",
"channelLinkHint": "https://t.me/channel",
"submit": "افزودن",
+ "save": "ذخیره",
"cancel": "لغو"
}
},
diff --git a/src/locales/ru.json b/src/locales/ru.json
index fe0a6ed..e10b230 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -1334,16 +1334,19 @@
"disabled": "Отключён",
"enable": "Включить",
"disable": "Отключить",
+ "edit": "Изменить",
+ "editing": "Редактирование",
"delete": "Удалить",
"deleteConfirm": "Удалить этот канал?",
"sortOrder": "Порядок",
"form": {
"title": "Название",
"channelId": "ID канала",
- "channelIdHint": "@username или -100XXXXXXXXXX",
+ "channelIdHint": "-100XXXXXXXXXX",
"channelLink": "Ссылка на канал",
"channelLinkHint": "https://t.me/channel",
"submit": "Добавить",
+ "save": "Сохранить",
"cancel": "Отмена"
}
},
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 4041148..b493a0d 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -1063,16 +1063,19 @@
"disabled": "已禁用",
"enable": "启用",
"disable": "禁用",
+ "edit": "编辑",
+ "editing": "编辑",
"delete": "删除",
"deleteConfirm": "删除此频道?",
"sortOrder": "排序",
"form": {
"title": "显示名称",
"channelId": "频道ID",
- "channelIdHint": "@username 或 -100XXXXXXXXXX",
+ "channelIdHint": "-100XXXXXXXXXX",
"channelLink": "频道链接",
"channelLinkHint": "https://t.me/channel",
"submit": "添加",
+ "save": "保存",
"cancel": "取消"
}
},
diff --git a/src/pages/AdminChannelSubscriptions.tsx b/src/pages/AdminChannelSubscriptions.tsx
index fcef8e0..ba37fca 100644
--- a/src/pages/AdminChannelSubscriptions.tsx
+++ b/src/pages/AdminChannelSubscriptions.tsx
@@ -6,6 +6,7 @@ import {
adminChannelsApi,
type RequiredChannel,
type CreateChannelRequest,
+ type UpdateChannelRequest,
} from '../api/adminChannels';
import { AdminBackButton } from '../components/admin';
@@ -58,6 +59,16 @@ const XIcon = () => (
);
+const EditIcon = () => (
+
+
+
+);
+
const LinkIcon = () => (
void;
onDelete: (id: number) => void;
+ onEdit: (channel: RequiredChannel) => void;
}) {
const { t } = useTranslation();
@@ -137,6 +150,14 @@ function ChannelCard({
{/* Action buttons */}
+
+
{channel.is_active ? (