From 4d14e3e8062c321e56fc37e79ed6cc16fa83df2a Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 27 Feb 2026 03:14:15 +0300 Subject: [PATCH] feat: add fullscreen QR code for subscription connection - New ConnectionQR page with fullscreen QR display (no modal) - QR button in InstallationGuide header next to platform selector - Respects hideLink setting: shows URL below QR only when visible - i18n translations for ru, en, zh, fa --- package-lock.json | 10 ++ package.json | 1 + src/App.tsx | 9 ++ .../connection/InstallationGuide.tsx | 27 ++++ src/locales/en.json | 5 +- src/locales/fa.json | 5 +- src/locales/ru.json | 5 +- src/locales/zh.json | 5 +- src/pages/Connection.tsx | 10 ++ src/pages/ConnectionQR.tsx | 116 ++++++++++++++++++ 10 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 src/pages/ConnectionQR.tsx diff --git a/package-lock.json b/package-lock.json index de67f80..ad8a110 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "i18next": "^25.8.4", "i18next-browser-languagedetector": "^8.2.0", "jsencrypt": "^3.5.4", + "qrcode.react": "^4.2.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-i18next": "^16.5.4", @@ -5673,6 +5674,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/package.json b/package.json index 4d9b4d1..24e2a15 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "i18next": "^25.8.4", "i18next-browser-languagedetector": "^8.2.0", "jsencrypt": "^3.5.4", + "qrcode.react": "^4.2.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-i18next": "^16.5.4", diff --git a/src/App.tsx b/src/App.tsx index 916a3bf..aee6f56 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,7 @@ const Polls = lazy(() => import('./pages/Polls')); const Info = lazy(() => import('./pages/Info')); const Wheel = lazy(() => import('./pages/Wheel')); const Connection = lazy(() => import('./pages/Connection')); +const ConnectionQR = lazy(() => import('./pages/ConnectionQR')); const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect')); const TopUpAmount = lazy(() => import('./pages/TopUpAmount')); @@ -322,6 +323,14 @@ function App() { } /> + + + + } + /> void; isTelegramWebApp: boolean; onGoBack: () => void; + onOpenQR?: () => void; } export default function InstallationGuide({ @@ -50,6 +51,7 @@ export default function InstallationGuide({ onOpenDeepLink, isTelegramWebApp, onGoBack, + onOpenQR, }: Props) { const { t, i18n } = useTranslation(); const { isLight } = useTheme(); @@ -206,6 +208,31 @@ export default function InstallationGuide({

{getBaseTranslation('installationGuideHeader', 'subscription.connection.title')}

+ {appConfig.subscriptionUrl && onOpenQR && ( + + )} {availablePlatforms.length > 1 && (
{currentPlatformSvg && ( diff --git a/src/locales/en.json b/src/locales/en.json index 15f991c..66840dd 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -422,7 +422,10 @@ "notConfigured": "Connection methods are not configured yet", "notConfiguredUser": "Connection setup is in progress. Please check back later.", "notConfiguredAdmin": "No connection apps have been configured. Set them up in the Apps settings.", - "goToApps": "Go to Apps settings" + "goToApps": "Go to Apps settings", + "qrTitle": "Subscription QR Code", + "qrScanHint": "Scan to connect", + "qrButton": "QR Code" }, "myDevices": "My Devices", "noDevices": "No connected devices", diff --git a/src/locales/fa.json b/src/locales/fa.json index f09239c..ea719c6 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -327,7 +327,10 @@ "notConfigured": "روش‌های اتصال هنوز پیکربندی نشده‌اند", "notConfiguredUser": "تنظیمات اتصال در حال انجام است. لطفاً بعداً مراجعه کنید.", "notConfiguredAdmin": "هیچ برنامه اتصالی پیکربندی نشده است. آن‌ها را در تنظیمات برنامه‌ها تنظیم کنید.", - "goToApps": "رفتن به تنظیمات برنامه‌ها" + "goToApps": "رفتن به تنظیمات برنامه‌ها", + "qrTitle": "کد QR اشتراک", + "qrScanHint": "برای اتصال با دوربین اسکن کنید", + "qrButton": "کد QR" }, "additionalOptions": { "title": "گزینه‌های اضافی", diff --git a/src/locales/ru.json b/src/locales/ru.json index f4b01bb..c5bae48 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -445,7 +445,10 @@ "notConfigured": "Способы подключения ещё не настроены", "notConfiguredUser": "Настройка подключения в процессе. Пожалуйста, загляните позже.", "notConfiguredAdmin": "Приложения для подключения не настроены. Настройте их в разделе Приложения.", - "goToApps": "Перейти к настройке приложений" + "goToApps": "Перейти к настройке приложений", + "qrTitle": "QR-код подписки", + "qrScanHint": "Отсканируйте для подключения", + "qrButton": "QR-код" }, "myDevices": "Мои устройства", "noDevices": "Нет подключенных устройств", diff --git a/src/locales/zh.json b/src/locales/zh.json index 5f6537f..a3dab1f 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -327,7 +327,10 @@ "notConfigured": "连接方式尚未配置", "notConfiguredUser": "连接设置正在进行中,请稍后再来查看。", "notConfiguredAdmin": "尚未配置连接应用。请在应用设置中进行配置。", - "goToApps": "前往应用设置" + "goToApps": "前往应用设置", + "qrTitle": "订阅二维码", + "qrScanHint": "使用相机扫描以连接", + "qrButton": "二维码" }, "additionalOptions": { "title": "附加选项", diff --git a/src/pages/Connection.tsx b/src/pages/Connection.tsx index 2b250cb..21e7e9b 100644 --- a/src/pages/Connection.tsx +++ b/src/pages/Connection.tsx @@ -35,6 +35,15 @@ export default function Connection() { navigate(-1); }, [navigate]); + const handleOpenQR = useCallback(() => { + navigate('/connection/qr', { + state: { + url: appConfig?.subscriptionUrl, + hideLink: appConfig?.hideLink ?? false, + }, + }); + }, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { @@ -184,6 +193,7 @@ export default function Connection() { onOpenDeepLink={openDeepLink} isTelegramWebApp={isTelegramWebApp} onGoBack={handleGoBack} + onOpenQR={handleOpenQR} /> ); } diff --git a/src/pages/ConnectionQR.tsx b/src/pages/ConnectionQR.tsx new file mode 100644 index 0000000..b09cffe --- /dev/null +++ b/src/pages/ConnectionQR.tsx @@ -0,0 +1,116 @@ +import { useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { QRCodeSVG } from 'qrcode.react'; +import { useAuthStore } from '../store/auth'; +import { useBranding } from '../hooks/useBranding'; + +interface ConnectionQRState { + url: string; + hideLink: boolean; +} + +function isValidState(state: unknown): state is ConnectionQRState { + if (!state || typeof state !== 'object') return false; + const s = state as Record; + return typeof s.url === 'string' && s.url.length > 0 && typeof s.hideLink === 'boolean'; +} + +export default function ConnectionQR() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const isLoading = useAuthStore((state) => state.isLoading); + const { appName } = useBranding(); + + const state = location.state as unknown; + const validState = isValidState(state) ? state : null; + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + navigate('/login', { replace: true }); + } + }, [isAuthenticated, isLoading, navigate]); + + useEffect(() => { + if (!isLoading && isAuthenticated && !validState) { + navigate('/connection', { replace: true }); + } + }, [isLoading, isAuthenticated, validState, navigate]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + navigate(-1); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [navigate]); + + if (isLoading || !validState) { + return null; + } + + return ( +
+ {/* Close button */} + + + {/* Content */} +
+ {/* Branding name */} + {appName && ( +

+ {appName} +

+ )} + + {/* Title */} +

+ {t('subscription.connection.qrTitle')} +

+ + {/* Hint */} +

+ {t('subscription.connection.qrScanHint')} +

+ + {/* QR code container */} +
+ +
+ + {/* URL display */} + {!validState.hideLink && ( +

+ {validState.url} +

+ )} +
+
+ ); +}