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 && ( -
+

{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 ? ( + +
+ + + ); +} + // Main component export default function AdminChannelSubscriptions() { const { t } = useTranslation(); @@ -268,6 +422,7 @@ export default function AdminChannelSubscriptions() { const haptic = useHaptic(); const notify = useNotify(); const [showAddForm, setShowAddForm] = useState(false); + const [editingChannel, setEditingChannel] = useState(null); // Fetch channels const { data, isLoading, refetch } = useQuery({ @@ -312,6 +467,20 @@ export default function AdminChannelSubscriptions() { }, }); + 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); }; @@ -326,6 +495,15 @@ export default function AdminChannelSubscriptions() { 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 ( @@ -354,7 +532,7 @@ export default function AdminChannelSubscriptions() { > - {!showAddForm && ( + {!showAddForm && !editingChannel && (