diff --git a/src/App.tsx b/src/App.tsx index d827e18..4d87f50 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,10 @@ import { lazy, Suspense } from 'react' import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { useAuthStore } from './store/auth' +import { useBlockingStore } from './store/blocking' import Layout from './components/layout/Layout' import PageLoader from './components/common/PageLoader' +import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking' import { saveReturnUrl } from './utils/token' // Auth pages - load immediately (small) @@ -89,9 +91,25 @@ function LazyPage({ children }: { children: React.ReactNode }) { ) } +function BlockingOverlay() { + const { blockingType } = useBlockingStore() + + if (blockingType === 'maintenance') { + return + } + + if (blockingType === 'channel_subscription') { + return + } + + return null +} + function App() { return ( - + <> + + {/* Public routes */} } /> } /> @@ -316,6 +334,7 @@ function App() { {/* Catch all */} } /> + ) } diff --git a/src/api/client.ts b/src/api/client.ts index 808184c..e65dc3f 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,5 +1,6 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios' import { tokenStorage, isTokenExpired, tokenRefreshManager, safeRedirectToLogin } from '../utils/token' +import { useBlockingStore } from '../store/blocking' const API_BASE_URL = import.meta.env.VITE_API_URL || '/api' @@ -87,12 +88,57 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => return config }) -// Response interceptor - handle 401 as fallback +// Custom error types for special handling +export interface MaintenanceError { + code: 'maintenance' + message: string + reason?: string +} + +export interface ChannelSubscriptionError { + code: 'channel_subscription_required' + message: string + channel_link?: string +} + +export function isMaintenanceError(error: unknown): error is { response: { status: 503, data: { detail: MaintenanceError } } } { + if (!error || typeof error !== 'object') return false + const err = error as AxiosError<{ detail: MaintenanceError }> + return err.response?.status === 503 && err.response?.data?.detail?.code === 'maintenance' +} + +export function isChannelSubscriptionError(error: unknown): error is { response: { status: 403, data: { detail: ChannelSubscriptionError } } } { + if (!error || typeof error !== 'object') return false + const err = error as AxiosError<{ detail: ChannelSubscriptionError }> + return err.response?.status === 403 && err.response?.data?.detail?.code === 'channel_subscription_required' +} + +// Response interceptor - handle 401, 503 (maintenance), 403 (channel subscription) apiClient.interceptors.response.use( (response) => response, async (error: AxiosError) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean } + // Handle maintenance mode (503) + if (isMaintenanceError(error)) { + const detail = (error.response?.data as { detail: MaintenanceError }).detail + useBlockingStore.getState().setMaintenance({ + message: detail.message, + reason: detail.reason, + }) + return Promise.reject(error) + } + + // Handle channel subscription required (403) + if (isChannelSubscriptionError(error)) { + const detail = (error.response?.data as { detail: ChannelSubscriptionError }).detail + useBlockingStore.getState().setChannelSubscription({ + message: detail.message, + channel_link: detail.channel_link, + }) + return Promise.reject(error) + } + // Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала) if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true diff --git a/src/components/blocking/ChannelSubscriptionScreen.tsx b/src/components/blocking/ChannelSubscriptionScreen.tsx new file mode 100644 index 0000000..98788f4 --- /dev/null +++ b/src/components/blocking/ChannelSubscriptionScreen.tsx @@ -0,0 +1,155 @@ +import { useState, useEffect, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useBlockingStore } from '../../store/blocking' +import { apiClient } from '../../api/client' + +const CHECK_COOLDOWN_SECONDS = 5 + +export default function ChannelSubscriptionScreen() { + const { t } = useTranslation() + const { channelInfo, clearBlocking } = useBlockingStore() + const [isChecking, setIsChecking] = useState(false) + const [cooldown, setCooldown] = useState(0) + const [error, setError] = useState(null) + + // Cooldown timer + useEffect(() => { + if (cooldown <= 0) return + + const timer = setInterval(() => { + setCooldown((prev) => { + if (prev <= 1) { + clearInterval(timer) + return 0 + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(timer) + }, [cooldown]) + + const openChannel = useCallback(() => { + if (channelInfo?.channel_link) { + window.open(channelInfo.channel_link, '_blank') + } + }, [channelInfo?.channel_link]) + + const checkSubscription = useCallback(async () => { + if (isChecking || cooldown > 0) return + + 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') { + setError(t('blocking.channel.notSubscribed', 'Вы ещё не подписались на канал')) + } else { + // Other error - might be network issue + setError(t('blocking.channel.checkError', 'Ошибка проверки. Попробуйте позже.')) + } + } finally { + setIsChecking(false) + setCooldown(CHECK_COOLDOWN_SECONDS) + } + }, [isChecking, cooldown, clearBlocking, t]) + + return ( +
+
+ {/* Icon */} +
+
+ + + +
+
+ + {/* Title */} +

+ {t('blocking.channel.title', 'Подписка на канал')} +

+ + {/* Message */} +

+ {channelInfo?.message || t('blocking.channel.defaultMessage', 'Для продолжения работы подпишитесь на наш канал')} +

+ + {/* Error message */} + {error && ( +
+

{error}

+
+ )} + + {/* Buttons */} +
+ {/* Open channel button */} + + + {/* Check subscription button */} + +
+ + {/* Hint */} +

+ {t('blocking.channel.hint', 'После подписки нажмите кнопку проверки')} +

+
+
+ ) +} diff --git a/src/components/blocking/MaintenanceScreen.tsx b/src/components/blocking/MaintenanceScreen.tsx new file mode 100644 index 0000000..a7a052b --- /dev/null +++ b/src/components/blocking/MaintenanceScreen.tsx @@ -0,0 +1,65 @@ +import { useTranslation } from 'react-i18next' +import { useBlockingStore } from '../../store/blocking' + +export default function MaintenanceScreen() { + const { t } = useTranslation() + const { maintenanceInfo } = useBlockingStore() + + return ( +
+
+ {/* Icon */} +
+
+ + + +
+
+ + {/* Title */} +

+ {t('blocking.maintenance.title', 'Технические работы')} +

+ + {/* Message */} +

+ {maintenanceInfo?.message || t('blocking.maintenance.defaultMessage', 'Сервис временно недоступен. Проводятся технические работы.')} +

+ + {/* Reason */} + {maintenanceInfo?.reason && ( +
+

+ {t('blocking.maintenance.reason', 'Причина')}: +

+

+ {maintenanceInfo.reason} +

+
+ )} + + {/* Decorative dots */} +
+
+
+
+
+ +

+ {t('blocking.maintenance.waitMessage', 'Пожалуйста, подождите...')} +

+
+
+ ) +} diff --git a/src/components/blocking/index.ts b/src/components/blocking/index.ts new file mode 100644 index 0000000..c04dc4e --- /dev/null +++ b/src/components/blocking/index.ts @@ -0,0 +1,2 @@ +export { default as MaintenanceScreen } from './MaintenanceScreen' +export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen' diff --git a/src/locales/en.json b/src/locales/en.json index 92e4e72..a2501e5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -33,7 +33,8 @@ "contests": "Contests", "polls": "Polls", "info": "Info", - "wheel": "Fortune Wheel" + "wheel": "Fortune Wheel", + "menu": "Menu" }, "notifications": { "ticketNotifications": "Ticket Notifications", @@ -1314,5 +1315,24 @@ "hours": "h", "minutes": "m" } + }, + "blocking": { + "maintenance": { + "title": "Maintenance", + "defaultMessage": "Service is temporarily unavailable. Maintenance in progress.", + "reason": "Reason", + "waitMessage": "Please wait..." + }, + "channel": { + "title": "Channel Subscription", + "defaultMessage": "Please subscribe to our channel to continue", + "openChannel": "Open Channel", + "checkSubscription": "Check Subscription", + "checking": "Checking...", + "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." + } } } diff --git a/src/locales/fa.json b/src/locales/fa.json index c0f1918..2c14f63 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -794,5 +794,24 @@ "hours": "ساعت", "minutes": "دقیقه" } + }, + "blocking": { + "maintenance": { + "title": "تعمیرات", + "defaultMessage": "سرویس موقتاً در دسترس نیست. تعمیرات در حال انجام است.", + "reason": "دلیل", + "waitMessage": "لطفاً صبر کنید..." + }, + "channel": { + "title": "اشتراک کانال", + "defaultMessage": "لطفاً برای ادامه در کانال ما عضو شوید", + "openChannel": "باز کردن کانال", + "checkSubscription": "بررسی اشتراک", + "checking": "در حال بررسی...", + "waitSeconds": "{{seconds}} ثانیه صبر کنید", + "hint": "پس از عضویت دکمه بررسی را بزنید", + "notSubscribed": "شما هنوز در کانال عضو نشده‌اید", + "checkError": "بررسی ناموفق بود. لطفاً بعداً دوباره امتحان کنید." + } } } \ No newline at end of file diff --git a/src/locales/ru.json b/src/locales/ru.json index 4987412..847c0b2 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -33,7 +33,8 @@ "contests": "Конкурсы", "polls": "Опросы", "info": "Информация", - "wheel": "Колесо удачи" + "wheel": "Колесо удачи", + "menu": "Меню" }, "notifications": { "ticketNotifications": "Уведомления о тикетах", @@ -1472,5 +1473,24 @@ "hours": "ч", "minutes": "м" } + }, + "blocking": { + "maintenance": { + "title": "Технические работы", + "defaultMessage": "Сервис временно недоступен. Проводятся технические работы.", + "reason": "Причина", + "waitMessage": "Пожалуйста, подождите..." + }, + "channel": { + "title": "Подписка на канал", + "defaultMessage": "Для продолжения работы подпишитесь на наш канал", + "openChannel": "Открыть канал", + "checkSubscription": "Проверить подписку", + "checking": "Проверяем...", + "waitSeconds": "Подождите {{seconds}} сек.", + "hint": "После подписки нажмите кнопку проверки", + "notSubscribed": "Вы ещё не подписались на канал", + "checkError": "Ошибка проверки. Попробуйте позже." + } } } diff --git a/src/locales/zh.json b/src/locales/zh.json index 8ce491a..afdaf2c 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -31,7 +31,8 @@ "contests": "活动", "polls": "问卷", "info": "信息", - "wheel": "幸运转盘" + "wheel": "幸运转盘", + "menu": "菜单" }, "notifications": { "ticketNotifications": "工单通知", @@ -794,5 +795,24 @@ "hours": "时", "minutes": "分" } + }, + "blocking": { + "maintenance": { + "title": "系统维护", + "defaultMessage": "服务暂时不可用。正在进行系统维护。", + "reason": "原因", + "waitMessage": "请稍候..." + }, + "channel": { + "title": "频道订阅", + "defaultMessage": "请订阅我们的频道以继续", + "openChannel": "打开频道", + "checkSubscription": "检查订阅", + "checking": "检查中...", + "waitSeconds": "请等待 {{seconds}} 秒", + "hint": "订阅后点击检查按钮", + "notSubscribed": "您还没有订阅该频道", + "checkError": "检查失败。请稍后重试。" + } } } \ No newline at end of file diff --git a/src/store/blocking.ts b/src/store/blocking.ts new file mode 100644 index 0000000..10378bd --- /dev/null +++ b/src/store/blocking.ts @@ -0,0 +1,47 @@ +import { create } from 'zustand' + +export type BlockingType = 'maintenance' | 'channel_subscription' | null + +interface MaintenanceInfo { + message: string + reason?: string +} + +interface ChannelSubscriptionInfo { + message: string + channel_link?: string +} + +interface BlockingState { + blockingType: BlockingType + maintenanceInfo: MaintenanceInfo | null + channelInfo: ChannelSubscriptionInfo | null + + setMaintenance: (info: MaintenanceInfo) => void + setChannelSubscription: (info: ChannelSubscriptionInfo) => void + clearBlocking: () => void +} + +export const useBlockingStore = create((set) => ({ + blockingType: null, + maintenanceInfo: null, + channelInfo: null, + + setMaintenance: (info) => set({ + blockingType: 'maintenance', + maintenanceInfo: info, + channelInfo: null, + }), + + setChannelSubscription: (info) => set({ + blockingType: 'channel_subscription', + channelInfo: info, + maintenanceInfo: null, + }), + + clearBlocking: () => set({ + blockingType: null, + maintenanceInfo: null, + channelInfo: null, + }), +}))