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": "^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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -327,7 +327,10 @@
|
|||||||
"notConfigured": "روشهای اتصال هنوز پیکربندی نشدهاند",
|
"notConfigured": "روشهای اتصال هنوز پیکربندی نشدهاند",
|
||||||
"notConfiguredUser": "تنظیمات اتصال در حال انجام است. لطفاً بعداً مراجعه کنید.",
|
"notConfiguredUser": "تنظیمات اتصال در حال انجام است. لطفاً بعداً مراجعه کنید.",
|
||||||
"notConfiguredAdmin": "هیچ برنامه اتصالی پیکربندی نشده است. آنها را در تنظیمات برنامهها تنظیم کنید.",
|
"notConfiguredAdmin": "هیچ برنامه اتصالی پیکربندی نشده است. آنها را در تنظیمات برنامهها تنظیم کنید.",
|
||||||
"goToApps": "رفتن به تنظیمات برنامهها"
|
"goToApps": "رفتن به تنظیمات برنامهها",
|
||||||
|
"qrTitle": "کد QR اشتراک",
|
||||||
|
"qrScanHint": "برای اتصال با دوربین اسکن کنید",
|
||||||
|
"qrButton": "کد QR"
|
||||||
},
|
},
|
||||||
"additionalOptions": {
|
"additionalOptions": {
|
||||||
"title": "گزینههای اضافی",
|
"title": "گزینههای اضافی",
|
||||||
|
|||||||
@@ -445,7 +445,10 @@
|
|||||||
"notConfigured": "Способы подключения ещё не настроены",
|
"notConfigured": "Способы подключения ещё не настроены",
|
||||||
"notConfiguredUser": "Настройка подключения в процессе. Пожалуйста, загляните позже.",
|
"notConfiguredUser": "Настройка подключения в процессе. Пожалуйста, загляните позже.",
|
||||||
"notConfiguredAdmin": "Приложения для подключения не настроены. Настройте их в разделе Приложения.",
|
"notConfiguredAdmin": "Приложения для подключения не настроены. Настройте их в разделе Приложения.",
|
||||||
"goToApps": "Перейти к настройке приложений"
|
"goToApps": "Перейти к настройке приложений",
|
||||||
|
"qrTitle": "QR-код подписки",
|
||||||
|
"qrScanHint": "Отсканируйте для подключения",
|
||||||
|
"qrButton": "QR-код"
|
||||||
},
|
},
|
||||||
"myDevices": "Мои устройства",
|
"myDevices": "Мои устройства",
|
||||||
"noDevices": "Нет подключенных устройств",
|
"noDevices": "Нет подключенных устройств",
|
||||||
|
|||||||
@@ -327,7 +327,10 @@
|
|||||||
"notConfigured": "连接方式尚未配置",
|
"notConfigured": "连接方式尚未配置",
|
||||||
"notConfiguredUser": "连接设置正在进行中,请稍后再来查看。",
|
"notConfiguredUser": "连接设置正在进行中,请稍后再来查看。",
|
||||||
"notConfiguredAdmin": "尚未配置连接应用。请在应用设置中进行配置。",
|
"notConfiguredAdmin": "尚未配置连接应用。请在应用设置中进行配置。",
|
||||||
"goToApps": "前往应用设置"
|
"goToApps": "前往应用设置",
|
||||||
|
"qrTitle": "订阅二维码",
|
||||||
|
"qrScanHint": "使用相机扫描以连接",
|
||||||
|
"qrButton": "二维码"
|
||||||
},
|
},
|
||||||
"additionalOptions": {
|
"additionalOptions": {
|
||||||
"title": "附加选项",
|
"title": "附加选项",
|
||||||
|
|||||||
@@ -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
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