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
This commit is contained in:
Fringg
2026-02-27 03:14:15 +03:00
parent 821e991f51
commit 4d14e3e806
10 changed files with 189 additions and 4 deletions

10
package-lock.json generated
View File

@@ -36,6 +36,7 @@
"i18next": "^25.8.4", "i18next": "^25.8.4",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"jsencrypt": "^3.5.4", "jsencrypt": "^3.5.4",
"qrcode.react": "^4.2.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",
@@ -5673,6 +5674,15 @@
"node": ">=6" "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": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@@ -44,6 +44,7 @@
"i18next": "^25.8.4", "i18next": "^25.8.4",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"jsencrypt": "^3.5.4", "jsencrypt": "^3.5.4",
"qrcode.react": "^4.2.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",

View File

@@ -35,6 +35,7 @@ const Polls = lazy(() => import('./pages/Polls'));
const Info = lazy(() => import('./pages/Info')); const Info = lazy(() => import('./pages/Info'));
const Wheel = lazy(() => import('./pages/Wheel')); const Wheel = lazy(() => import('./pages/Wheel'));
const Connection = lazy(() => import('./pages/Connection')); const Connection = lazy(() => import('./pages/Connection'));
const ConnectionQR = lazy(() => import('./pages/ConnectionQR'));
const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect')); const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect'));
const TopUpAmount = lazy(() => import('./pages/TopUpAmount')); const TopUpAmount = lazy(() => import('./pages/TopUpAmount'));
@@ -322,6 +323,14 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/connection/qr"
element={
<LazyPage>
<ConnectionQR />
</LazyPage>
}
/>
<Route <Route
path="/connection" path="/connection"
element={ element={

View File

@@ -43,6 +43,7 @@ interface Props {
onOpenDeepLink: (url: string) => void; onOpenDeepLink: (url: string) => void;
isTelegramWebApp: boolean; isTelegramWebApp: boolean;
onGoBack: () => void; onGoBack: () => void;
onOpenQR?: () => void;
} }
export default function InstallationGuide({ export default function InstallationGuide({
@@ -50,6 +51,7 @@ export default function InstallationGuide({
onOpenDeepLink, onOpenDeepLink,
isTelegramWebApp, isTelegramWebApp,
onGoBack, onGoBack,
onOpenQR,
}: Props) { }: Props) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { isLight } = useTheme(); const { isLight } = useTheme();
@@ -206,6 +208,31 @@ export default function InstallationGuide({
<h2 className="flex-1 text-lg font-bold text-dark-100"> <h2 className="flex-1 text-lg font-bold text-dark-100">
{getBaseTranslation('installationGuideHeader', 'subscription.connection.title')} {getBaseTranslation('installationGuideHeader', 'subscription.connection.title')}
</h2> </h2>
{appConfig.subscriptionUrl && onOpenQR && (
<button
onClick={() => onOpenQR()}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 text-dark-200 transition-colors hover:border-dark-600"
>
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.75 6.75h.75v.75h-.75v-.75zM6.75 16.5h.75v.75h-.75v-.75zM16.5 6.75h.75v.75H16.5v-.75zM13.5 13.5h.75v.75h-.75v-.75zM13.5 19.5h.75v.75h-.75v-.75zM19.5 13.5h.75v.75h-.75v-.75zM19.5 19.5h.75v.75h-.75v-.75zM16.5 16.5h3v3h-3v-3z"
/>
</svg>
</button>
)}
{availablePlatforms.length > 1 && ( {availablePlatforms.length > 1 && (
<div className="relative flex items-center"> <div className="relative flex items-center">
{currentPlatformSvg && ( {currentPlatformSvg && (

View File

@@ -422,7 +422,10 @@
"notConfigured": "Connection methods are not configured yet", "notConfigured": "Connection methods are not configured yet",
"notConfiguredUser": "Connection setup is in progress. Please check back later.", "notConfiguredUser": "Connection setup is in progress. Please check back later.",
"notConfiguredAdmin": "No connection apps have been configured. Set them up in the Apps settings.", "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", "myDevices": "My Devices",
"noDevices": "No connected devices", "noDevices": "No connected devices",

View File

@@ -327,7 +327,10 @@
"notConfigured": "روش‌های اتصال هنوز پیکربندی نشده‌اند", "notConfigured": "روش‌های اتصال هنوز پیکربندی نشده‌اند",
"notConfiguredUser": "تنظیمات اتصال در حال انجام است. لطفاً بعداً مراجعه کنید.", "notConfiguredUser": "تنظیمات اتصال در حال انجام است. لطفاً بعداً مراجعه کنید.",
"notConfiguredAdmin": "هیچ برنامه اتصالی پیکربندی نشده است. آن‌ها را در تنظیمات برنامه‌ها تنظیم کنید.", "notConfiguredAdmin": "هیچ برنامه اتصالی پیکربندی نشده است. آن‌ها را در تنظیمات برنامه‌ها تنظیم کنید.",
"goToApps": "رفتن به تنظیمات برنامه‌ها" "goToApps": "رفتن به تنظیمات برنامه‌ها",
"qrTitle": "کد QR اشتراک",
"qrScanHint": "برای اتصال با دوربین اسکن کنید",
"qrButton": "کد QR"
}, },
"additionalOptions": { "additionalOptions": {
"title": "گزینه‌های اضافی", "title": "گزینه‌های اضافی",

View File

@@ -445,7 +445,10 @@
"notConfigured": "Способы подключения ещё не настроены", "notConfigured": "Способы подключения ещё не настроены",
"notConfiguredUser": "Настройка подключения в процессе. Пожалуйста, загляните позже.", "notConfiguredUser": "Настройка подключения в процессе. Пожалуйста, загляните позже.",
"notConfiguredAdmin": "Приложения для подключения не настроены. Настройте их в разделе Приложения.", "notConfiguredAdmin": "Приложения для подключения не настроены. Настройте их в разделе Приложения.",
"goToApps": "Перейти к настройке приложений" "goToApps": "Перейти к настройке приложений",
"qrTitle": "QR-код подписки",
"qrScanHint": "Отсканируйте для подключения",
"qrButton": "QR-код"
}, },
"myDevices": "Мои устройства", "myDevices": "Мои устройства",
"noDevices": "Нет подключенных устройств", "noDevices": "Нет подключенных устройств",

View File

@@ -327,7 +327,10 @@
"notConfigured": "连接方式尚未配置", "notConfigured": "连接方式尚未配置",
"notConfiguredUser": "连接设置正在进行中,请稍后再来查看。", "notConfiguredUser": "连接设置正在进行中,请稍后再来查看。",
"notConfiguredAdmin": "尚未配置连接应用。请在应用设置中进行配置。", "notConfiguredAdmin": "尚未配置连接应用。请在应用设置中进行配置。",
"goToApps": "前往应用设置" "goToApps": "前往应用设置",
"qrTitle": "订阅二维码",
"qrScanHint": "使用相机扫描以连接",
"qrButton": "二维码"
}, },
"additionalOptions": { "additionalOptions": {
"title": "附加选项", "title": "附加选项",

View File

@@ -35,6 +35,15 @@ export default function Connection() {
navigate(-1); navigate(-1);
}, [navigate]); }, [navigate]);
const handleOpenQR = useCallback(() => {
navigate('/connection/qr', {
state: {
url: appConfig?.subscriptionUrl,
hideLink: appConfig?.hideLink ?? false,
},
});
}, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
@@ -184,6 +193,7 @@ export default function Connection() {
onOpenDeepLink={openDeepLink} onOpenDeepLink={openDeepLink}
isTelegramWebApp={isTelegramWebApp} isTelegramWebApp={isTelegramWebApp}
onGoBack={handleGoBack} onGoBack={handleGoBack}
onOpenQR={handleOpenQR}
/> />
); );
} }

116
src/pages/ConnectionQR.tsx Normal file
View File

@@ -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<string, unknown>;
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 (
<div className="fixed inset-0 z-50 flex min-h-dvh flex-col items-center justify-center bg-gradient-to-b from-dark-900 to-dark-950">
{/* Close button */}
<button
type="button"
onClick={() => navigate(-1)}
className="absolute left-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-dark-800/60 backdrop-blur-sm transition-colors hover:bg-dark-700/80"
aria-label={t('common.close')}
>
<svg
className="h-5 w-5 text-dark-200"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Content */}
<div className="flex w-full max-w-sm animate-scale-in flex-col items-center px-6">
{/* Branding name */}
{appName && (
<p className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
{appName}
</p>
)}
{/* Title */}
<h1 className="mb-2 text-center text-xl font-bold text-dark-100">
{t('subscription.connection.qrTitle')}
</h1>
{/* Hint */}
<p className="mb-8 text-center text-sm text-dark-400">
{t('subscription.connection.qrScanHint')}
</p>
{/* QR code container */}
<div className="rounded-3xl bg-white p-6">
<QRCodeSVG
value={validState.url}
size={280}
level="M"
includeMargin={false}
className="h-[280px] w-[280px] sm:h-[360px] sm:w-[360px]"
/>
</div>
{/* URL display */}
{!validState.hideLink && (
<p className="mt-6 max-w-full truncate text-center font-mono text-xs text-dark-500">
{validState.url}
</p>
)}
</div>
</div>
);
}