mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/connection/qr"
|
||||
element={
|
||||
<LazyPage>
|
||||
<ConnectionQR />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/connection"
|
||||
element={
|
||||
|
||||
@@ -43,6 +43,7 @@ interface Props {
|
||||
onOpenDeepLink: (url: string) => 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({
|
||||
<h2 className="flex-1 text-lg font-bold text-dark-100">
|
||||
{getBaseTranslation('installationGuideHeader', 'subscription.connection.title')}
|
||||
</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 && (
|
||||
<div className="relative flex items-center">
|
||||
{currentPlatformSvg && (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -327,7 +327,10 @@
|
||||
"notConfigured": "روشهای اتصال هنوز پیکربندی نشدهاند",
|
||||
"notConfiguredUser": "تنظیمات اتصال در حال انجام است. لطفاً بعداً مراجعه کنید.",
|
||||
"notConfiguredAdmin": "هیچ برنامه اتصالی پیکربندی نشده است. آنها را در تنظیمات برنامهها تنظیم کنید.",
|
||||
"goToApps": "رفتن به تنظیمات برنامهها"
|
||||
"goToApps": "رفتن به تنظیمات برنامهها",
|
||||
"qrTitle": "کد QR اشتراک",
|
||||
"qrScanHint": "برای اتصال با دوربین اسکن کنید",
|
||||
"qrButton": "کد QR"
|
||||
},
|
||||
"additionalOptions": {
|
||||
"title": "گزینههای اضافی",
|
||||
|
||||
@@ -445,7 +445,10 @@
|
||||
"notConfigured": "Способы подключения ещё не настроены",
|
||||
"notConfiguredUser": "Настройка подключения в процессе. Пожалуйста, загляните позже.",
|
||||
"notConfiguredAdmin": "Приложения для подключения не настроены. Настройте их в разделе Приложения.",
|
||||
"goToApps": "Перейти к настройке приложений"
|
||||
"goToApps": "Перейти к настройке приложений",
|
||||
"qrTitle": "QR-код подписки",
|
||||
"qrScanHint": "Отсканируйте для подключения",
|
||||
"qrButton": "QR-код"
|
||||
},
|
||||
"myDevices": "Мои устройства",
|
||||
"noDevices": "Нет подключенных устройств",
|
||||
|
||||
@@ -327,7 +327,10 @@
|
||||
"notConfigured": "连接方式尚未配置",
|
||||
"notConfiguredUser": "连接设置正在进行中,请稍后再来查看。",
|
||||
"notConfiguredAdmin": "尚未配置连接应用。请在应用设置中进行配置。",
|
||||
"goToApps": "前往应用设置"
|
||||
"goToApps": "前往应用设置",
|
||||
"qrTitle": "订阅二维码",
|
||||
"qrScanHint": "使用相机扫描以连接",
|
||||
"qrButton": "二维码"
|
||||
},
|
||||
"additionalOptions": {
|
||||
"title": "附加选项",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
116
src/pages/ConnectionQR.tsx
Normal file
116
src/pages/ConnectionQR.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user